From 2771cff92b9254e0d58cc331c417b36a0c19d2ec Mon Sep 17 00:00:00 2001 From: wehub-resource-sync Date: Mon, 13 Jul 2026 12:22:56 +0800 Subject: [PATCH] chore: import upstream snapshot with attribution --- .env.local.example | 2 + .github/workflows/ci.yml | 77 + .github/workflows/claude-code-review.yml | 57 + .github/workflows/claude.yml | 50 + .github/workflows/duplicate-issues.yml | 22 + .github/workflows/release.yml | 76 + .github/workflows/stale.yml | 21 + .gitignore | 97 + .node-version | 1 + .npmrc | 2 + AGENTS.md | 107 + CLAUDE.md | 5 + LICENSE | 21 + README.md | 190 + README.wehub.md | 7 + assets/Preview.png | Bin 0 -> 2100957 bytes assets/sponsors/MOMA.png | Bin 0 -> 408145 bytes bun.lock | 1673 +++ esbuild.config.mjs | 195 + eslint.config.mjs | 147 + jest.config.js | 41 + manifest.json | 10 + package-lock.json | 11472 ++++++++++++++++ package.json | 64 + scripts/build-css.mjs | 119 + scripts/build.mjs | 19 + .../check-architecture-boundaries.test.mjs | 59 + scripts/check-release-version.mjs | 37 + scripts/check-release-version.test.mjs | 30 + scripts/postinstall.mjs | 25 + scripts/rendererSafeUnref.js | 205 + scripts/run-jest.js | 19 + scripts/run-tests.js | 29 + scripts/sync-version.js | 16 + .../conversations/ConversationRepository.ts | 317 + src/app/providers/ClaudianProviderHost.ts | 112 + src/app/settings/ClaudianSettingsStorage.ts | 409 + src/app/settings/SettingsCoordinator.ts | 41 + src/app/settings/defaultSettings.ts | 55 + src/app/storage/SharedStorageService.ts | 73 + src/core/AGENTS.md | 61 + src/core/CLAUDE.md | 1 + src/core/auxiliary/AuxQueryRunner.ts | 11 + .../auxiliary/QueryBackedInlineEditService.ts | 73 + .../QueryBackedInstructionRefineService.ts | 78 + .../QueryBackedTitleGenerationService.ts | 90 + src/core/bootstrap/SessionStorage.ts | 218 + src/core/bootstrap/StoragePaths.ts | 7 + src/core/bootstrap/storage.ts | 20 + src/core/bootstrap/tabManagerState.ts | 51 + src/core/commands/builtInCommands.ts | 141 + src/core/mcp/McpConfigParser.ts | 102 + src/core/mcp/McpServerManager.ts | 119 + src/core/mcp/McpTester.ts | 310 + src/core/prompt/inlineEdit.ts | 252 + src/core/prompt/instructionRefine.ts | 72 + src/core/prompt/mainAgent.ts | 213 + src/core/prompt/titleGeneration.ts | 44 + src/core/providers/ProviderHost.ts | 52 + src/core/providers/ProviderRegistry.ts | 262 + .../providers/ProviderSettingsCoordinator.ts | 502 + .../providers/ProviderWorkspaceRegistry.ts | 126 + .../commands/ProviderCommandCatalog.ts | 21 + .../commands/ProviderCommandEntry.ts | 33 + src/core/providers/commands/hiddenCommands.ts | 74 + src/core/providers/conversationModel.ts | 144 + src/core/providers/modelRouting.ts | 17 + src/core/providers/modelSelection.ts | 72 + src/core/providers/providerConfig.ts | 34 + src/core/providers/providerEnvironment.ts | 364 + src/core/providers/reasoning.ts | 14 + src/core/providers/types.ts | 618 + src/core/runtime/ChatRuntime.ts | 66 + src/core/runtime/QueuedTurn.ts | 97 + src/core/runtime/types.ts | 118 + src/core/security/ApprovalManager.ts | 142 + src/core/storage/HomeFileAdapter.ts | 75 + src/core/storage/NotifiedMutationError.ts | 11 + src/core/storage/VaultFileAdapter.ts | 132 + src/core/storage/pathContainment.ts | 51 + src/core/tools/todo.ts | 65 + src/core/tools/toolIcons.ts | 80 + src/core/tools/toolInput.ts | 119 + src/core/tools/toolNames.ts | 149 + src/core/tools/toolResultContent.ts | 26 + src/core/types/agent.ts | 28 + src/core/types/chat.ts | 186 + src/core/types/diff.ts | 31 + src/core/types/index.ts | 78 + src/core/types/mcp.ts | 97 + src/core/types/plugins.ts | 9 + src/core/types/provider.ts | 1 + src/core/types/settings.ts | 152 + src/core/types/tools.ts | 80 + src/features/FeatureHost.ts | 64 + src/features/chat/AGENTS.md | 53 + src/features/chat/CLAUDE.md | 1 + src/features/chat/ClaudianView.ts | 813 ++ src/features/chat/constants.ts | 114 + .../controllers/BrowserSelectionController.ts | 277 + .../controllers/CanvasSelectionController.ts | 141 + .../controllers/ConversationController.ts | 1114 ++ .../chat/controllers/InputController.ts | 1822 +++ .../chat/controllers/NavigationController.ts | 209 + .../chat/controllers/SelectionController.ts | 430 + .../chat/controllers/StreamController.ts | 1578 +++ .../chat/controllers/TurnCoordinator.ts | 30 + src/features/chat/rendering/DiffRenderer.ts | 134 + .../chat/rendering/InlineAskUserQuestion.ts | 702 + .../chat/rendering/InlineExitPlanMode.ts | 263 + .../chat/rendering/InlinePlanApproval.ts | 183 + .../chat/rendering/MessageRenderer.ts | 938 ++ .../chat/rendering/SubagentRenderer.ts | 678 + .../chat/rendering/ThinkingBlockRenderer.ts | 126 + .../chat/rendering/TodoListRenderer.ts | 5 + .../chat/rendering/ToolCallRenderer.ts | 1161 ++ .../chat/rendering/WriteEditRenderer.ts | 232 + src/features/chat/rendering/collapsible.ts | 101 + .../rendering/subagentLifecycleResolution.ts | 25 + src/features/chat/rendering/todoUtils.ts | 29 + src/features/chat/rewind.ts | 31 + src/features/chat/services/BangBashService.ts | 56 + .../chat/services/MentionCacheCoordinator.ts | 26 + src/features/chat/services/SubagentManager.ts | 1107 ++ src/features/chat/state/ChatState.ts | 436 + src/features/chat/state/types.ts | 138 + src/features/chat/tabs/RuntimeSupervisor.ts | 22 + src/features/chat/tabs/Tab.ts | 2031 +++ src/features/chat/tabs/TabBar.ts | 191 + src/features/chat/tabs/TabManager.ts | 1065 ++ src/features/chat/tabs/TabSession.ts | 28 + src/features/chat/tabs/providerResolution.ts | 34 + src/features/chat/tabs/types.ts | 293 + src/features/chat/ui/BangBashModeManager.ts | 121 + src/features/chat/ui/ComposerContextTray.ts | 261 + src/features/chat/ui/FileContext.ts | 392 + src/features/chat/ui/ImageContext.ts | 335 + src/features/chat/ui/InputToolbar.ts | 1247 ++ .../chat/ui/InstructionModeManager.ts | 158 + src/features/chat/ui/NavigationSidebar.ts | 284 + src/features/chat/ui/StatusPanel.ts | 601 + .../ui/file-context/state/FileContextState.ts | 83 + .../ui/file-context/view/FileChipsView.ts | 40 + src/features/chat/ui/textareaResize.ts | 47 + .../chat/utils/conversationDirectoryTitle.ts | 11 + src/features/chat/utils/usageInfo.ts | 26 + .../inline-edit/ui/InlineEditModal.ts | 1017 ++ .../ui/inlineEditMarkdownPreview.ts | 55 + src/features/settings/ClaudianSettings.ts | 684 + src/features/settings/keyboardNavigation.ts | 60 + src/i18n/constants.ts | 58 + src/i18n/i18n.ts | 140 + src/i18n/locales/de.json | 446 + src/i18n/locales/en.json | 446 + src/i18n/locales/es.json | 446 + src/i18n/locales/fr.json | 446 + src/i18n/locales/ja.json | 446 + src/i18n/locales/ko.json | 446 + src/i18n/locales/pt.json | 446 + src/i18n/locales/ru.json | 446 + src/i18n/locales/zh-CN.json | 446 + src/i18n/locales/zh-TW.json | 446 + src/i18n/types.ts | 15 + src/main.ts | 703 + src/providers/acp/AcpClientConnection.ts | 361 + src/providers/acp/AcpJsonRpcTransport.ts | 431 + src/providers/acp/AcpSessionConfig.ts | 139 + .../acp/AcpSessionUpdateNormalizer.ts | 371 + src/providers/acp/AcpSubprocess.ts | 160 + src/providers/acp/AcpToolStreamAdapter.ts | 132 + src/providers/acp/buildAcpUsageInfo.ts | 41 + src/providers/acp/index.ts | 9 + src/providers/acp/methodNames.ts | 50 + src/providers/acp/types.ts | 566 + src/providers/claude/AGENTS.md | 33 + src/providers/claude/CLAUDE.md | 1 + src/providers/claude/agents/AgentManager.ts | 230 + src/providers/claude/agents/AgentStorage.ts | 101 + .../claude/app/ClaudeWorkspaceServices.ts | 105 + .../auxiliary/ClaudeInlineEditService.ts | 129 + .../ClaudeInstructionRefineService.ts | 90 + .../auxiliary/ClaudeTitleGenerationService.ts | 129 + .../claude/auxiliary/extractAssistantText.ts | 28 + src/providers/claude/capabilities.ts | 17 + src/providers/claude/cli/findClaudeCLIPath.ts | 261 + .../claude/commands/ClaudeCommandCatalog.ts | 149 + .../claude/commands/probeRuntimeCommands.ts | 86 + .../claude/config/ClaudeConfigDir.ts | 52 + .../claude/env/ClaudeSettingsReconciler.ts | 90 + src/providers/claude/env/claudeModelEnv.ts | 86 + .../ClaudeConversationHistoryService.ts | 737 + .../claude/history/ClaudeHistoryStore.ts | 191 + .../claude/history/sdkAsyncSubagent.ts | 92 + .../claude/history/sdkBranchFilter.ts | 271 + .../claude/history/sdkHistoryTypes.ts | 61 + .../claude/history/sdkMessageParsing.ts | 424 + .../claude/history/sdkSessionPaths.ts | 236 + .../claude/history/sdkSubagentSidecar.ts | 281 + src/providers/claude/hooks/SubagentHooks.ts | 31 + src/providers/claude/modelLabels.ts | 77 + src/providers/claude/modelOptions.ts | 109 + src/providers/claude/modelSelection.ts | 17 + src/providers/claude/plugins/PluginManager.ts | 206 + .../claude/prompt/ClaudeTurnEncoder.ts | 46 + src/providers/claude/registration.ts | 69 + .../claude/runtime/ClaudeApprovalHandler.ts | 158 + .../claude/runtime/ClaudeChatRuntime.ts | 1985 +++ .../claude/runtime/ClaudeCliResolver.ts | 94 + .../claude/runtime/ClaudeDynamicUpdates.ts | 164 + .../claude/runtime/ClaudeMessageChannel.ts | 209 + .../runtime/ClaudeQueryOptionsBuilder.ts | 315 + .../claude/runtime/ClaudeRewindService.ts | 229 + .../claude/runtime/ClaudeSessionManager.ts | 92 + .../runtime/ClaudeTaskResultInterpreter.ts | 172 + .../runtime/ClaudeUserMessageFactory.ts | 83 + .../claude/runtime/claudeColdStartQuery.ts | 152 + src/providers/claude/runtime/customSpawn.ts | 89 + src/providers/claude/runtime/types.ts | 134 + src/providers/claude/sdk/messages.ts | 17 + src/providers/claude/sdk/toolResultContent.ts | 4 + src/providers/claude/sdk/typeGuards.ts | 14 + src/providers/claude/sdk/types.ts | 15 + .../security/ClaudePermissionUpdates.ts | 77 + src/providers/claude/settings.ts | 128 + .../claude/storage/AgentVaultStorage.ts | 101 + .../claude/storage/CCSettingsStorage.ts | 182 + .../claude/storage/ClaudianSettingsStorage.ts | 6 + src/providers/claude/storage/McpStorage.ts | 145 + .../claude/storage/SessionStorage.ts | 5 + src/providers/claude/storage/SkillStorage.ts | 61 + .../claude/storage/SlashCommandStorage.ts | 96 + .../claude/storage/StorageService.ts | 151 + .../claude/stream/toolInputStreamState.ts | 318 + .../claude/stream/transformClaudeMessage.ts | 594 + src/providers/claude/types/agent.ts | 2 + src/providers/claude/types/models.ts | 167 + src/providers/claude/types/plugins.ts | 14 + src/providers/claude/types/providerState.ts | 39 + src/providers/claude/types/settings.ts | 98 + src/providers/claude/ui/AgentSettings.ts | 389 + src/providers/claude/ui/ClaudeChatUIConfig.ts | 109 + src/providers/claude/ui/ClaudeSettingsTab.ts | 357 + .../claude/ui/PluginSettingsManager.ts | 164 + .../claude/ui/SlashCommandSettings.ts | 527 + src/providers/codex/AGENTS.md | 41 + src/providers/codex/CLAUDE.md | 1 + .../codex/agents/CodexAgentMentionProvider.ts | 33 + .../codex/app/CodexWorkspaceServices.ts | 129 + .../codex/auxiliary/CodexInlineEditService.ts | 9 + .../CodexInstructionRefineService.ts | 9 + .../auxiliary/CodexTaskResultInterpreter.ts | 29 + .../auxiliary/CodexTitleGenerationService.ts | 22 + src/providers/codex/capabilities.ts | 16 + src/providers/codex/codexUserText.ts | 108 + .../codex/commands/CodexSkillCatalog.ts | 180 + .../codex/env/CodexSettingsReconciler.ts | 68 + .../CodexConversationHistoryService.ts | 269 + .../codex/history/CodexHistoryPathResolver.ts | 170 + .../codex/history/CodexHistoryStore.ts | 1811 +++ src/providers/codex/modelOptions.ts | 216 + src/providers/codex/modelSelection.ts | 21 + src/providers/codex/models.ts | 213 + .../codexSubagentNormalization.ts | 227 + .../normalization/codexToolNormalization.ts | 719 + src/providers/codex/prompt/encodeCodexTurn.ts | 57 + src/providers/codex/registration.ts | 50 + .../codex/runtime/CodexAppServerProcess.ts | 132 + .../codex/runtime/CodexAuxQueryRunner.ts | 187 + .../codex/runtime/CodexBinaryLocator.ts | 163 + .../codex/runtime/CodexChatRuntime.ts | 1382 ++ .../codex/runtime/CodexCliResolver.ts | 102 + .../runtime/CodexExecutionTargetResolver.ts | 138 + .../codex/runtime/CodexLaunchSpecBuilder.ts | 86 + .../runtime/CodexModelDiscoveryService.ts | 74 + .../codex/runtime/CodexNotificationRouter.ts | 1126 ++ .../codex/runtime/CodexPathMapper.ts | 155 + .../codex/runtime/CodexRpcTransport.ts | 188 + .../codex/runtime/CodexRuntimeContext.ts | 109 + .../codex/runtime/CodexServerRequestRouter.ts | 331 + .../codex/runtime/CodexSessionManager.ts | 39 + .../codex/runtime/codexAppServerSupport.ts | 66 + .../codex/runtime/codexAppServerTypes.ts | 740 + .../codex/runtime/codexLaunchTypes.ts | 30 + src/providers/codex/settings.ts | 607 + .../codex/skills/CodexSkillListingService.ts | 173 + .../codex/storage/CodexSkillStorage.ts | 250 + .../codex/storage/CodexSubagentStorage.ts | 212 + src/providers/codex/types/index.ts | 16 + src/providers/codex/types/models.ts | 21 + src/providers/codex/types/subagent.ts | 23 + src/providers/codex/ui/CodexChatUIConfig.ts | 190 + src/providers/codex/ui/CodexModelPicker.ts | 124 + src/providers/codex/ui/CodexSettingsTab.ts | 343 + src/providers/codex/ui/CodexSkillSettings.ts | 276 + .../codex/ui/CodexSubagentSettings.ts | 408 + src/providers/defaultProviderConfigs.ts | 14 + src/providers/index.ts | 29 + src/providers/opencode/AGENTS.md | 37 + src/providers/opencode/CLAUDE.md | 1 + .../agents/OpencodeAgentMentionProvider.ts | 42 + .../app/OpencodeRuntimeCommandLoader.ts | 67 + .../opencode/app/OpencodeWorkspaceServices.ts | 55 + .../auxiliary/OpencodeInlineEditService.ts | 13 + .../OpencodeInstructionRefineService.ts | 12 + .../OpencodeTaskResultInterpreter.ts | 29 + .../OpencodeTitleGenerationService.ts | 27 + src/providers/opencode/capabilities.ts | 16 + .../commands/OpencodeCommandCatalog.ts | 92 + src/providers/opencode/discoveryState.ts | 135 + .../env/OpencodeSettingsReconciler.ts | 178 + .../OpencodeConversationHistoryService.ts | 101 + .../history/OpencodeHistoryPathResolver.ts | 36 + .../opencode/history/OpencodeHistoryStore.ts | 508 + .../opencode/history/OpencodeSqliteReader.ts | 308 + .../opencode/internal/compareCollections.ts | 72 + .../opencode/internal/providerProjection.ts | 15 + src/providers/opencode/models.ts | 396 + src/providers/opencode/modes.ts | 150 + .../opencodeToolNormalization.ts | 406 + src/providers/opencode/registration.ts | 37 + .../runtime/OpencodeAuxQueryRunner.ts | 436 + .../opencode/runtime/OpencodeChatRuntime.ts | 1874 +++ .../opencode/runtime/OpencodeCliResolver.ts | 57 + .../runtime/OpencodeLaunchArtifacts.ts | 231 + .../opencode/runtime/OpencodePaths.ts | 113 + .../runtime/OpencodeRuntimeEnvironment.ts | 18 + .../opencode/runtime/buildOpencodePrompt.ts | 66 + src/providers/opencode/settings.ts | 430 + .../opencode/storage/OpencodeAgentStorage.ts | 346 + src/providers/opencode/types/agent.ts | 37 + src/providers/opencode/types/index.ts | 9 + .../opencode/ui/OpencodeAgentSettings.ts | 579 + .../opencode/ui/OpencodeChatUIConfig.ts | 311 + .../opencode/ui/OpencodeSettingsTab.ts | 320 + src/providers/pi/AGENTS.md | 37 + src/providers/pi/CLAUDE.md | 1 + .../pi/app/PiRuntimeCommandLoader.ts | 57 + src/providers/pi/app/PiWorkspaceServices.ts | 39 + .../pi/auxiliary/PiInlineEditService.ts | 9 + .../auxiliary/PiInstructionRefineService.ts | 9 + .../pi/auxiliary/PiTaskResultInterpreter.ts | 29 + .../pi/auxiliary/PiTitleGenerationService.ts | 19 + src/providers/pi/capabilities.ts | 16 + src/providers/pi/commands/PiCommandCatalog.ts | 92 + src/providers/pi/env/PiSettingsReconciler.ts | 180 + .../history/PiConversationHistoryService.ts | 167 + .../pi/history/PiHistoryPathResolver.ts | 73 + src/providers/pi/history/PiHistoryStore.ts | 718 + .../pi/internal/compareCollections.ts | 4 + .../pi/internal/providerProjection.ts | 37 + src/providers/pi/models.ts | 307 + .../pi/normalizations/piEventNormalization.ts | 211 + .../pi/normalizations/piToolNormalization.ts | 97 + src/providers/pi/registration.ts | 40 + src/providers/pi/runtime/PiAuxQueryRunner.ts | 216 + src/providers/pi/runtime/PiChatRuntime.ts | 1293 ++ src/providers/pi/runtime/PiCliResolver.ts | 53 + .../pi/runtime/PiExtensionUiBridge.ts | 161 + src/providers/pi/runtime/PiJsonl.ts | 71 + src/providers/pi/runtime/PiLaunchSpec.ts | 70 + .../pi/runtime/PiModelDiscoveryService.ts | 92 + src/providers/pi/runtime/PiRpcPayloads.ts | 18 + src/providers/pi/runtime/PiRpcTransport.ts | 243 + src/providers/pi/runtime/PiSubprocess.ts | 164 + src/providers/pi/runtime/buildPiPrompt.ts | 62 + src/providers/pi/runtime/buildPiUsageInfo.ts | 69 + src/providers/pi/settings.ts | 473 + src/providers/pi/types.ts | 64 + .../pi/ui/ObsidianPiExtensionUiRenderer.ts | 251 + src/providers/pi/ui/PiChatUIConfig.ts | 265 + src/providers/pi/ui/PiExtensionUiRenderer.ts | 12 + src/providers/pi/ui/PiSettingsTab.ts | 296 + .../components/ResumeSessionDropdown.ts | 185 + src/shared/components/SelectableDropdown.ts | 140 + src/shared/components/SelectionHighlight.ts | 77 + src/shared/components/SlashCommandDropdown.ts | 421 + src/shared/icons.ts | 180 + .../mention/MentionDropdownController.ts | 627 + src/shared/mention/VaultMentionCache.ts | 106 + .../mention/VaultMentionDataProvider.ts | 51 + src/shared/mention/types.ts | 67 + src/shared/modals/ConfirmModal.ts | 60 + src/shared/modals/ForkTargetModal.ts | 47 + src/shared/modals/InstructionConfirmModal.ts | 281 + src/shared/settings/EnvSnippetManager.ts | 438 + .../settings/EnvironmentSettingsSection.ts | 85 + src/shared/settings/McpServerModal.ts | 335 + src/shared/settings/McpSettingsManager.ts | 438 + src/shared/settings/McpTestModal.ts | 351 + src/shared/settings/ProviderModelPicker.ts | 419 + src/style/AGENTS.md | 38 + src/style/CLAUDE.md | 1 + src/style/accessibility.css | 50 + src/style/base/animations.css | 44 + src/style/base/container.css | 20 + src/style/base/variables.css | 46 + src/style/base/visibility.css | 15 + src/style/components/code.css | 97 + src/style/components/context-footer.css | 76 + src/style/components/context-tray.css | 160 + src/style/components/header.css | 27 + src/style/components/history.css | 221 + src/style/components/input.css | 216 + src/style/components/messages.css | 262 + src/style/components/nav-sidebar.css | 124 + src/style/components/status-panel.css | 206 + src/style/components/subagent.css | 248 + src/style/components/tabs.css | 112 + src/style/components/thinking.css | 88 + src/style/components/toolcalls.css | 278 + src/style/features/ask-user-question.css | 315 + src/style/features/diff.css | 197 + src/style/features/file-context.css | 188 + src/style/features/file-link.css | 22 + src/style/features/image-context.css | 73 + src/style/features/image-embed.css | 40 + src/style/features/image-modal.css | 52 + src/style/features/inline-edit.css | 278 + src/style/features/plan-mode.css | 103 + src/style/features/resume-session.css | 119 + src/style/features/slash-commands.css | 91 + src/style/index.css | 64 + src/style/modals/fork-target.css | 21 + src/style/modals/instruction.css | 161 + src/style/modals/mcp-modal.css | 241 + src/style/settings/agent-settings.css | 2 + src/style/settings/base.css | 300 + src/style/settings/env-snippets.css | 366 + src/style/settings/mcp-settings.css | 211 + src/style/settings/plugin-settings.css | 164 + src/style/settings/provider-model-picker.css | 367 + src/style/settings/slash-settings.css | 16 + src/style/toolbar/external-context.css | 177 + src/style/toolbar/mcp-selector.css | 176 + src/style/toolbar/mode-selector.css | 19 + src/style/toolbar/model-selector.css | 99 + src/style/toolbar/permission-toggle.css | 56 + src/style/toolbar/service-tier-toggle.css | 39 + src/style/toolbar/thinking-selector.css | 83 + src/types/smol-toml.d.ts | 4 + src/utils/agent.ts | 50 + src/utils/animationFrame.ts | 46 + src/utils/browser.ts | 46 + src/utils/canvas.ts | 14 + src/utils/cliBinaryLocator.ts | 97 + src/utils/context.ts | 118 + src/utils/contextMentionResolver.ts | 154 + src/utils/date.ts | 31 + src/utils/diff.ts | 384 + src/utils/editor.ts | 104 + src/utils/electronCompat.ts | 53 + src/utils/env.ts | 465 + src/utils/externalContext.ts | 143 + src/utils/externalContextScanner.ts | 135 + src/utils/fileLink.ts | 263 + src/utils/frontmatter.ts | 194 + src/utils/imageAttachment.ts | 80 + src/utils/imageEmbed.ts | 139 + src/utils/inlineEdit.ts | 22 + src/utils/interrupt.ts | 23 + src/utils/markdown.ts | 25 + src/utils/markdownMath.ts | 130 + src/utils/mcp.ts | 96 + src/utils/obsidianCompat.ts | 23 + src/utils/path.ts | 342 + src/utils/session.ts | 253 + src/utils/slashCommand.ts | 152 + src/utils/subagentJsonl.ts | 52 + src/utils/windowsCmdShim.ts | 98 + tests/__mocks__/claude-agent-sdk.ts | 317 + tests/__mocks__/codex-sdk.ts | 88 + tests/__mocks__/obsidian.ts | 434 + tests/fixtures/provider-protocol-child.mjs | 41 + tests/helpers/codexModels.ts | 34 + tests/helpers/mockElement.ts | 398 + tests/helpers/sdkMessages.ts | 291 + .../core/agent/ClaudianService.test.ts | 1843 +++ tests/integration/core/mcp/mcp.test.ts | 905 ++ .../features/chat/imagePersistence.test.ts | 38 + tests/integration/main.test.ts | 2137 +++ .../ProviderLifecycleFixtures.test.ts | 184 + tests/setupWindow.ts | 26 + tests/tsconfig.json | 7 + .../providers/ClaudianProviderHost.test.ts | 76 + .../app/settings/SettingsCoordinator.test.ts | 75 + .../core/bootstrap/tabManagerState.test.ts | 36 + .../core/commands/builtInCommands.test.ts | 239 + tests/unit/core/mcp/McpServerManager.test.ts | 405 + tests/unit/core/mcp/McpTester.test.ts | 282 + tests/unit/core/mcp/createNodeFetch.test.ts | 188 + .../core/providers/ProviderRegistry.test.ts | 276 + .../ProviderSettingsCoordinator.test.ts | 562 + .../ProviderWorkspaceRegistry.test.ts | 84 + .../unit/core/providers/modelRouting.test.ts | 92 + .../core/providers/modelSelection.test.ts | 155 + .../providers/providerEnvironment.test.ts | 162 + .../unit/core/providers/tabLifecycle.test.ts | 218 + .../core/security/ApprovalManager.test.ts | 152 + .../core/storage/VaultFileAdapter.test.ts | 535 + .../unit/core/storage/pathContainment.test.ts | 35 + tests/unit/core/tools/todo.test.ts | 227 + tests/unit/core/tools/toolIcons.test.ts | 75 + tests/unit/core/tools/toolInput.test.ts | 350 + tests/unit/core/tools/toolNames.test.ts | 464 + tests/unit/core/types/mcp.test.ts | 115 + tests/unit/features/chat/ClaudianView.test.ts | 460 + .../BrowserSelectionController.test.ts | 160 + .../CanvasSelectionController.test.ts | 197 + .../ConversationController.test.ts | 2795 ++++ .../chat/controllers/InputController.test.ts | 3369 +++++ .../controllers/NavigationController.test.ts | 640 + .../controllers/SelectionController.test.ts | 676 + .../chat/controllers/StreamController.test.ts | 2534 ++++ .../chat/controllers/TurnCoordinator.test.ts | 31 + .../features/chat/controllers/index.test.ts | 16 + .../chat/rendering/DiffRenderer.test.ts | 355 + .../rendering/InlineAskUserQuestion.test.ts | 1035 ++ .../chat/rendering/InlineExitPlanMode.test.ts | 191 + .../chat/rendering/InlinePlanApproval.test.ts | 126 + .../chat/rendering/MessageRenderer.test.ts | 2100 +++ .../chat/rendering/SubagentRenderer.test.ts | 917 ++ .../rendering/ThinkingBlockRenderer.test.ts | 124 + .../chat/rendering/TodoListRenderer.test.ts | 173 + .../chat/rendering/ToolCallRenderer.test.ts | 909 ++ .../chat/rendering/WriteEditRenderer.test.ts | 474 + .../chat/rendering/collapsible.test.ts | 158 + .../features/chat/rendering/todoUtils.test.ts | 105 + tests/unit/features/chat/rewind.test.ts | 56 + .../chat/services/BangBashService.test.ts | 142 + .../services/InstructionRefineService.test.ts | 371 + .../services/MentionCacheCoordinator.test.ts | 35 + .../chat/services/SubagentManager.test.ts | 1759 +++ .../services/TitleGenerationService.test.ts | 480 + .../features/chat/state/ChatState.test.ts | 581 + .../chat/tabs/RuntimeSupervisor.test.ts | 46 + tests/unit/features/chat/tabs/Tab.test.ts | 4410 ++++++ tests/unit/features/chat/tabs/TabBar.test.ts | 388 + .../features/chat/tabs/TabManager.test.ts | 3024 ++++ tests/unit/features/chat/tabs/index.test.ts | 11 + .../chat/ui/BangBashModeManager.test.ts | 321 + .../chat/ui/ComposerContextTray.test.ts | 156 + .../chat/ui/ExternalContextSelector.test.ts | 555 + .../chat/ui/FileContextManager.test.ts | 876 ++ .../features/chat/ui/ImageContext.test.ts | 765 ++ .../features/chat/ui/InputToolbar.test.ts | 1139 ++ .../chat/ui/InstructionModeManager.test.ts | 243 + .../chat/ui/NavigationSidebar.test.ts | 869 ++ .../unit/features/chat/ui/StatusPanel.test.ts | 964 ++ .../state/FileContextState.test.ts | 155 + .../features/chat/ui/textareaResize.test.ts | 102 + .../features/chat/utils/usageInfo.test.ts | 57 + .../inline-edit/InlineEditService.test.ts | 1223 ++ .../ui/InlineEditModal.openAndWait.test.ts | 1609 +++ .../inline-edit/ui/InlineEditModal.test.ts | 495 + .../inline-edit/ui/InlineEditSession.test.ts | 163 + .../ui/inlineEditMarkdownPreview.test.ts | 92 + .../features/settings/AgentSettings.test.ts | 82 + .../settings/keyboardNavigation.test.ts | 73 + .../settings/ui/CodexSkillSettings.test.ts | 294 + .../settings/ui/CodexSubagentSettings.test.ts | 219 + tests/unit/i18n/constants.test.ts | 43 + tests/unit/i18n/i18n.test.ts | 244 + tests/unit/i18n/locales.test.ts | 150 + .../providers/ProviderModuleCatalog.test.ts | 16 + .../providers/acp/AcpClientConnection.test.ts | 248 + .../providers/acp/AcpJsonRpcTransport.test.ts | 223 + .../providers/acp/AcpSessionConfig.test.ts | 247 + .../acp/AcpSessionUpdateNormalizer.test.ts | 145 + .../unit/providers/acp/AcpSubprocess.test.ts | 123 + .../providers/acp/buildAcpUsageInfo.test.ts | 51 + .../claude/agents/AgentManager.test.ts | 614 + .../claude/agents/AgentStorage.test.ts | 434 + .../providers/claude/agents/index.test.ts | 10 + .../commands/ClaudeCommandCatalog.test.ts | 396 + .../commands/probeRuntimeCommands.test.ts | 92 + .../env/ClaudeSettingsReconciler.test.ts | 151 + .../claude/env/claudeModelEnv.test.ts | 228 + .../ClaudeConversationHistoryService.test.ts | 505 + .../claude/hooks/SubagentHooks.test.ts | 83 + .../claude/plugins/PluginManager.test.ts | 901 ++ .../providers/claude/plugins/index.test.ts | 7 + .../claude/prompt/ClaudeTurnEncoder.test.ts | 145 + .../claude/prompt/instructionRefine.test.ts | 185 + .../claude/prompt/systemPrompt.test.ts | 163 + .../claude/prompt/titleGeneration.test.ts | 20 + .../runtime/ClaudeApprovalHandler.test.ts | 65 + .../ClaudeTaskResultInterpreter.test.ts | 28 + .../claude/runtime/ClaudianService.test.ts | 4132 ++++++ .../claude/runtime/MessageChannel.test.ts | 421 + .../runtime/QueryOptionsBuilder.test.ts | 775 ++ .../claude/runtime/SessionManager.test.ts | 182 + .../runtime/claudeColdStartQuery.test.ts | 331 + .../claude/runtime/customSpawn.test.ts | 374 + .../providers/claude/runtime/index.test.ts | 13 + .../providers/claude/runtime/types.test.ts | 190 + .../providers/claude/sdk/typeGuards.test.ts | 50 + .../security/ClaudePermissionUpdates.test.ts | 231 + .../claude/storage/AgentVaultStorage.test.ts | 413 + .../claude/storage/CCSettingsStorage.test.ts | 433 + .../storage/ClaudianSettingsStorage.test.ts | 809 ++ .../claude/storage/McpStorage.test.ts | 624 + .../claude/storage/SessionStorage.test.ts | 741 + .../claude/storage/SkillStorage.test.ts | 275 + .../storage/SlashCommandStorage.test.ts | 612 + .../providers/claude/storage/storage.test.ts | 360 + .../storageService.convenience.test.ts | 465 + .../claude/stream/transformSDKMessage.test.ts | 1801 +++ .../unit/providers/claude/types/types.test.ts | 727 + .../claude/ui/ClaudeChatUIConfig.test.ts | 202 + .../claude/ui/ClaudeSettingsTab.test.ts | 482 + .../claude/ui/PluginSettingsManager.test.ts | 50 + .../agents/CodexAgentMentionProvider.test.ts | 89 + .../codex/app/CodexWorkspaceServices.test.ts | 154 + .../CodexInstructionRefineService.test.ts | 81 + .../unit/providers/codex/capabilities.test.ts | 39 + .../providers/codex/codexUserText.test.ts | 22 + .../codex/commands/CodexSkillCatalog.test.ts | 413 + .../codex/env/CodexSettingsReconciler.test.ts | 109 + .../codex/fixtures/codex-session-abort.jsonl | 9 + .../codex-session-agent-lifecycle.jsonl | 12 + .../codex-session-persisted-tools.jsonl | 15 + .../codex/fixtures/codex-session-simple.jsonl | 10 + .../codex/fixtures/codex-session-tools.jsonl | 12 + .../codex-session-websearch-persisted.jsonl | 3 + .../fixtures/codex-session-websearch.jsonl | 7 + .../CodexConversationHistoryService.test.ts | 770 ++ .../history/CodexHistoryPathResolver.test.ts | 94 + .../codex/history/CodexHistoryStore.test.ts | 2622 ++++ tests/unit/providers/codex/models.test.ts | 137 + .../codexSubagentNormalization.test.ts | 81 + .../codexToolNormalization.test.ts | 412 + .../codex/prompt/encodeCodexTurn.test.ts | 168 + .../runtime/CodexAppServerProcess.test.ts | 296 + .../codex/runtime/CodexAuxQueryRunner.test.ts | 130 + .../codex/runtime/CodexBinaryLocator.test.ts | 130 + .../codex/runtime/CodexChatRuntime.test.ts | 2517 ++++ .../codex/runtime/CodexCliResolver.test.ts | 133 + .../CodexExecutionTargetResolver.test.ts | 119 + .../runtime/CodexLaunchSpecBuilder.test.ts | 150 + .../CodexModelDiscoveryService.test.ts | 128 + .../runtime/CodexNotificationRouter.test.ts | 1473 ++ .../codex/runtime/CodexPathMapper.test.ts | 51 + .../codex/runtime/CodexRpcTransport.test.ts | 275 + .../codex/runtime/CodexRuntimeContext.test.ts | 107 + .../runtime/CodexServerRequestRouter.test.ts | 537 + .../codex/runtime/CodexSessionManager.test.ts | 82 + .../codex/runtime/codexAppServerTypes.test.ts | 336 + tests/unit/providers/codex/settings.test.ts | 447 + .../skills/CodexSkillListingService.test.ts | 178 + .../codex/storage/CodexSkillStorage.test.ts | 342 + .../storage/CodexSubagentStorage.test.ts | 377 + .../codex/ui/CodexChatUIConfig.test.ts | 575 + .../codex/ui/CodexModelPicker.test.ts | 318 + .../codex/ui/CodexSettingsTab.test.ts | 538 + .../providers/defaultProviderConfigs.test.ts | 18 + .../opencode/OpencodeAuxQueryRunner.test.ts | 355 + .../opencode/OpencodeChatRuntime.test.ts | 1081 ++ .../opencode/OpencodeCliResolver.test.ts | 93 + .../opencode/OpencodeCommandCatalog.test.ts | 75 + ...OpencodeConversationHistoryService.test.ts | 190 + .../opencode/OpencodeHistoryStore.test.ts | 457 + .../opencode/OpencodeLaunchArtifacts.test.ts | 268 + .../providers/opencode/OpencodePaths.test.ts | 35 + .../OpencodeRuntimeCommandLoader.test.ts | 129 + .../OpencodeSettingsReconciler.test.ts | 96 + .../opencode/OpencodeSettingsTab.test.ts | 734 + .../opencode/OpencodeSqliteReader.test.ts | 194 + .../OpencodeAgentMentionProvider.test.ts | 56 + .../opencode/buildOpencodePrompt.test.ts | 87 + .../providers/opencode/capabilities.test.ts | 39 + tests/unit/providers/opencode/models.test.ts | 312 + tests/unit/providers/opencode/modes.test.ts | 151 + .../opencodeToolNormalization.test.ts | 197 + .../unit/providers/opencode/settings.test.ts | 469 + .../storage/OpencodeAgentStorage.test.ts | 377 + .../opencode/ui/OpencodeAgentSettings.test.ts | 91 + .../pi/PiRuntimeCommandLoader.test.ts | 149 + tests/unit/providers/pi/capabilities.test.ts | 20 + .../pi/commands/PiCommandCatalog.test.ts | 69 + .../pi/env/PiSettingsReconciler.test.ts | 61 + .../PiConversationHistoryService.test.ts | 314 + .../pi/history/PiHistoryStore.test.ts | 558 + tests/unit/providers/pi/models.test.ts | 96 + tests/unit/providers/pi/registration.test.ts | 54 + .../pi/runtime/PiAuxQueryRunner.test.ts | 172 + .../pi/runtime/PiChatRuntime.test.ts | 953 ++ .../pi/runtime/PiCliResolver.test.ts | 105 + .../pi/runtime/PiEventNormalization.test.ts | 147 + .../pi/runtime/PiExtensionUiBridge.test.ts | 98 + .../unit/providers/pi/runtime/PiJsonl.test.ts | 42 + .../providers/pi/runtime/PiLaunchSpec.test.ts | 92 + .../runtime/PiModelDiscoveryService.test.ts | 135 + .../pi/runtime/PiRpcPayloads.test.ts | 15 + .../pi/runtime/PiRpcTransport.test.ts | 116 + .../providers/pi/runtime/PiSubprocess.test.ts | 168 + .../pi/runtime/buildPiPrompt.test.ts | 84 + .../pi/runtime/buildPiUsageInfo.test.ts | 69 + tests/unit/providers/pi/settings.test.ts | 253 + .../providers/pi/ui/PiChatUIConfig.test.ts | 178 + .../providers/pi/ui/PiSettingsTab.test.ts | 505 + tests/unit/scripts/rendererSafeUnref.test.ts | 101 + .../components/ResumeSessionDropdown.test.ts | 356 + .../components/SelectableDropdown.test.ts | 406 + .../SlashCommandDropdown.provider.test.ts | 354 + .../components/SlashCommandDropdown.test.ts | 508 + tests/unit/shared/icons.test.ts | 56 + tests/unit/shared/index.test.ts | 46 + .../mention/MentionDropdownController.test.ts | 823 ++ .../shared/mention/VaultFileCache.test.ts | 195 + .../shared/mention/VaultFolderCache.test.ts | 143 + .../mention/VaultMentionDataProvider.test.ts | 87 + tests/unit/shared/modals/ConfirmModal.test.ts | 111 + .../shared/modals/ForkTargetModal.test.ts | 101 + .../modals/InstructionConfirmModal.test.ts | 305 + .../settings/McpSettingsManager.test.ts | 55 + .../unit/shared/settings/McpTestModal.test.ts | 34 + tests/unit/utils/agent.test.ts | 395 + tests/unit/utils/animationFrame.test.ts | 59 + tests/unit/utils/browser.test.ts | 73 + tests/unit/utils/canvas.test.ts | 54 + tests/unit/utils/claudeCli.test.ts | 294 + tests/unit/utils/cliBinaryLocator.test.ts | 33 + tests/unit/utils/context.test.ts | 315 + .../unit/utils/contextMentionResolver.test.ts | 270 + tests/unit/utils/date.test.ts | 80 + tests/unit/utils/diff.test.ts | 291 + tests/unit/utils/editor.test.ts | 249 + tests/unit/utils/electronCompat.test.ts | 87 + tests/unit/utils/env.test.ts | 1240 ++ tests/unit/utils/externalContext.test.ts | 336 + .../unit/utils/externalContextScanner.test.ts | 186 + tests/unit/utils/fileLink.dom.test.ts | 273 + tests/unit/utils/fileLink.handler.test.ts | 64 + tests/unit/utils/fileLink.test.ts | 233 + tests/unit/utils/frontmatter.test.ts | 434 + tests/unit/utils/imageEmbed.test.ts | 407 + tests/unit/utils/inlineEdit.test.ts | 63 + tests/unit/utils/interrupt.test.ts | 73 + tests/unit/utils/markdown.test.ts | 35 + tests/unit/utils/markdownMath.test.ts | 54 + tests/unit/utils/mcp.test.ts | 256 + tests/unit/utils/obsidianCompat.test.ts | 18 + tests/unit/utils/path.test.ts | 677 + tests/unit/utils/sdkSession.test.ts | 2630 ++++ tests/unit/utils/session.test.ts | 985 ++ tests/unit/utils/slashCommand.test.ts | 778 ++ tests/unit/utils/utils.test.ts | 809 ++ tsconfig.jest.json | 8 + tsconfig.json | 26 + versions.json | 4 + 750 files changed, 214822 insertions(+) create mode 100644 .env.local.example create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/claude.yml create mode 100644 .github/workflows/duplicate-issues.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/stale.yml create mode 100644 .gitignore create mode 100644 .node-version create mode 100644 .npmrc create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 README.wehub.md create mode 100644 assets/Preview.png create mode 100644 assets/sponsors/MOMA.png create mode 100644 bun.lock create mode 100644 esbuild.config.mjs create mode 100644 eslint.config.mjs create mode 100644 jest.config.js create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/build-css.mjs create mode 100644 scripts/build.mjs create mode 100644 scripts/check-architecture-boundaries.test.mjs create mode 100644 scripts/check-release-version.mjs create mode 100644 scripts/check-release-version.test.mjs create mode 100644 scripts/postinstall.mjs create mode 100644 scripts/rendererSafeUnref.js create mode 100644 scripts/run-jest.js create mode 100644 scripts/run-tests.js create mode 100644 scripts/sync-version.js create mode 100644 src/app/conversations/ConversationRepository.ts create mode 100644 src/app/providers/ClaudianProviderHost.ts create mode 100644 src/app/settings/ClaudianSettingsStorage.ts create mode 100644 src/app/settings/SettingsCoordinator.ts create mode 100644 src/app/settings/defaultSettings.ts create mode 100644 src/app/storage/SharedStorageService.ts create mode 100644 src/core/AGENTS.md create mode 100644 src/core/CLAUDE.md create mode 100644 src/core/auxiliary/AuxQueryRunner.ts create mode 100644 src/core/auxiliary/QueryBackedInlineEditService.ts create mode 100644 src/core/auxiliary/QueryBackedInstructionRefineService.ts create mode 100644 src/core/auxiliary/QueryBackedTitleGenerationService.ts create mode 100644 src/core/bootstrap/SessionStorage.ts create mode 100644 src/core/bootstrap/StoragePaths.ts create mode 100644 src/core/bootstrap/storage.ts create mode 100644 src/core/bootstrap/tabManagerState.ts create mode 100644 src/core/commands/builtInCommands.ts create mode 100644 src/core/mcp/McpConfigParser.ts create mode 100644 src/core/mcp/McpServerManager.ts create mode 100644 src/core/mcp/McpTester.ts create mode 100644 src/core/prompt/inlineEdit.ts create mode 100644 src/core/prompt/instructionRefine.ts create mode 100644 src/core/prompt/mainAgent.ts create mode 100644 src/core/prompt/titleGeneration.ts create mode 100644 src/core/providers/ProviderHost.ts create mode 100644 src/core/providers/ProviderRegistry.ts create mode 100644 src/core/providers/ProviderSettingsCoordinator.ts create mode 100644 src/core/providers/ProviderWorkspaceRegistry.ts create mode 100644 src/core/providers/commands/ProviderCommandCatalog.ts create mode 100644 src/core/providers/commands/ProviderCommandEntry.ts create mode 100644 src/core/providers/commands/hiddenCommands.ts create mode 100644 src/core/providers/conversationModel.ts create mode 100644 src/core/providers/modelRouting.ts create mode 100644 src/core/providers/modelSelection.ts create mode 100644 src/core/providers/providerConfig.ts create mode 100644 src/core/providers/providerEnvironment.ts create mode 100644 src/core/providers/reasoning.ts create mode 100644 src/core/providers/types.ts create mode 100644 src/core/runtime/ChatRuntime.ts create mode 100644 src/core/runtime/QueuedTurn.ts create mode 100644 src/core/runtime/types.ts create mode 100644 src/core/security/ApprovalManager.ts create mode 100644 src/core/storage/HomeFileAdapter.ts create mode 100644 src/core/storage/NotifiedMutationError.ts create mode 100644 src/core/storage/VaultFileAdapter.ts create mode 100644 src/core/storage/pathContainment.ts create mode 100644 src/core/tools/todo.ts create mode 100644 src/core/tools/toolIcons.ts create mode 100644 src/core/tools/toolInput.ts create mode 100644 src/core/tools/toolNames.ts create mode 100644 src/core/tools/toolResultContent.ts create mode 100644 src/core/types/agent.ts create mode 100644 src/core/types/chat.ts create mode 100644 src/core/types/diff.ts create mode 100644 src/core/types/index.ts create mode 100644 src/core/types/mcp.ts create mode 100644 src/core/types/plugins.ts create mode 100644 src/core/types/provider.ts create mode 100644 src/core/types/settings.ts create mode 100644 src/core/types/tools.ts create mode 100644 src/features/FeatureHost.ts create mode 100644 src/features/chat/AGENTS.md create mode 100644 src/features/chat/CLAUDE.md create mode 100644 src/features/chat/ClaudianView.ts create mode 100644 src/features/chat/constants.ts create mode 100644 src/features/chat/controllers/BrowserSelectionController.ts create mode 100644 src/features/chat/controllers/CanvasSelectionController.ts create mode 100644 src/features/chat/controllers/ConversationController.ts create mode 100644 src/features/chat/controllers/InputController.ts create mode 100644 src/features/chat/controllers/NavigationController.ts create mode 100644 src/features/chat/controllers/SelectionController.ts create mode 100644 src/features/chat/controllers/StreamController.ts create mode 100644 src/features/chat/controllers/TurnCoordinator.ts create mode 100644 src/features/chat/rendering/DiffRenderer.ts create mode 100644 src/features/chat/rendering/InlineAskUserQuestion.ts create mode 100644 src/features/chat/rendering/InlineExitPlanMode.ts create mode 100644 src/features/chat/rendering/InlinePlanApproval.ts create mode 100644 src/features/chat/rendering/MessageRenderer.ts create mode 100644 src/features/chat/rendering/SubagentRenderer.ts create mode 100644 src/features/chat/rendering/ThinkingBlockRenderer.ts create mode 100644 src/features/chat/rendering/TodoListRenderer.ts create mode 100644 src/features/chat/rendering/ToolCallRenderer.ts create mode 100644 src/features/chat/rendering/WriteEditRenderer.ts create mode 100644 src/features/chat/rendering/collapsible.ts create mode 100644 src/features/chat/rendering/subagentLifecycleResolution.ts create mode 100644 src/features/chat/rendering/todoUtils.ts create mode 100644 src/features/chat/rewind.ts create mode 100644 src/features/chat/services/BangBashService.ts create mode 100644 src/features/chat/services/MentionCacheCoordinator.ts create mode 100644 src/features/chat/services/SubagentManager.ts create mode 100644 src/features/chat/state/ChatState.ts create mode 100644 src/features/chat/state/types.ts create mode 100644 src/features/chat/tabs/RuntimeSupervisor.ts create mode 100644 src/features/chat/tabs/Tab.ts create mode 100644 src/features/chat/tabs/TabBar.ts create mode 100644 src/features/chat/tabs/TabManager.ts create mode 100644 src/features/chat/tabs/TabSession.ts create mode 100644 src/features/chat/tabs/providerResolution.ts create mode 100644 src/features/chat/tabs/types.ts create mode 100644 src/features/chat/ui/BangBashModeManager.ts create mode 100644 src/features/chat/ui/ComposerContextTray.ts create mode 100644 src/features/chat/ui/FileContext.ts create mode 100644 src/features/chat/ui/ImageContext.ts create mode 100644 src/features/chat/ui/InputToolbar.ts create mode 100644 src/features/chat/ui/InstructionModeManager.ts create mode 100644 src/features/chat/ui/NavigationSidebar.ts create mode 100644 src/features/chat/ui/StatusPanel.ts create mode 100644 src/features/chat/ui/file-context/state/FileContextState.ts create mode 100644 src/features/chat/ui/file-context/view/FileChipsView.ts create mode 100644 src/features/chat/ui/textareaResize.ts create mode 100644 src/features/chat/utils/conversationDirectoryTitle.ts create mode 100644 src/features/chat/utils/usageInfo.ts create mode 100644 src/features/inline-edit/ui/InlineEditModal.ts create mode 100644 src/features/inline-edit/ui/inlineEditMarkdownPreview.ts create mode 100644 src/features/settings/ClaudianSettings.ts create mode 100644 src/features/settings/keyboardNavigation.ts create mode 100644 src/i18n/constants.ts create mode 100644 src/i18n/i18n.ts create mode 100644 src/i18n/locales/de.json create mode 100644 src/i18n/locales/en.json create mode 100644 src/i18n/locales/es.json create mode 100644 src/i18n/locales/fr.json create mode 100644 src/i18n/locales/ja.json create mode 100644 src/i18n/locales/ko.json create mode 100644 src/i18n/locales/pt.json create mode 100644 src/i18n/locales/ru.json create mode 100644 src/i18n/locales/zh-CN.json create mode 100644 src/i18n/locales/zh-TW.json create mode 100644 src/i18n/types.ts create mode 100644 src/main.ts create mode 100644 src/providers/acp/AcpClientConnection.ts create mode 100644 src/providers/acp/AcpJsonRpcTransport.ts create mode 100644 src/providers/acp/AcpSessionConfig.ts create mode 100644 src/providers/acp/AcpSessionUpdateNormalizer.ts create mode 100644 src/providers/acp/AcpSubprocess.ts create mode 100644 src/providers/acp/AcpToolStreamAdapter.ts create mode 100644 src/providers/acp/buildAcpUsageInfo.ts create mode 100644 src/providers/acp/index.ts create mode 100644 src/providers/acp/methodNames.ts create mode 100644 src/providers/acp/types.ts create mode 100644 src/providers/claude/AGENTS.md create mode 100644 src/providers/claude/CLAUDE.md create mode 100644 src/providers/claude/agents/AgentManager.ts create mode 100644 src/providers/claude/agents/AgentStorage.ts create mode 100644 src/providers/claude/app/ClaudeWorkspaceServices.ts create mode 100644 src/providers/claude/auxiliary/ClaudeInlineEditService.ts create mode 100644 src/providers/claude/auxiliary/ClaudeInstructionRefineService.ts create mode 100644 src/providers/claude/auxiliary/ClaudeTitleGenerationService.ts create mode 100644 src/providers/claude/auxiliary/extractAssistantText.ts create mode 100644 src/providers/claude/capabilities.ts create mode 100644 src/providers/claude/cli/findClaudeCLIPath.ts create mode 100644 src/providers/claude/commands/ClaudeCommandCatalog.ts create mode 100644 src/providers/claude/commands/probeRuntimeCommands.ts create mode 100644 src/providers/claude/config/ClaudeConfigDir.ts create mode 100644 src/providers/claude/env/ClaudeSettingsReconciler.ts create mode 100644 src/providers/claude/env/claudeModelEnv.ts create mode 100644 src/providers/claude/history/ClaudeConversationHistoryService.ts create mode 100644 src/providers/claude/history/ClaudeHistoryStore.ts create mode 100644 src/providers/claude/history/sdkAsyncSubagent.ts create mode 100644 src/providers/claude/history/sdkBranchFilter.ts create mode 100644 src/providers/claude/history/sdkHistoryTypes.ts create mode 100644 src/providers/claude/history/sdkMessageParsing.ts create mode 100644 src/providers/claude/history/sdkSessionPaths.ts create mode 100644 src/providers/claude/history/sdkSubagentSidecar.ts create mode 100644 src/providers/claude/hooks/SubagentHooks.ts create mode 100644 src/providers/claude/modelLabels.ts create mode 100644 src/providers/claude/modelOptions.ts create mode 100644 src/providers/claude/modelSelection.ts create mode 100644 src/providers/claude/plugins/PluginManager.ts create mode 100644 src/providers/claude/prompt/ClaudeTurnEncoder.ts create mode 100644 src/providers/claude/registration.ts create mode 100644 src/providers/claude/runtime/ClaudeApprovalHandler.ts create mode 100644 src/providers/claude/runtime/ClaudeChatRuntime.ts create mode 100644 src/providers/claude/runtime/ClaudeCliResolver.ts create mode 100644 src/providers/claude/runtime/ClaudeDynamicUpdates.ts create mode 100644 src/providers/claude/runtime/ClaudeMessageChannel.ts create mode 100644 src/providers/claude/runtime/ClaudeQueryOptionsBuilder.ts create mode 100644 src/providers/claude/runtime/ClaudeRewindService.ts create mode 100644 src/providers/claude/runtime/ClaudeSessionManager.ts create mode 100644 src/providers/claude/runtime/ClaudeTaskResultInterpreter.ts create mode 100644 src/providers/claude/runtime/ClaudeUserMessageFactory.ts create mode 100644 src/providers/claude/runtime/claudeColdStartQuery.ts create mode 100644 src/providers/claude/runtime/customSpawn.ts create mode 100644 src/providers/claude/runtime/types.ts create mode 100644 src/providers/claude/sdk/messages.ts create mode 100644 src/providers/claude/sdk/toolResultContent.ts create mode 100644 src/providers/claude/sdk/typeGuards.ts create mode 100644 src/providers/claude/sdk/types.ts create mode 100644 src/providers/claude/security/ClaudePermissionUpdates.ts create mode 100644 src/providers/claude/settings.ts create mode 100644 src/providers/claude/storage/AgentVaultStorage.ts create mode 100644 src/providers/claude/storage/CCSettingsStorage.ts create mode 100644 src/providers/claude/storage/ClaudianSettingsStorage.ts create mode 100644 src/providers/claude/storage/McpStorage.ts create mode 100644 src/providers/claude/storage/SessionStorage.ts create mode 100644 src/providers/claude/storage/SkillStorage.ts create mode 100644 src/providers/claude/storage/SlashCommandStorage.ts create mode 100644 src/providers/claude/storage/StorageService.ts create mode 100644 src/providers/claude/stream/toolInputStreamState.ts create mode 100644 src/providers/claude/stream/transformClaudeMessage.ts create mode 100644 src/providers/claude/types/agent.ts create mode 100644 src/providers/claude/types/models.ts create mode 100644 src/providers/claude/types/plugins.ts create mode 100644 src/providers/claude/types/providerState.ts create mode 100644 src/providers/claude/types/settings.ts create mode 100644 src/providers/claude/ui/AgentSettings.ts create mode 100644 src/providers/claude/ui/ClaudeChatUIConfig.ts create mode 100644 src/providers/claude/ui/ClaudeSettingsTab.ts create mode 100644 src/providers/claude/ui/PluginSettingsManager.ts create mode 100644 src/providers/claude/ui/SlashCommandSettings.ts create mode 100644 src/providers/codex/AGENTS.md create mode 100644 src/providers/codex/CLAUDE.md create mode 100644 src/providers/codex/agents/CodexAgentMentionProvider.ts create mode 100644 src/providers/codex/app/CodexWorkspaceServices.ts create mode 100644 src/providers/codex/auxiliary/CodexInlineEditService.ts create mode 100644 src/providers/codex/auxiliary/CodexInstructionRefineService.ts create mode 100644 src/providers/codex/auxiliary/CodexTaskResultInterpreter.ts create mode 100644 src/providers/codex/auxiliary/CodexTitleGenerationService.ts create mode 100644 src/providers/codex/capabilities.ts create mode 100644 src/providers/codex/codexUserText.ts create mode 100644 src/providers/codex/commands/CodexSkillCatalog.ts create mode 100644 src/providers/codex/env/CodexSettingsReconciler.ts create mode 100644 src/providers/codex/history/CodexConversationHistoryService.ts create mode 100644 src/providers/codex/history/CodexHistoryPathResolver.ts create mode 100644 src/providers/codex/history/CodexHistoryStore.ts create mode 100644 src/providers/codex/modelOptions.ts create mode 100644 src/providers/codex/modelSelection.ts create mode 100644 src/providers/codex/models.ts create mode 100644 src/providers/codex/normalization/codexSubagentNormalization.ts create mode 100644 src/providers/codex/normalization/codexToolNormalization.ts create mode 100644 src/providers/codex/prompt/encodeCodexTurn.ts create mode 100644 src/providers/codex/registration.ts create mode 100644 src/providers/codex/runtime/CodexAppServerProcess.ts create mode 100644 src/providers/codex/runtime/CodexAuxQueryRunner.ts create mode 100644 src/providers/codex/runtime/CodexBinaryLocator.ts create mode 100644 src/providers/codex/runtime/CodexChatRuntime.ts create mode 100644 src/providers/codex/runtime/CodexCliResolver.ts create mode 100644 src/providers/codex/runtime/CodexExecutionTargetResolver.ts create mode 100644 src/providers/codex/runtime/CodexLaunchSpecBuilder.ts create mode 100644 src/providers/codex/runtime/CodexModelDiscoveryService.ts create mode 100644 src/providers/codex/runtime/CodexNotificationRouter.ts create mode 100644 src/providers/codex/runtime/CodexPathMapper.ts create mode 100644 src/providers/codex/runtime/CodexRpcTransport.ts create mode 100644 src/providers/codex/runtime/CodexRuntimeContext.ts create mode 100644 src/providers/codex/runtime/CodexServerRequestRouter.ts create mode 100644 src/providers/codex/runtime/CodexSessionManager.ts create mode 100644 src/providers/codex/runtime/codexAppServerSupport.ts create mode 100644 src/providers/codex/runtime/codexAppServerTypes.ts create mode 100644 src/providers/codex/runtime/codexLaunchTypes.ts create mode 100644 src/providers/codex/settings.ts create mode 100644 src/providers/codex/skills/CodexSkillListingService.ts create mode 100644 src/providers/codex/storage/CodexSkillStorage.ts create mode 100644 src/providers/codex/storage/CodexSubagentStorage.ts create mode 100644 src/providers/codex/types/index.ts create mode 100644 src/providers/codex/types/models.ts create mode 100644 src/providers/codex/types/subagent.ts create mode 100644 src/providers/codex/ui/CodexChatUIConfig.ts create mode 100644 src/providers/codex/ui/CodexModelPicker.ts create mode 100644 src/providers/codex/ui/CodexSettingsTab.ts create mode 100644 src/providers/codex/ui/CodexSkillSettings.ts create mode 100644 src/providers/codex/ui/CodexSubagentSettings.ts create mode 100644 src/providers/defaultProviderConfigs.ts create mode 100644 src/providers/index.ts create mode 100644 src/providers/opencode/AGENTS.md create mode 100644 src/providers/opencode/CLAUDE.md create mode 100644 src/providers/opencode/agents/OpencodeAgentMentionProvider.ts create mode 100644 src/providers/opencode/app/OpencodeRuntimeCommandLoader.ts create mode 100644 src/providers/opencode/app/OpencodeWorkspaceServices.ts create mode 100644 src/providers/opencode/auxiliary/OpencodeInlineEditService.ts create mode 100644 src/providers/opencode/auxiliary/OpencodeInstructionRefineService.ts create mode 100644 src/providers/opencode/auxiliary/OpencodeTaskResultInterpreter.ts create mode 100644 src/providers/opencode/auxiliary/OpencodeTitleGenerationService.ts create mode 100644 src/providers/opencode/capabilities.ts create mode 100644 src/providers/opencode/commands/OpencodeCommandCatalog.ts create mode 100644 src/providers/opencode/discoveryState.ts create mode 100644 src/providers/opencode/env/OpencodeSettingsReconciler.ts create mode 100644 src/providers/opencode/history/OpencodeConversationHistoryService.ts create mode 100644 src/providers/opencode/history/OpencodeHistoryPathResolver.ts create mode 100644 src/providers/opencode/history/OpencodeHistoryStore.ts create mode 100644 src/providers/opencode/history/OpencodeSqliteReader.ts create mode 100644 src/providers/opencode/internal/compareCollections.ts create mode 100644 src/providers/opencode/internal/providerProjection.ts create mode 100644 src/providers/opencode/models.ts create mode 100644 src/providers/opencode/modes.ts create mode 100644 src/providers/opencode/normalization/opencodeToolNormalization.ts create mode 100644 src/providers/opencode/registration.ts create mode 100644 src/providers/opencode/runtime/OpencodeAuxQueryRunner.ts create mode 100644 src/providers/opencode/runtime/OpencodeChatRuntime.ts create mode 100644 src/providers/opencode/runtime/OpencodeCliResolver.ts create mode 100644 src/providers/opencode/runtime/OpencodeLaunchArtifacts.ts create mode 100644 src/providers/opencode/runtime/OpencodePaths.ts create mode 100644 src/providers/opencode/runtime/OpencodeRuntimeEnvironment.ts create mode 100644 src/providers/opencode/runtime/buildOpencodePrompt.ts create mode 100644 src/providers/opencode/settings.ts create mode 100644 src/providers/opencode/storage/OpencodeAgentStorage.ts create mode 100644 src/providers/opencode/types/agent.ts create mode 100644 src/providers/opencode/types/index.ts create mode 100644 src/providers/opencode/ui/OpencodeAgentSettings.ts create mode 100644 src/providers/opencode/ui/OpencodeChatUIConfig.ts create mode 100644 src/providers/opencode/ui/OpencodeSettingsTab.ts create mode 100644 src/providers/pi/AGENTS.md create mode 100644 src/providers/pi/CLAUDE.md create mode 100644 src/providers/pi/app/PiRuntimeCommandLoader.ts create mode 100644 src/providers/pi/app/PiWorkspaceServices.ts create mode 100644 src/providers/pi/auxiliary/PiInlineEditService.ts create mode 100644 src/providers/pi/auxiliary/PiInstructionRefineService.ts create mode 100644 src/providers/pi/auxiliary/PiTaskResultInterpreter.ts create mode 100644 src/providers/pi/auxiliary/PiTitleGenerationService.ts create mode 100644 src/providers/pi/capabilities.ts create mode 100644 src/providers/pi/commands/PiCommandCatalog.ts create mode 100644 src/providers/pi/env/PiSettingsReconciler.ts create mode 100644 src/providers/pi/history/PiConversationHistoryService.ts create mode 100644 src/providers/pi/history/PiHistoryPathResolver.ts create mode 100644 src/providers/pi/history/PiHistoryStore.ts create mode 100644 src/providers/pi/internal/compareCollections.ts create mode 100644 src/providers/pi/internal/providerProjection.ts create mode 100644 src/providers/pi/models.ts create mode 100644 src/providers/pi/normalizations/piEventNormalization.ts create mode 100644 src/providers/pi/normalizations/piToolNormalization.ts create mode 100644 src/providers/pi/registration.ts create mode 100644 src/providers/pi/runtime/PiAuxQueryRunner.ts create mode 100644 src/providers/pi/runtime/PiChatRuntime.ts create mode 100644 src/providers/pi/runtime/PiCliResolver.ts create mode 100644 src/providers/pi/runtime/PiExtensionUiBridge.ts create mode 100644 src/providers/pi/runtime/PiJsonl.ts create mode 100644 src/providers/pi/runtime/PiLaunchSpec.ts create mode 100644 src/providers/pi/runtime/PiModelDiscoveryService.ts create mode 100644 src/providers/pi/runtime/PiRpcPayloads.ts create mode 100644 src/providers/pi/runtime/PiRpcTransport.ts create mode 100644 src/providers/pi/runtime/PiSubprocess.ts create mode 100644 src/providers/pi/runtime/buildPiPrompt.ts create mode 100644 src/providers/pi/runtime/buildPiUsageInfo.ts create mode 100644 src/providers/pi/settings.ts create mode 100644 src/providers/pi/types.ts create mode 100644 src/providers/pi/ui/ObsidianPiExtensionUiRenderer.ts create mode 100644 src/providers/pi/ui/PiChatUIConfig.ts create mode 100644 src/providers/pi/ui/PiExtensionUiRenderer.ts create mode 100644 src/providers/pi/ui/PiSettingsTab.ts create mode 100644 src/shared/components/ResumeSessionDropdown.ts create mode 100644 src/shared/components/SelectableDropdown.ts create mode 100644 src/shared/components/SelectionHighlight.ts create mode 100644 src/shared/components/SlashCommandDropdown.ts create mode 100644 src/shared/icons.ts create mode 100644 src/shared/mention/MentionDropdownController.ts create mode 100644 src/shared/mention/VaultMentionCache.ts create mode 100644 src/shared/mention/VaultMentionDataProvider.ts create mode 100644 src/shared/mention/types.ts create mode 100644 src/shared/modals/ConfirmModal.ts create mode 100644 src/shared/modals/ForkTargetModal.ts create mode 100644 src/shared/modals/InstructionConfirmModal.ts create mode 100644 src/shared/settings/EnvSnippetManager.ts create mode 100644 src/shared/settings/EnvironmentSettingsSection.ts create mode 100644 src/shared/settings/McpServerModal.ts create mode 100644 src/shared/settings/McpSettingsManager.ts create mode 100644 src/shared/settings/McpTestModal.ts create mode 100644 src/shared/settings/ProviderModelPicker.ts create mode 100644 src/style/AGENTS.md create mode 100644 src/style/CLAUDE.md create mode 100644 src/style/accessibility.css create mode 100644 src/style/base/animations.css create mode 100644 src/style/base/container.css create mode 100644 src/style/base/variables.css create mode 100644 src/style/base/visibility.css create mode 100644 src/style/components/code.css create mode 100644 src/style/components/context-footer.css create mode 100644 src/style/components/context-tray.css create mode 100644 src/style/components/header.css create mode 100644 src/style/components/history.css create mode 100644 src/style/components/input.css create mode 100644 src/style/components/messages.css create mode 100644 src/style/components/nav-sidebar.css create mode 100644 src/style/components/status-panel.css create mode 100644 src/style/components/subagent.css create mode 100644 src/style/components/tabs.css create mode 100644 src/style/components/thinking.css create mode 100644 src/style/components/toolcalls.css create mode 100644 src/style/features/ask-user-question.css create mode 100644 src/style/features/diff.css create mode 100644 src/style/features/file-context.css create mode 100644 src/style/features/file-link.css create mode 100644 src/style/features/image-context.css create mode 100644 src/style/features/image-embed.css create mode 100644 src/style/features/image-modal.css create mode 100644 src/style/features/inline-edit.css create mode 100644 src/style/features/plan-mode.css create mode 100644 src/style/features/resume-session.css create mode 100644 src/style/features/slash-commands.css create mode 100644 src/style/index.css create mode 100644 src/style/modals/fork-target.css create mode 100644 src/style/modals/instruction.css create mode 100644 src/style/modals/mcp-modal.css create mode 100644 src/style/settings/agent-settings.css create mode 100644 src/style/settings/base.css create mode 100644 src/style/settings/env-snippets.css create mode 100644 src/style/settings/mcp-settings.css create mode 100644 src/style/settings/plugin-settings.css create mode 100644 src/style/settings/provider-model-picker.css create mode 100644 src/style/settings/slash-settings.css create mode 100644 src/style/toolbar/external-context.css create mode 100644 src/style/toolbar/mcp-selector.css create mode 100644 src/style/toolbar/mode-selector.css create mode 100644 src/style/toolbar/model-selector.css create mode 100644 src/style/toolbar/permission-toggle.css create mode 100644 src/style/toolbar/service-tier-toggle.css create mode 100644 src/style/toolbar/thinking-selector.css create mode 100644 src/types/smol-toml.d.ts create mode 100644 src/utils/agent.ts create mode 100644 src/utils/animationFrame.ts create mode 100644 src/utils/browser.ts create mode 100644 src/utils/canvas.ts create mode 100644 src/utils/cliBinaryLocator.ts create mode 100644 src/utils/context.ts create mode 100644 src/utils/contextMentionResolver.ts create mode 100644 src/utils/date.ts create mode 100644 src/utils/diff.ts create mode 100644 src/utils/editor.ts create mode 100644 src/utils/electronCompat.ts create mode 100644 src/utils/env.ts create mode 100644 src/utils/externalContext.ts create mode 100644 src/utils/externalContextScanner.ts create mode 100644 src/utils/fileLink.ts create mode 100644 src/utils/frontmatter.ts create mode 100644 src/utils/imageAttachment.ts create mode 100644 src/utils/imageEmbed.ts create mode 100644 src/utils/inlineEdit.ts create mode 100644 src/utils/interrupt.ts create mode 100644 src/utils/markdown.ts create mode 100644 src/utils/markdownMath.ts create mode 100644 src/utils/mcp.ts create mode 100644 src/utils/obsidianCompat.ts create mode 100644 src/utils/path.ts create mode 100644 src/utils/session.ts create mode 100644 src/utils/slashCommand.ts create mode 100644 src/utils/subagentJsonl.ts create mode 100644 src/utils/windowsCmdShim.ts create mode 100644 tests/__mocks__/claude-agent-sdk.ts create mode 100644 tests/__mocks__/codex-sdk.ts create mode 100644 tests/__mocks__/obsidian.ts create mode 100644 tests/fixtures/provider-protocol-child.mjs create mode 100644 tests/helpers/codexModels.ts create mode 100644 tests/helpers/mockElement.ts create mode 100644 tests/helpers/sdkMessages.ts create mode 100644 tests/integration/core/agent/ClaudianService.test.ts create mode 100644 tests/integration/core/mcp/mcp.test.ts create mode 100644 tests/integration/features/chat/imagePersistence.test.ts create mode 100644 tests/integration/main.test.ts create mode 100644 tests/integration/providers/ProviderLifecycleFixtures.test.ts create mode 100644 tests/setupWindow.ts create mode 100644 tests/tsconfig.json create mode 100644 tests/unit/app/providers/ClaudianProviderHost.test.ts create mode 100644 tests/unit/app/settings/SettingsCoordinator.test.ts create mode 100644 tests/unit/core/bootstrap/tabManagerState.test.ts create mode 100644 tests/unit/core/commands/builtInCommands.test.ts create mode 100644 tests/unit/core/mcp/McpServerManager.test.ts create mode 100644 tests/unit/core/mcp/McpTester.test.ts create mode 100644 tests/unit/core/mcp/createNodeFetch.test.ts create mode 100644 tests/unit/core/providers/ProviderRegistry.test.ts create mode 100644 tests/unit/core/providers/ProviderSettingsCoordinator.test.ts create mode 100644 tests/unit/core/providers/ProviderWorkspaceRegistry.test.ts create mode 100644 tests/unit/core/providers/modelRouting.test.ts create mode 100644 tests/unit/core/providers/modelSelection.test.ts create mode 100644 tests/unit/core/providers/providerEnvironment.test.ts create mode 100644 tests/unit/core/providers/tabLifecycle.test.ts create mode 100644 tests/unit/core/security/ApprovalManager.test.ts create mode 100644 tests/unit/core/storage/VaultFileAdapter.test.ts create mode 100644 tests/unit/core/storage/pathContainment.test.ts create mode 100644 tests/unit/core/tools/todo.test.ts create mode 100644 tests/unit/core/tools/toolIcons.test.ts create mode 100644 tests/unit/core/tools/toolInput.test.ts create mode 100644 tests/unit/core/tools/toolNames.test.ts create mode 100644 tests/unit/core/types/mcp.test.ts create mode 100644 tests/unit/features/chat/ClaudianView.test.ts create mode 100644 tests/unit/features/chat/controllers/BrowserSelectionController.test.ts create mode 100644 tests/unit/features/chat/controllers/CanvasSelectionController.test.ts create mode 100644 tests/unit/features/chat/controllers/ConversationController.test.ts create mode 100644 tests/unit/features/chat/controllers/InputController.test.ts create mode 100644 tests/unit/features/chat/controllers/NavigationController.test.ts create mode 100644 tests/unit/features/chat/controllers/SelectionController.test.ts create mode 100644 tests/unit/features/chat/controllers/StreamController.test.ts create mode 100644 tests/unit/features/chat/controllers/TurnCoordinator.test.ts create mode 100644 tests/unit/features/chat/controllers/index.test.ts create mode 100644 tests/unit/features/chat/rendering/DiffRenderer.test.ts create mode 100644 tests/unit/features/chat/rendering/InlineAskUserQuestion.test.ts create mode 100644 tests/unit/features/chat/rendering/InlineExitPlanMode.test.ts create mode 100644 tests/unit/features/chat/rendering/InlinePlanApproval.test.ts create mode 100644 tests/unit/features/chat/rendering/MessageRenderer.test.ts create mode 100644 tests/unit/features/chat/rendering/SubagentRenderer.test.ts create mode 100644 tests/unit/features/chat/rendering/ThinkingBlockRenderer.test.ts create mode 100644 tests/unit/features/chat/rendering/TodoListRenderer.test.ts create mode 100644 tests/unit/features/chat/rendering/ToolCallRenderer.test.ts create mode 100644 tests/unit/features/chat/rendering/WriteEditRenderer.test.ts create mode 100644 tests/unit/features/chat/rendering/collapsible.test.ts create mode 100644 tests/unit/features/chat/rendering/todoUtils.test.ts create mode 100644 tests/unit/features/chat/rewind.test.ts create mode 100644 tests/unit/features/chat/services/BangBashService.test.ts create mode 100644 tests/unit/features/chat/services/InstructionRefineService.test.ts create mode 100644 tests/unit/features/chat/services/MentionCacheCoordinator.test.ts create mode 100644 tests/unit/features/chat/services/SubagentManager.test.ts create mode 100644 tests/unit/features/chat/services/TitleGenerationService.test.ts create mode 100644 tests/unit/features/chat/state/ChatState.test.ts create mode 100644 tests/unit/features/chat/tabs/RuntimeSupervisor.test.ts create mode 100644 tests/unit/features/chat/tabs/Tab.test.ts create mode 100644 tests/unit/features/chat/tabs/TabBar.test.ts create mode 100644 tests/unit/features/chat/tabs/TabManager.test.ts create mode 100644 tests/unit/features/chat/tabs/index.test.ts create mode 100644 tests/unit/features/chat/ui/BangBashModeManager.test.ts create mode 100644 tests/unit/features/chat/ui/ComposerContextTray.test.ts create mode 100644 tests/unit/features/chat/ui/ExternalContextSelector.test.ts create mode 100644 tests/unit/features/chat/ui/FileContextManager.test.ts create mode 100644 tests/unit/features/chat/ui/ImageContext.test.ts create mode 100644 tests/unit/features/chat/ui/InputToolbar.test.ts create mode 100644 tests/unit/features/chat/ui/InstructionModeManager.test.ts create mode 100644 tests/unit/features/chat/ui/NavigationSidebar.test.ts create mode 100644 tests/unit/features/chat/ui/StatusPanel.test.ts create mode 100644 tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts create mode 100644 tests/unit/features/chat/ui/textareaResize.test.ts create mode 100644 tests/unit/features/chat/utils/usageInfo.test.ts create mode 100644 tests/unit/features/inline-edit/InlineEditService.test.ts create mode 100644 tests/unit/features/inline-edit/ui/InlineEditModal.openAndWait.test.ts create mode 100644 tests/unit/features/inline-edit/ui/InlineEditModal.test.ts create mode 100644 tests/unit/features/inline-edit/ui/InlineEditSession.test.ts create mode 100644 tests/unit/features/inline-edit/ui/inlineEditMarkdownPreview.test.ts create mode 100644 tests/unit/features/settings/AgentSettings.test.ts create mode 100644 tests/unit/features/settings/keyboardNavigation.test.ts create mode 100644 tests/unit/features/settings/ui/CodexSkillSettings.test.ts create mode 100644 tests/unit/features/settings/ui/CodexSubagentSettings.test.ts create mode 100644 tests/unit/i18n/constants.test.ts create mode 100644 tests/unit/i18n/i18n.test.ts create mode 100644 tests/unit/i18n/locales.test.ts create mode 100644 tests/unit/providers/ProviderModuleCatalog.test.ts create mode 100644 tests/unit/providers/acp/AcpClientConnection.test.ts create mode 100644 tests/unit/providers/acp/AcpJsonRpcTransport.test.ts create mode 100644 tests/unit/providers/acp/AcpSessionConfig.test.ts create mode 100644 tests/unit/providers/acp/AcpSessionUpdateNormalizer.test.ts create mode 100644 tests/unit/providers/acp/AcpSubprocess.test.ts create mode 100644 tests/unit/providers/acp/buildAcpUsageInfo.test.ts create mode 100644 tests/unit/providers/claude/agents/AgentManager.test.ts create mode 100644 tests/unit/providers/claude/agents/AgentStorage.test.ts create mode 100644 tests/unit/providers/claude/agents/index.test.ts create mode 100644 tests/unit/providers/claude/commands/ClaudeCommandCatalog.test.ts create mode 100644 tests/unit/providers/claude/commands/probeRuntimeCommands.test.ts create mode 100644 tests/unit/providers/claude/env/ClaudeSettingsReconciler.test.ts create mode 100644 tests/unit/providers/claude/env/claudeModelEnv.test.ts create mode 100644 tests/unit/providers/claude/history/ClaudeConversationHistoryService.test.ts create mode 100644 tests/unit/providers/claude/hooks/SubagentHooks.test.ts create mode 100644 tests/unit/providers/claude/plugins/PluginManager.test.ts create mode 100644 tests/unit/providers/claude/plugins/index.test.ts create mode 100644 tests/unit/providers/claude/prompt/ClaudeTurnEncoder.test.ts create mode 100644 tests/unit/providers/claude/prompt/instructionRefine.test.ts create mode 100644 tests/unit/providers/claude/prompt/systemPrompt.test.ts create mode 100644 tests/unit/providers/claude/prompt/titleGeneration.test.ts create mode 100644 tests/unit/providers/claude/runtime/ClaudeApprovalHandler.test.ts create mode 100644 tests/unit/providers/claude/runtime/ClaudeTaskResultInterpreter.test.ts create mode 100644 tests/unit/providers/claude/runtime/ClaudianService.test.ts create mode 100644 tests/unit/providers/claude/runtime/MessageChannel.test.ts create mode 100644 tests/unit/providers/claude/runtime/QueryOptionsBuilder.test.ts create mode 100644 tests/unit/providers/claude/runtime/SessionManager.test.ts create mode 100644 tests/unit/providers/claude/runtime/claudeColdStartQuery.test.ts create mode 100644 tests/unit/providers/claude/runtime/customSpawn.test.ts create mode 100644 tests/unit/providers/claude/runtime/index.test.ts create mode 100644 tests/unit/providers/claude/runtime/types.test.ts create mode 100644 tests/unit/providers/claude/sdk/typeGuards.test.ts create mode 100644 tests/unit/providers/claude/security/ClaudePermissionUpdates.test.ts create mode 100644 tests/unit/providers/claude/storage/AgentVaultStorage.test.ts create mode 100644 tests/unit/providers/claude/storage/CCSettingsStorage.test.ts create mode 100644 tests/unit/providers/claude/storage/ClaudianSettingsStorage.test.ts create mode 100644 tests/unit/providers/claude/storage/McpStorage.test.ts create mode 100644 tests/unit/providers/claude/storage/SessionStorage.test.ts create mode 100644 tests/unit/providers/claude/storage/SkillStorage.test.ts create mode 100644 tests/unit/providers/claude/storage/SlashCommandStorage.test.ts create mode 100644 tests/unit/providers/claude/storage/storage.test.ts create mode 100644 tests/unit/providers/claude/storage/storageService.convenience.test.ts create mode 100644 tests/unit/providers/claude/stream/transformSDKMessage.test.ts create mode 100644 tests/unit/providers/claude/types/types.test.ts create mode 100644 tests/unit/providers/claude/ui/ClaudeChatUIConfig.test.ts create mode 100644 tests/unit/providers/claude/ui/ClaudeSettingsTab.test.ts create mode 100644 tests/unit/providers/claude/ui/PluginSettingsManager.test.ts create mode 100644 tests/unit/providers/codex/agents/CodexAgentMentionProvider.test.ts create mode 100644 tests/unit/providers/codex/app/CodexWorkspaceServices.test.ts create mode 100644 tests/unit/providers/codex/auxiliary/CodexInstructionRefineService.test.ts create mode 100644 tests/unit/providers/codex/capabilities.test.ts create mode 100644 tests/unit/providers/codex/codexUserText.test.ts create mode 100644 tests/unit/providers/codex/commands/CodexSkillCatalog.test.ts create mode 100644 tests/unit/providers/codex/env/CodexSettingsReconciler.test.ts create mode 100644 tests/unit/providers/codex/fixtures/codex-session-abort.jsonl create mode 100644 tests/unit/providers/codex/fixtures/codex-session-agent-lifecycle.jsonl create mode 100644 tests/unit/providers/codex/fixtures/codex-session-persisted-tools.jsonl create mode 100644 tests/unit/providers/codex/fixtures/codex-session-simple.jsonl create mode 100644 tests/unit/providers/codex/fixtures/codex-session-tools.jsonl create mode 100644 tests/unit/providers/codex/fixtures/codex-session-websearch-persisted.jsonl create mode 100644 tests/unit/providers/codex/fixtures/codex-session-websearch.jsonl create mode 100644 tests/unit/providers/codex/history/CodexConversationHistoryService.test.ts create mode 100644 tests/unit/providers/codex/history/CodexHistoryPathResolver.test.ts create mode 100644 tests/unit/providers/codex/history/CodexHistoryStore.test.ts create mode 100644 tests/unit/providers/codex/models.test.ts create mode 100644 tests/unit/providers/codex/normalization/codexSubagentNormalization.test.ts create mode 100644 tests/unit/providers/codex/normalization/codexToolNormalization.test.ts create mode 100644 tests/unit/providers/codex/prompt/encodeCodexTurn.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexAppServerProcess.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexAuxQueryRunner.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexBinaryLocator.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexChatRuntime.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexCliResolver.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexExecutionTargetResolver.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexLaunchSpecBuilder.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexModelDiscoveryService.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexNotificationRouter.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexPathMapper.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexRpcTransport.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexRuntimeContext.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexServerRequestRouter.test.ts create mode 100644 tests/unit/providers/codex/runtime/CodexSessionManager.test.ts create mode 100644 tests/unit/providers/codex/runtime/codexAppServerTypes.test.ts create mode 100644 tests/unit/providers/codex/settings.test.ts create mode 100644 tests/unit/providers/codex/skills/CodexSkillListingService.test.ts create mode 100644 tests/unit/providers/codex/storage/CodexSkillStorage.test.ts create mode 100644 tests/unit/providers/codex/storage/CodexSubagentStorage.test.ts create mode 100644 tests/unit/providers/codex/ui/CodexChatUIConfig.test.ts create mode 100644 tests/unit/providers/codex/ui/CodexModelPicker.test.ts create mode 100644 tests/unit/providers/codex/ui/CodexSettingsTab.test.ts create mode 100644 tests/unit/providers/defaultProviderConfigs.test.ts create mode 100644 tests/unit/providers/opencode/OpencodeAuxQueryRunner.test.ts create mode 100644 tests/unit/providers/opencode/OpencodeChatRuntime.test.ts create mode 100644 tests/unit/providers/opencode/OpencodeCliResolver.test.ts create mode 100644 tests/unit/providers/opencode/OpencodeCommandCatalog.test.ts create mode 100644 tests/unit/providers/opencode/OpencodeConversationHistoryService.test.ts create mode 100644 tests/unit/providers/opencode/OpencodeHistoryStore.test.ts create mode 100644 tests/unit/providers/opencode/OpencodeLaunchArtifacts.test.ts create mode 100644 tests/unit/providers/opencode/OpencodePaths.test.ts create mode 100644 tests/unit/providers/opencode/OpencodeRuntimeCommandLoader.test.ts create mode 100644 tests/unit/providers/opencode/OpencodeSettingsReconciler.test.ts create mode 100644 tests/unit/providers/opencode/OpencodeSettingsTab.test.ts create mode 100644 tests/unit/providers/opencode/OpencodeSqliteReader.test.ts create mode 100644 tests/unit/providers/opencode/agents/OpencodeAgentMentionProvider.test.ts create mode 100644 tests/unit/providers/opencode/buildOpencodePrompt.test.ts create mode 100644 tests/unit/providers/opencode/capabilities.test.ts create mode 100644 tests/unit/providers/opencode/models.test.ts create mode 100644 tests/unit/providers/opencode/modes.test.ts create mode 100644 tests/unit/providers/opencode/opencodeToolNormalization.test.ts create mode 100644 tests/unit/providers/opencode/settings.test.ts create mode 100644 tests/unit/providers/opencode/storage/OpencodeAgentStorage.test.ts create mode 100644 tests/unit/providers/opencode/ui/OpencodeAgentSettings.test.ts create mode 100644 tests/unit/providers/pi/PiRuntimeCommandLoader.test.ts create mode 100644 tests/unit/providers/pi/capabilities.test.ts create mode 100644 tests/unit/providers/pi/commands/PiCommandCatalog.test.ts create mode 100644 tests/unit/providers/pi/env/PiSettingsReconciler.test.ts create mode 100644 tests/unit/providers/pi/history/PiConversationHistoryService.test.ts create mode 100644 tests/unit/providers/pi/history/PiHistoryStore.test.ts create mode 100644 tests/unit/providers/pi/models.test.ts create mode 100644 tests/unit/providers/pi/registration.test.ts create mode 100644 tests/unit/providers/pi/runtime/PiAuxQueryRunner.test.ts create mode 100644 tests/unit/providers/pi/runtime/PiChatRuntime.test.ts create mode 100644 tests/unit/providers/pi/runtime/PiCliResolver.test.ts create mode 100644 tests/unit/providers/pi/runtime/PiEventNormalization.test.ts create mode 100644 tests/unit/providers/pi/runtime/PiExtensionUiBridge.test.ts create mode 100644 tests/unit/providers/pi/runtime/PiJsonl.test.ts create mode 100644 tests/unit/providers/pi/runtime/PiLaunchSpec.test.ts create mode 100644 tests/unit/providers/pi/runtime/PiModelDiscoveryService.test.ts create mode 100644 tests/unit/providers/pi/runtime/PiRpcPayloads.test.ts create mode 100644 tests/unit/providers/pi/runtime/PiRpcTransport.test.ts create mode 100644 tests/unit/providers/pi/runtime/PiSubprocess.test.ts create mode 100644 tests/unit/providers/pi/runtime/buildPiPrompt.test.ts create mode 100644 tests/unit/providers/pi/runtime/buildPiUsageInfo.test.ts create mode 100644 tests/unit/providers/pi/settings.test.ts create mode 100644 tests/unit/providers/pi/ui/PiChatUIConfig.test.ts create mode 100644 tests/unit/providers/pi/ui/PiSettingsTab.test.ts create mode 100644 tests/unit/scripts/rendererSafeUnref.test.ts create mode 100644 tests/unit/shared/components/ResumeSessionDropdown.test.ts create mode 100644 tests/unit/shared/components/SelectableDropdown.test.ts create mode 100644 tests/unit/shared/components/SlashCommandDropdown.provider.test.ts create mode 100644 tests/unit/shared/components/SlashCommandDropdown.test.ts create mode 100644 tests/unit/shared/icons.test.ts create mode 100644 tests/unit/shared/index.test.ts create mode 100644 tests/unit/shared/mention/MentionDropdownController.test.ts create mode 100644 tests/unit/shared/mention/VaultFileCache.test.ts create mode 100644 tests/unit/shared/mention/VaultFolderCache.test.ts create mode 100644 tests/unit/shared/mention/VaultMentionDataProvider.test.ts create mode 100644 tests/unit/shared/modals/ConfirmModal.test.ts create mode 100644 tests/unit/shared/modals/ForkTargetModal.test.ts create mode 100644 tests/unit/shared/modals/InstructionConfirmModal.test.ts create mode 100644 tests/unit/shared/settings/McpSettingsManager.test.ts create mode 100644 tests/unit/shared/settings/McpTestModal.test.ts create mode 100644 tests/unit/utils/agent.test.ts create mode 100644 tests/unit/utils/animationFrame.test.ts create mode 100644 tests/unit/utils/browser.test.ts create mode 100644 tests/unit/utils/canvas.test.ts create mode 100644 tests/unit/utils/claudeCli.test.ts create mode 100644 tests/unit/utils/cliBinaryLocator.test.ts create mode 100644 tests/unit/utils/context.test.ts create mode 100644 tests/unit/utils/contextMentionResolver.test.ts create mode 100644 tests/unit/utils/date.test.ts create mode 100644 tests/unit/utils/diff.test.ts create mode 100644 tests/unit/utils/editor.test.ts create mode 100644 tests/unit/utils/electronCompat.test.ts create mode 100644 tests/unit/utils/env.test.ts create mode 100644 tests/unit/utils/externalContext.test.ts create mode 100644 tests/unit/utils/externalContextScanner.test.ts create mode 100644 tests/unit/utils/fileLink.dom.test.ts create mode 100644 tests/unit/utils/fileLink.handler.test.ts create mode 100644 tests/unit/utils/fileLink.test.ts create mode 100644 tests/unit/utils/frontmatter.test.ts create mode 100644 tests/unit/utils/imageEmbed.test.ts create mode 100644 tests/unit/utils/inlineEdit.test.ts create mode 100644 tests/unit/utils/interrupt.test.ts create mode 100644 tests/unit/utils/markdown.test.ts create mode 100644 tests/unit/utils/markdownMath.test.ts create mode 100644 tests/unit/utils/mcp.test.ts create mode 100644 tests/unit/utils/obsidianCompat.test.ts create mode 100644 tests/unit/utils/path.test.ts create mode 100644 tests/unit/utils/sdkSession.test.ts create mode 100644 tests/unit/utils/session.test.ts create mode 100644 tests/unit/utils/slashCommand.test.ts create mode 100644 tests/unit/utils/utils.test.ts create mode 100644 tsconfig.jest.json create mode 100644 tsconfig.json create mode 100644 versions.json diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..0a87adc --- /dev/null +++ b/.env.local.example @@ -0,0 +1,2 @@ +# Path to your Obsidian vault for auto-copy during development +# OBSIDIAN_VAULT=/path/to/your/vault diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1343b28 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,77 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Test + run: npm run test + + build: + needs: [lint, typecheck, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..8452b0f --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,57 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + prompt: | + REPO: ${{ github.repository }} + PR NUMBER: ${{ github.event.pull_request.number }} + + Please review this pull request and provide feedback on: + - Code quality and best practices + - Potential bugs or issues + - Performance considerations + - Security concerns + - Test coverage + + Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. + + Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. + + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..d300267 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' + diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml new file mode 100644 index 0000000..fc50de1 --- /dev/null +++ b/.github/workflows/duplicate-issues.yml @@ -0,0 +1,22 @@ +name: Potential Duplicates +on: + issues: + types: [opened, edited] + +jobs: + check-duplicates: + runs-on: ubuntu-latest + steps: + - uses: wow-actions/potential-duplicates@v1 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + threshold: 0.6 + state: all + label: possible-duplicate + comment: > + Possible duplicates found: + {{#issues}} + - #{{number}} ({{similarity}}% similar): {{title}} + {{/issues}} + + If one of these matches your issue, please add your details there instead. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d5d384a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,76 @@ +name: Release + +on: + push: + tags: + - '*' + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + attestations: write + artifact-metadata: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for changelog generation + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: .node-version + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Validate release version + run: node scripts/check-release-version.mjs "${{ github.ref_name }}" + + - name: Build + run: npm run build + + - name: Generate artifact attestations + uses: actions/attest@v4 + with: + subject-path: | + main.js + manifest.json + styles.css + + - name: Get previous tag + id: prev_tag + run: | + PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + echo "tag=$PREV_TAG" >> $GITHUB_OUTPUT + + - name: Generate changelog + id: changelog + run: | + if [ -n "${{ steps.prev_tag.outputs.tag }}" ]; then + CHANGELOG=$(git log ${{ steps.prev_tag.outputs.tag }}..HEAD --pretty=format:"- %s" --no-merges | grep -v "^- ${{ github.ref_name }}$" || true) + else + CHANGELOG=$(git log --pretty=format:"- %s" --no-merges | head -20) + fi + # Handle multiline output + echo "notes<> $GITHUB_OUTPUT + echo "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + body: | + ## What's Changed + ${{ steps.changelog.outputs.notes }} + + **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.tag }}...${{ github.ref_name }} + files: | + main.js + manifest.json + styles.css diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..c17d245 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,21 @@ +name: Close stale issues + +on: + schedule: + - cron: '0 0 * * *' # Runs daily at midnight UTC + workflow_dispatch: # Allow manual trigger + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/stale@v9 + with: + days-before-stale: 7 + days-before-close: 7 + stale-issue-message: 'This issue has been inactive for 7 days and will be closed in 7 days if no further activity occurs.' + close-issue-message: 'Closed due to 14 days of inactivity.' + stale-issue-label: 'stale' + exempt-issue-labels: 'pinned,security,bug' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dabfc9e --- /dev/null +++ b/.gitignore @@ -0,0 +1,97 @@ +# Build output +main.js +styles.css +.codex-vendor/ +*.js.map +dist/ +build/ + +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# npm/yarn +npm-debug.log* +yarn-error.log* +.yarn-integrity + +# TypeScript +*.tsbuildinfo + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Output of 'npm pack' +*.tgz + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# Temporary folders +tmp/ +temp/ + +# Obsidian specific +*.obsidian +data.json + +# Test artifacts +test-results/ + +# Development files +sandbox/ +dev +.context/ +CLAUDE.local.md + +# Lock files (uncomment based on package manager) +# yarn.lock +# package-lock.json + +.claude/ +.codex/ diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..b832e40 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24.16.0 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7a5d5ef --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +tag-version-prefix="" +loglevel=silent diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..85437ae --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,107 @@ +# AGENTS.md + +## Project + +Claudian is an Obsidian plugin that embeds provider-backed coding agents in a sidebar and inline-edit flow. Claude is the default provider. Codex, OpenCode, and Pi are optional providers that plug into the same conversation model through `Conversation.providerId` and opaque provider-owned `providerState`. + +Do not assume provider parity. Check each provider's `capabilities.ts`, `registration.ts`, and UI config before wiring shared behavior. + +## Instruction Map + +- This file is the canonical cross-agent guide. Keep shared instructions here. +- `CLAUDE.md` files should import the nearest `AGENTS.md`; do not duplicate shared guidance there. +- Before editing a scoped area, read its nearest scoped guide: + - `src/core/AGENTS.md` + - `src/features/chat/AGENTS.md` + - `src/providers/claude/AGENTS.md` + - `src/providers/codex/AGENTS.md` + - `src/providers/opencode/AGENTS.md` + - `src/providers/pi/AGENTS.md` + - `src/style/AGENTS.md` + +## Commands + +```bash +npm run dev +npm run build +npm run typecheck +npm run lint +npm run lint:fix +npm run test +npm run test:watch +npm run test:coverage +``` + +Use focused commands while iterating. Before handing off code changes, run the narrowest meaningful verification plus broader checks when the change touches shared behavior. The default full check is: + +```bash +npm run typecheck && npm run lint && npm run test && npm run build +``` + +Tests mirror `src/` under `tests/unit/` and `tests/integration/`. + +## Architecture + +| Area | Ownership | +| --- | --- | +| `src/app/` | Shared settings defaults and plugin-level storage helpers | +| `src/core/` | Provider-neutral runtime, registry, storage, tool, and type contracts | +| `src/providers/*/` | Provider adaptors, provider-owned runtime protocol, history, storage, settings, and UI | +| `src/features/chat/` | Sidebar chat orchestration against provider-neutral contracts | +| `src/features/inline-edit/` | Inline edit modal and provider-backed edit services | +| `src/features/settings/` | Shared settings shell and provider tab assembly | +| `src/shared/` | Reusable UI components | +| `src/style/` | Modular CSS built into `styles.css` | + +The feature layer depends on `core/` contracts, not provider internals. Provider-specific session fields belong behind typed helpers in the owning provider directory. + +## Provider Rules + +- Prefer provider-native behavior over local reimplementation. Adapt provider output at the boundary instead of shadowing provider features. +- Keep live streaming and history replay responsibilities separate. Live output should come from the provider runtime protocol when available; provider transcript files are the replay source. +- New provider behavior must be expressed through registries and capabilities: `ProviderRegistry`, `ProviderWorkspaceRegistry`, `ProviderChatUIConfig`, provider capabilities, and provider-owned settings reconciliation. +- Model, permission, plan-mode, command, MCP, skill, and subagent behavior is provider-specific unless the core contract explicitly makes it shared. +- When provider behavior is uncertain, inspect real runtime output first. Put throwaway scripts, traces, and handoff notes in `.context/`. + +## Storage + +| Path | Contents | +| --- | --- | +| `.claudian/claudian-settings.json` | Shared Claudian settings and provider-specific configuration | +| `.claudian/sessions/*.meta.json` | Provider-neutral session metadata | +| `.claude/settings.json` | Claude Code-compatible project settings, permissions, and plugin overrides | +| `.claude/mcp.json` | Claudian-managed MCP servers for Claude | +| `.claude/commands/**/*.md` | Claude slash commands | +| `.claude/skills/*/SKILL.md` | Claude skills | +| `.claude/agents/*.md` | Claude vault agents | +| `.codex/skills/*/SKILL.md` | Codex vault skills | +| `.agents/skills/*/SKILL.md` | Alternate Codex vault skill root | +| `.codex/agents/*.toml` | Codex vault subagent definitions | +| `.opencode/agent`, `.opencode/agents` | OpenCode agent definitions | +| `.pi/agent/sessions/` | Pi vault-local sessions | +| `~/.claude/projects/{vault}/*.jsonl` | Claude-native transcripts | +| `~/.codex/sessions/**/*.jsonl` | Codex-native transcripts | +| `~/.pi/agent/sessions/` | Pi user-level sessions | + +## Development Rules + +- Use `rg` or `rg --files` for repo searches. +- Write code, comments, identifiers, commit messages, and code blocks in English. +- Keep comments sparse. Explain non-obvious intent, protocol constraints, or invariants; do not narrate obvious code. +- Do not use `console.*` in production code. +- Preserve user data and provider-native files. Settings writers should merge with existing provider-owned data instead of clobbering it. +- Put non-committed notes, handoff files, traces, and throwaway scripts in `.context/`. +- Do not add new production dependencies without a clear need and an explicit tradeoff. + +## TDD Workflow + +- For new behavior or bug fixes, write or update the failing test first in the mirrored `tests/` path. +- Make the narrowest implementation change that passes the focused test. +- Refactor after the test is green, preserving the provider and feature ownership boundaries above. +- If a change cannot be tested directly, document why and cover the closest stable contract instead. + +## Review Expectations + +- Findings first: correctness, regression risk, API or contract ambiguity, and missing tests. +- Treat maintainability issues as real findings when they increase future change cost or failure risk. +- Call out duplicated logic, unclear ownership, and tight coupling with a concrete refactoring direction. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5110880 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,5 @@ +@AGENTS.md + +## Claude Code + +Claude-specific instructions belong here only when they do not apply to other agents. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3e4c433 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..2bedf02 --- /dev/null +++ b/README.md @@ -0,0 +1,190 @@ +# Claudian + +![GitHub stars](https://img.shields.io/github/stars/YishenTu/claudian?style=social) +![GitHub release](https://img.shields.io/github/v/release/YishenTu/claudian) +![License](https://img.shields.io/github/license/YishenTu/claudian) + +![Preview](assets/Preview.png) + +An Obsidian plugin that embeds AI coding agents (Claude Code, Codex, Opencode, Pi, and more to come) in your vault. Your vault becomes the agent's working directory — file read/write, search, bash, and multi-step workflows all work out of the box. + +## Features & Usage + +Open the chat sidebar from the ribbon icon or command palette. Select text and use the hotkey for inline edit. Everything works like your familiar coding agent, Claude Code, Codex, Opencode, and Pi — talk to the agent, and it reads, writes, edits, and searches files in your vault. + +**Inline Edit** — Select text or start at the cursor position + hotkey to edit directly in notes with word-level diff preview. + +**Slash Commands & Skills** — Type `/` or `$` for reusable prompt templates or Skills from user- and vault-level scopes. + +**`@mention`** - Type `@` to mention anything you want the agent to work with, vault files, subagents, MCP servers, or files in external directories. + +**Plan Mode** — Toggle via `Shift+Tab`. The agent explores and designs before implementing, then presents a plan for approval. + +**Instruction Mode (`#`)** — Refined custom instructions added from the chat input. + +**MCP Servers** — Connect external tools via Model Context Protocol (stdio, SSE, HTTP). Claude manages vault MCP in-app; Codex uses its own CLI-managed MCP configuration. + +**Multi-Tab & Conversations** — Multiple chat tabs, conversation history, fork, resume, and compact. + +## Requirements + +- **Claude provider**: [Claude Code CLI](https://code.claude.com/docs/en/overview) installed (native install recommended). Claude subscription/API or compatible provider ([Openrouter](https://openrouter.ai/docs/guides/guides/claude-code-integration), [Kimi](https://platform.kimi.ai/docs/guide/claude-code-kimi), [GLM](https://docs.z.ai/devpack/tool/claude) etc.). +- **Optional providers**: [Codex CLI](https://github.com/openai/codex), [Opencode](https://opencode.ai/), [Pi](https://github.com/earendil-works/pi). +- Obsidian v1.7.2+ +- Desktop only (macOS, Linux, Windows) + +## Installation + +### From Obsidian Community Plugins (recommended) + +1. Open Obsidian → Settings → Community plugins → Browse +2. Search for "Claudian" and click Install +3. Enable the plugin + +Or install directly from the [community plugin page](https://community.obsidian.md/plugins/realclaudian). + +### From GitHub Release + +1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](https://github.com/YishenTu/claudian/releases/latest) +2. Create a folder called `claudian` in your vault's plugins folder: + ``` + /path/to/vault/.obsidian/plugins/claudian/ + ``` +3. Copy the downloaded files into the `claudian` folder +4. Enable the plugin in Obsidian: + - Settings → Community plugins → Enable "Claudian" + +### From source (development) + +1. Clone this repository into your vault's plugins folder: + ```bash + cd /path/to/vault/.obsidian/plugins + git clone https://github.com/YishenTu/claudian.git + cd claudian + ``` + +2. Install dependencies and build: + ```bash + npm install + npm run build + ``` + +3. Enable the plugin in Obsidian: + - Settings → Community plugins → Enable "Claudian" + +### Development + +```bash +# Watch mode +npm run dev + +# Production build +npm run build +``` + +## Privacy & Data Use + +- **Sent to API**: Your input, attached files, images, and tool call outputs. Default: Anthropic (Claude), OpenAI (Codex), or the provider configured in Opencode/Pi; configurable via provider settings and environment variables. +- **Local storage**: Claudian settings and session metadata in `vault/.claudian/`; Claude provider files in `vault/.claude/`; transcripts in `~/.claude/projects/` (Claude), `~/.codex/sessions/` (Codex), and `.pi/agent/sessions/` or `~/.pi/agent/sessions/` (Pi). +- **Environment variables**: Provider subprocesses inherit the Obsidian process environment plus any variables you configure in Claudian. This is needed for CLI authentication, proxies, certificates, and PATH resolution. +- **Device-specific paths**: Per-device CLI paths use an opaque local key stored in browser local storage, not your system hostname. +- **Background activity**: Claudian does not run telemetry beacons. UI polling timers read local Obsidian/editor selection state only. Network activity is limited to explicit provider runtime work, configured MCP endpoints, and provider SDK/CLI calls needed to answer your requests. + +## Troubleshooting + +### Claude CLI not found + +If you encounter `spawn claude ENOENT` or `Claude CLI not found`, the plugin can't auto-detect your Claude installation. Common with Node version managers (nvm, fnm, volta). + +**Solution**: Leave the setting empty first so Claudian can auto-detect Claude Code. If auto-detection fails, find your CLI path and set it in Settings → Advanced → Claude CLI path. + +| Platform | Command | Example Path | +|----------|---------|--------------| +| macOS/Linux | `which claude` | `/Users/you/.volta/bin/claude` | +| Windows (native) | `where.exe claude` | `C:\Users\you\AppData\Local\Claude\claude.exe` | +| Windows (npm) | `npm root -g` | `{root}\@anthropic-ai\claude-code\cli-wrapper.cjs` | + +> **Note**: On Windows, avoid `.cmd` and `.ps1` wrappers. Use `claude.exe` for native installs, or `cli-wrapper.cjs` for package-manager installs. `cli.js` is only a legacy fallback for older Claude Code npm packages. + +**Alternative**: Add your Node.js bin directory to PATH in Settings → Environment → Custom variables. + +### npm CLI and Node.js not in same directory + +If using npm-installed CLI, check if `claude` and `node` are in the same directory: +```bash +dirname $(which claude) +dirname $(which node) +``` + +If different, GUI apps like Obsidian may not find Node.js. + +**Solutions**: +1. Install native binary (recommended) +2. Add Node.js path to Settings → Environment: `PATH=/path/to/node/bin` + +### Other providers + +Codex, Opencode, and Pi support are live but features might be incomplete, and still need more testing across platforms and installation methods. If you have feature request or run into any bugs, please [submit a GitHub issue](https://github.com/YishenTu/claudian/issues). + +## Architecture + +``` +src/ +├── main.ts # Plugin entry point +├── app/ # Shared defaults and plugin-level storage +├── core/ # Provider-neutral runtime, registry, and type contracts +│ ├── runtime/ # ChatRuntime interface and approval types +│ ├── providers/ # Provider registry and workspace services +│ ├── auxiliary/ # Shared provider auxiliary services +│ ├── bootstrap/ # Plugin bootstrap wiring +│ ├── security/ # Approval utilities +│ └── ... # commands, mcp, prompt, storage, tools, types +├── providers/ +│ ├── claude/ # Claude SDK adaptor, prompt encoding, storage, MCP, plugins +│ ├── codex/ # Codex app-server adaptor, JSON-RPC transport, JSONL history +│ ├── opencode/ # Opencode adaptor +│ ├── pi/ # Pi RPC adaptor, model discovery, JSONL history +│ └── acp/ # Agent Client Protocol shared transport +├── features/ +│ ├── chat/ # Sidebar chat: tabs, controllers, renderers +│ ├── inline-edit/ # Inline edit modal and provider-backed edit services +│ └── settings/ # Settings shell with provider tabs +├── shared/ # Reusable UI components and modals +├── i18n/ # Internationalization (10 locales) +├── types/ # Shared ambient types +├── utils/ # Cross-cutting utilities +└── style/ # Modular CSS +``` + +## License + +Licensed under the [MIT License](LICENSE). + +## Sponsorship + +### Ke Holdings Inc. (BEIKE) + +MOMA + +Claudian is proudly sponsored by Ke Holdings Inc. (BEIKE) and the MOMA team. Their support helps Claudian continue to +improve through ongoing development and maintenance. + +> Want to support Claudian or appear here? Contact me: [tysk01213@gmail.com](mailto:tysk01213@gmail.com). + +## Star History + + + + + + Star History Chart + + + +## Acknowledgments + +- [Obsidian](https://obsidian.md) for the plugin API +- [Anthropic](https://anthropic.com) for Claude and the [Claude Agent SDK](https://platform.claude.com/docs/en/agent-sdk/overview) +- [OpenAI](https://openai.com) for [Codex](https://github.com/openai/codex) +- [Opencode](https://opencode.ai/) +- [Pi](https://github.com/earendil-works/pi) diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..b86aeb1 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`YishenTu/claudian` +- 原始仓库:https://github.com/YishenTu/claudian +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/assets/Preview.png b/assets/Preview.png new file mode 100644 index 0000000000000000000000000000000000000000..03a9a7e9f0a05047cba28e938294082f7be5fbb6 GIT binary patch literal 2100957 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 z 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�{d)O_Ms^8^rcM@?k>(lvv7T}`oITOxN^v1vza~%-*r5@w( z4yrf49GEW#Q+&3>AW&Od8vt%>KeJ0Y zy|_w0-p%}QxEl>fo*%X=X+QUn4$1ZzH6jTJ9OaVE3|fc(-$2;eh5q%ETdCd-BP}fR zJ95fG`}upop$P2hbt_21*>d1$WdHjzlu~&6-mCo$QcC-p_f9kr|8%XD^dfN5FFDb~ z<#m7DWq@+yGMxD-75@HrKI-^yp0}nTn+6?b`H9s)Jrl+qVi+@mvZVdiPE5U`>YJHg z+niQ}R((%aSO@)K3T+PGd69gbeJ{JwuGS&20S3_7>ME7g?H@nDp_J#0sxbW7pgeEe zE_>u5Y1!F>nO^In2yL(=vt}GUQ=h$Dhl`FUB!aH z{R2!jx9=qjTUmT(%4|Jv!AddlWCy_8H8)vo~%z-eY!%T@GeXhHZOXuO#4!?r34yA#bHgt0$M#^VY_2XXg%er|UbAS7T-n zOkvgMz+|!MiY?Ri^um~L7)JvPecE`H8k?A)xl0Mc!tXwie)aK(GZLMrFz=^L zw2Sjo%Ep~P5gma8d$*)PSER1vf(rlu>~s?5;=fIpt?xeW)Y$G8x8AvhQ;7yRU1`L- z@R_W%TKPhd^1to64I#}hcB>OYhkk!wwC!v9f+qj$v%UX00OTKewP#TLi(P|kM~q8P z#6l^eUyEER*;URtG{Q1Cb_jXWZ*=B5HmAcwEW#A6QbcY060U8>>8RaTM6RC5eh)Bp zRwl%>NC(6fJS!M+Dq2Dg14-GstT|$TT#Jjzg(Nr_hUn4MC49eltTgS(D7rBLMB!pF zC~sB*L9{n4~;}9L&qf{ zu;Nq$hvJlPtY&tqZAg&4Wf9YcgY@a?6Eg0dXdVZP2=Vvg{G{`SXdqB9p2I>}&pua1 zEL#t;oC^Ij)LN!AF?5E_cR>EvNASyq*n#Q4h6)Fp@89T@dzwDMe`C=n#E!`ld$3bv zeoV!86JUiF#F+GvAF3ov9DV}O=!njsA@wzpeRr2h`qp!8cP;aF$z~Pz_H@L71MZEqurMFLZX7jO6*w7{o6 za{e!~7bT`A5LxrFa?`N1BlbF{J}=s*x3I@fm>|5Q1IQH;`-b@r0RuBfHUHF91tHjP zQ$waJQnM~fg8#TBqU1c!i6J~RnD3i%pR0hJBW31I7dnxaw zKgybAE%i$NE?qu>?AB`ppBS;q_Kd>VbqktmAh?rYaiY8w$d>c5v&wmmqV2YXbZ7f# z1KX(8iaVGH<#q@WSu=LVTR^BPq1sdJ-5H>X>a&LR=g%XHO2Z$Q&oAyL5hQlA(_l?6 z%p(m%LTEr-y74Tjy;h5V5d z_{4N%SmgX^W5oDqnGZh_iS4k3NeMkOR=WE5BSDvgPJVt@$Fyr?q1Q!^tB64I*S8H> zIT+%m4U}M}xv)i#QaLA1ZJx`VBHXldkydV-oYF_M9_k!C1a=PX++_g-Y_=A7HOg`6 zD}94wJFwQlZaSYNuR(^0{j?u768Xqfb58Ksloq}so&AAscev;4p;)^1ZZbR4Stv`k z{a-x9)J?hH^vH9aC2wnx`){H%Nb)p88=T^_u4gPzcs;hdazbQQpNVWw;EP1qS3l&)A`q|k9iyv$^;zT-*| z>DPpx$P!fX3@w(g(Gu2Hs(zkQe8;dyua=vsH_EGI=X>&n5_b2H|9xL#+D0BC1F!1>F0wYl} zMRv;0m6aFx`Mp7+j%ix%++ZcPaR$?I)?m^Se)%??Mo#v;!y!wI*1VClIF$B#)Obza z&p!GmefH&>X06MVl`p|B6_AfSyrCN8!7Z#2$bCY8zH9E0S`vTWG@?)xu!v_rlne`I z1MrH>9zqH=7Lt~8$l|md)?6=5eM+|jPho|SotvQR?5pC9C!&tKA>nZ|51vl4)~35u zM6Fq-0*`~TUU*l4sze#bw;c0kHhj}KCTTP=G*foK09nav=n{8>9RHdy)yv?$nfwIM z5muq+q=<}rKS`Cl&tocgy!>mu?wt{7#ZtPe>VGy$dDlvLO%@Iz zpH=RB4;SiT3nzbQIc9Z0{q(GA_CaT5pSt{5P*{NAM2c^q)XcQuVVidVl)IDVqWia@ z2IFM9xI%(Yl<)+lUuFM^6px8jYN>wM-!?PiYqV}-6@R5uxTBDS^?-}rD2v~P=6&mL z_qfRRqmekg4!@~r;iv7Xtk)Xk4Bd)X*9SViA-8W%Wm&1}x=eG@0ueNd)s>hFJ8Ot@BzW?y^7 z+Gv99`81PRAkmQ;VH6%~NQ@v8q`S%RUV4SRKf#5yM~%2BCq@UH*$Z!^si*x7s^PSF z1iUl7jsxL2;76?WiqBS@P${p!-nkHa*=r72iqKNzCur)|x%b znEZNcbANUCM*XthX2S!jUH7c;0p`tN_L^mXuAS*T2q!*|nt2ljFZz`ZkB+2IqZ;L7 z{F1YR9%KGoPr89r;OKOO^h4<6=5c9|xe|ySprWd}1rN+vT^CHeI3JA`To@Ui=Od&< z5E`URpVAuJQ1ndh;*~Zg&u^9&+EAsR=7S=F)3#IS;n(R-KZSERZItlADTW?<&bCilxOdg-*7mWSRN?FeD zDqrXDxO^(#hTgnBr35wa3Re2=y}2TkzS*&1(L4H52u-LalRhU+LRf(X}d z&R0^9jh@_GnznqOXUB!PppcnQq>k{Lph3$2foJ``R$3ULA@J3ujZ}EUeJ0*q6J($P zkN!KONXgh94;y?vNjhKC)OUz0y`j7wRx<^v4`&;XK;3Z_Zm5@wQKMsjrdL~#Qk5X! zzd9c6>%*Yya6c(6L~78XarJtp>AWSV+S;tNq=&n0hx#Fo7PiIQ`BWg>h5!#=XX3AA z<(lr79j>}@*)Bf9Q-TXAMlq;Lz15Kz|Be;y%TW9rGJtxJRSL$dLM?l*rYEF6Yx{mr4Kq)URh} z$wtoI9`KV>uJI}Jyj?a9gzi1`VGNXYt@VHPzio#*_(|0Z{*Ss&x22XcFnKZnR(3VJUk3a zCp%qEpTV`DzH#A=I&{ag@)C|V*j?qIB*gzR6(TZsAI9xZze7|~((}gj>W7u7A6yz9 zk*1aKzRd5M{}0c&8deYdioe*h@}Cr~9`vaFlVA&gLbKsEiIxH|a$sa=yNY({8pmx* ziLJ0oeKv4efo4{^ga8yw#8Wk(&$Y0&mf!+w16ZuOTTwi zQB8J3R*R}CiJlr^h!{$qxPoyD?HooG=Zz6cn>}jnr@+_la}^m1@R+B;3lzg*=FdBy z!~7N{q$jjqYIe`}yt=afx3id(I0Xf5_i^gU7OMBts+ZMGa%-lneb( zk}b-rw5*X^?lD5X@MpyqJNww84cQ6iTxX?n&X=1zpqSPiHL>clRjAfW4+u;bp0Dip zw1o}VPpT(O>}3+6rflV%8|RL(J)*tSL9t?4XpLGXfk-~kq2}?T<|!<3CgAE9jis#+ zY~jY5GLBag`bQGyo+n6T{<%8+)LAXKY zG=ACZRyDRcb7c6#Y5myx2}>0?2YbP`GPdH516Kb@Qtdq(drs?Q)ugGmPPt)g;GNOPGR4i+DXKLw&<_WxKiziJBQ1cdhUX{* zT(Uq|_0e+Qxq|8Sg)N%+0Y`5LZ_(v9j zI7AU-2D>oqS&_!%aIwsCa6NX&z7n{8*PAj3HEFBZ(}yZr9&vX61wU~A^+J4(#6(bP zq1^h3c(Xq_*apR#D&>fZiMFt}M_2b-?$B?trx$Ws{Wd7%gqVaPh9p~5gxcL(?{5fb z=icm&4Avxx0!n_qH`fD8g6OR^B3Vq9#3t=|Y~zm#0ULS8e6@t9F*S1vxJ93p^0(MH zlWk{yvxKBR7s{uzWs)(_i2{Cg_l*EkgF6$Cqr;)fWe*`qU(EUT^+QKLWD}twv1yC? zWS_e2Di0&D2k#P?8!T{7zd?(L6e|zYn19|yD8Z?dH%miO+KsQv)B)ZfBkvxdd#ZsH zkbyoxd4%O7fUoK|4n>&^&0rWjs2R~%!y&t(n)-gv8e3rrt8j6|+Hg1_yv4GJ*4k@lFm|mR!jp z6{Rf?Mqi!9VpVo8G@~H@0sr#1{*ujC#E^*y=6~{4Sq#&c-WZmCsxj=x7);a_fC^u9 zk7%;oUyUQ)Kyd2^x?(mdHKzJkWIgXbjT2Y_2++$6$ApxWr2v0iaXri#S4lDo98bg| z(-YI3nxUV<8*k;=Jr}SJrGtwQzLn^LEGZ0i*hb1akVS0%C2r^_O4+zg)s!-MCdS^o z3e8QZ$)IBw&~*u3^Jq|sDj1Ml+F-R`@mi3wSjpmjrv2t24%K3TjEtNXeRHFsM|U*> zbuOu;O2yx_Sn`1*OMZ}Tw2>#@vUeF-x8}A?BEuV&BWQhyj3OGlWx9NAN>N1Y8Hfr! zmQK}emFKDvnbLZF>I+Nq;CtRu*T&^7*fO|SW1f%&RK7JvDk??v0oAzu)rHQFV7@4~p6#rm}R08ahVVV2R zpK`l;k8zZSChTDU5cN%p#PBBPOn@Zi-tN9k-tZ$4%}jhS9Pm&eKP$+QEA2lQ3Mb66Ge9Im9^rPuodR zEo>+C#gnk~H(BY~OoGdh5^a22?|p8(+#2N1c-|_4!IWEk`*1m8+L<3E zmu)?JqV=!STb#A`2bedD>82UD^1$$j_q-Ak%l0lbcH$-steL}z3d`yXhd*T*;lt^2 z7v0`S_IEQMZMkbHK1w1?Xj|rGi|Eo73cb4(BLFdV@KisnWhu^6&WmxyfgpuPTfxiZ zN{FABiV@A@ug?7j@-O!QTe0t;y{Ij{!p0-$hlE&i$`S#EfIx?>m;I}{_ z?8UJ()#Pk`A+zl(nsBzqq@BTwT9oxhzxZ9NNwEX&i1F-!c|M`Kv-qQnJ?h=C)99}Y z!HD|tB-7K+9_q1|=e~lo50I-*hw!jXM`DLu9SzWSUtftp*U&kv*51;M{<4J^#8yp* zh$v!MYm0N%D#&Y5AF}2>8#>dHcNeq8m<& zRo_>%7PP?@tzr=Pt;W+=s=w|jQ@L&iiyZY4Y6TQ#!~VISh2v0O)>6X#`{VE>UJr>y zo-cb5hG_Q7_$T|I7cDs#>vo+`)guFUbOeX_vhHIem)^r6bX}@;#P^l@2n;=4U4Zp~ zZWQm6l}RLk|I5gmFMarvJ#Bw{0zhSNI_~RlAK~%YQsPUR{&~xjv&6yDBr4#y=W%h< z$+_QS$7?3(9a33#`GU}>MT8V%KW2DD#MRAb z>27(AH6eDQ@?81)JSnAdU6I_28=ydo=q(a^pu*`#cZyGV&k;$`dq5{qHMDJL@t7wA_`(>5}A=se?9Y@#~>w+Szt8aCpEU*dUc zQl@+G(D_UJAHB|F9i_>NPMGhi*Xs)a4Bxh(k&Sf61&?S!hxyHvQe6vsSM@t09E$z_0-9abn9pI;OZUY@1u^U&vV(C_zMh6EY{Pw>d7CFob1puDT z8fi@-E&T9~g}~y37#|3A^;Q=6g))&*PKbcHUiWUlR8gr3U}R^E}mW}*Du5C zq++6@wR;20M&$}nlY&|sd#!nh&vksasn~NbxGoHOEDQkM zQo6L^zy8Irf+I7CY9d?Si8ky~s|I%Go*Vaxk|^GYfCax1?bcHSqYL)VY5DG!K7hDi zYQD;QjZG3!8=?7&E_9Vc&q2zl$!K#d{v7O90;btc=8zDll?Yx5z7WH1|3E}Q9A!7t zElOk*HA`qmG(HzS+9UJ5nXY#pjV==^{diWMJkI0x9^2Jxy zo(bnZ_ANp@d#$Iu7Wg*5Fh&;*5}-2Qp|tb2+YHNA)!W>W-^dL~qI3aE7LTIL`1iJ$ zYL{Rkh(P#J)PnQ-zktQ%((Wd`3SZw!KUth?R~({n+0S@x)sl&QhrdICbzRQ z*Iyb7J-5fd0qCQ-U5AIMzbFLHsb{LEwd}_JG!^k-TDaHvq=5Jc*a}<#!;3?iRW4$5 zs5tf9%J)0P5Tu{{G`jRJRVfybg2Tz-NXiUYcSj42M_%QzH0FvO6$7T3!m5 z99(VSMx+7eMt)u`mEGVy)ukmH0bwExvD=%1fHdu<(Qx+l?}MZu!jT*WvNjJD23CFb zv#1^blg5tV5`{Of#1~QGU&jJA(diV25tvc+2QYk~Cxz_L-Sv_`EBFQS#8uXD$0Bim zn3scGN2C#Us5k^_{D>S2Oq*l*isqKhs4V>u=N_M9JAZM9P;D#xB1+Bd3`y#JTa>rTwG*3Z}H;-Vj#!l z&b_m9MgN12UZJ3hj~*2g63O+#AM6C+lVT)RLOjAh#c*`Qr>9-@w zd6cNs44f>>HvE?JKIpFEO=*TS``!%`C(8d0b~=TBQ7qP4%>KK(6UtfUM3H(?1iB2v z!!3|J(dOPLRr49}cY;z?|0wB|-1N1)-Sfz1W8rfXt!GyJ8twMZm+CWmMy(2N@$1Cy zt~;U$BV#q+jV)Q;qOsCQu@#p}%X&8{4Yf4j7iZKeW(}&W8l>nAS$5$f``uG6oKHma zWN!M!ae%Y*6BAnjM4apVlZ~~mtD8_G;oU#$YL!iH&uxfee93~feDyg!avz<_WNss| z;u7S<6o;;hl;)3a_~$jN$g@P4&jB%@{5xnrS0tr^cZ0!IBlA0l*+}2-(=aeBk8q77 z``z=a7-~}vMy>0+_~IAVyYGG3zW{u(*>mLQIy&)%zaK*CUTdNwGe!DFywAJRPZ$86 zcz757mNu+NvAjrXplz$29q=)CKe!)@6d)_R_%OtRJU4wysIgpXD?H2G^%tQtug1Ig zyM#t4jZjfG_s*vIrmF~B8e85kL7+%xA5f%+7pO#@NXS^Vkf0=zy%{%eIkICD$Th0= zCyqZaFlwf6bkL+`Kxfflp{!X*pic>m&r*|* z?R%R-#W>+-r25Ez$*M?bp?{j8?KigAcPFjn%7X#&J@dFo?yBTqZAifo z!^e%5WV8`I*$7zbc()ruZ0^A4azm4Nv;1w=fA;s8LtyHQ+RQJJ$_+ZByK>7BtMyx6 zr*U-{OP)^hqRVTc#pwJv87-~6$x7CeT07`E?_VuW?r*Ahq6qzJ3)Kafiz^f^-vKpV zbuXqLvp1G+rye7Ap`a)q7b{dA4|TFFv=v!0+KZ4AXG_6kYtX+w92-hq@$fn{QnN1a zfw}K$g(2PK5+?KI;I{W}6U-U#ek%1EJJNW>BGu#uxg?qF(;WXvvBB{Jjt0nwP22Vq z-pZ95!H|1?=r`S2eGS{Nh$Ad88HeamPgZvIc=mA!hNNQ_rw`W!bGrJF6Sg)YIg*V7 z;B11ohiq<={>GJXG2O$z!FeTo zYs(@DDJkeKrBqpM%Ulu~PCtCOo??#69)}}GcIwVEPn}|CScXB_u&)1M$$3H%R;tDn zS8WK9uC*94bo{(AYqfUV759GE@IZoY0JfVf5lP6U5%j5oF+nSBlO)~*RlD_{znjolt4q327T<3{zMA^Us-=5Sus&GX|3jsvwZlWhjV`~p-lXu*hY8RX`1GQ?A*n73&7U>3g5+Uiwz!aa}wDPTqHZa2z0QJIQQF@qhcwXFPUIjozo^ zWe6ia_@7#(Y>(BhCw55Rr&_r_SVa7VESLpJ!X62;RW3T8;anR$b?png@DT=>XWNWk z&clTVo9vgA)qCzBQP^4Q##-?gc)lsz z5nO2ojlPAMy`G7CPk$=hdcHxyUU6=$_J_JvU#F0p`%FJ=YzU{gh#pW$v~9(8JartE z@n6-fnJZ?OC6Efc6Ff}J>Fv~MtEXwP(pFwyhQE(DeKmtiQp5Yt2SG2uP7O*i?cx&( z7wHRX@6B$s89TG~ZOrF0R_QqF!eIvNv1rHhqPCs3n@6(H*P|bAT<02VULMaO9TB0p z62R>C=j+>Nh=<+p;LfLrAOPLi`FQhsM}RnNg>s!o$qOzw#B43Sr$zz%mstJ}O=lU@ zX4|dNB)B`p8(fOJdnsNh4#nLm?gT4doZ@c9ofZugcPms z&t#)}KX!9x2a6vwCjF5m9{=zKTz5YmJO2c7I%XjF=_9a=>Hg4J-u`bNzIorndJvvd z`*F+f=Qp)|4{NtZk1gCG0tC4Koi2V=>X=U#zC{~iIXuyt{W7R*>Gg1#bL#)}?G3pSqve{f1tvV!OwS z8qY6UHye(58yA{pt;o5)$4eSFDL+`HuPP+B9+^Q{?ygqzW2}l7oZQa5nZY#D!n?Uy z%8K}f(PQNYfcTM~%aSPMQwJZkgZ}+iPca zl&@0f_?qNnyfGgq6|4_Fhp1yfIet^jS@bp(e-UM0*iSx(G@+Qi2ciaHFgW`Fxd729 z)xBxpJntdNLv3C2CGYxI!eM&3!{(y;1xEnoH54;GmL(?bx5(@Fqon2N#VrlvjrOsY z=3Q+0>AF*71OuRtdilRdBc?_MibW$| z*$#DgPUNYr+jDNXKQjm$GOX8w+lY`m04ZS!K}yhURWjRj{--96;Cpwhq5G^axUCzm zG7-xC59z_zsHU|JGbOi7ohZ7;Z)3}4_RO;_t-$8}tc+u^xI*CMak)%8`jy3O1O(j0 zxxGbgt?;%Q0;c$1{9i4}xzJHpgg9bXmaTDn?jYf}P}j3AN%bZ|3V8_4ugNIUk-~6o zdr%E)0+66KHC3~+uO)q%x2B0NWaXNog10ytd|Js|IRk#yiQua#f%h)M2qgu0H0zJOUdp08L~MdeXD1kuv6!F}SV4-N{- zJEW09nAcn6L;}KX4!0ik^pT?&h$c~*&sSAPd46q<&{?jVI6ghFiS02zu`xH5WPl2( z`*;OZnvOY&61g{Gp7ta?gu{`Ir|wN+2!`eFVM{}Lq<2D!F1A7$&{h3;L07X%8Vn|` zyVn$e03O|5qJxLDW{MV^D|zX1D$YanE`f@W20rO;l|sU%XLB0JVa@umdEY595coaP zu~#hQIi4CiH?xCFdmxQ7W}lSQu}a% zd3)ES?Y9FCwz0>mx(>7uNp*Z_k3=-}XVi(&Z3E?_rWO+Yc-f4Y*jNQTyr|HM>Qut$KqRN5Z>T=mDopYY=<11Av5|Ea`}h=|i>BVo zfe(eh?7roFuxn5;N+uhj=MM=DWW>A;=CxJ6&DMKwv6{e4#BkSE)3=~*wTo+a4d4R=m*QIUo!x@_E%_v_fIMVcGOlRWpniTlfqAg^vC( zGL|U(l^Bcu9%d6~(6cD3UK6TP8f(wy+n74{b2CzrZ}MmJohoX2TpWd;i$%WFK@Y@R)08n(0CI*VyhR$WfSndDcZ zXp3_zfvQrIp)xYS7|~StiFyCsPn=Xb*HozkV;+sHq%B8ob2BFGbw2U2KzsA`S(gto z<{Znq=QF}-CoBG{rYLxl^XR(L?Q8V$AD&~2_AS$hAnwjdq8oD7M>quwm1i5FhxN9z z+MF5}81lOVUmGSjB~{OTE#|74kB*)yx!A+NHdpkE>130`N%Gt`Wrk{MRX+;*Jo_QXxf=yzx6Ov@ z*91GUx;JPOH)ul-VA91!Mr}M8hbXcLWs@S#YIBKL1mjTK^kS$%h(m+h@WL^Nh5bp` zX?jt%MQ<7f?QNr2yhiu@Kqw)Jv%*W1J8Zk}OUHt?u`Y_ay91_H?LpS4f6nMLw?OW~ z&{tQ-SX<}0TV*v%_=%FjTSw#Je6&3)A+BJPGUO$1qPNp!UbCqRKePBtH83AB1u{gn zG+uHU0(oTk8tr?IR)kuw&7vp9rp3H&1lm7LQlza`H)gm$TQr+qV0k@#aa&5GTtAG; zI{#7g?R`KlX3y&WjLo^~-t)(=UprYt5B`($;sosdOs@G>fqn`s&YQ{H$^QBJvFG%? z-y|@VxYi4;)>(Decn#o-RLgbX*7ZO?-`CQ62nGgsn_s)Kjl6^@o5(;vQ{Mzd#YniI zu|LD<1#hdJ4-q^SO+UIF$K!*ln9wQaK)I7zcYpX6im&}?7VTrziR`u01HtOf!y56r=G2$A9RQSV29FUz>e(r`+&^ z=dZWr6f&%`_IZuYg&PDwOV;&Ht8G_ZJ|R7Q7vs)Gzff|UFES=S&My|J3s0NYjDEb@ z-HegQFKZkjyZhqRfiUSi-w%hl-c-x=If{P{@3t+;3x;IPhAQyv43Oy ze4zt;E8DM(0{7@7xa5F&%d4B<>T3V1qSq>iGU|lh!%9Y?`52k)}M^eLS8Ai zl@4D#0s)h@_qkr0+BF|D2KIlutqYfQ0JvIn0RJ%%R#0BAwyEj|QY1=_j%l}Dw*lU# ze(Mo2>}@1LJpK32n(X7V)R|=!x<3wU+FfpzoO^&>7qP&@-pjLyKp)-GdV~soP4F?- z4&hnTeloYAY|I1C#2z_@WUC1_g-BQVXXi0rBKT*lSe3+CaqY})lt=~$!MhO&p3*sg zl9gxv2|aqq!19A^uTXKRu1Pm3IX%-#ehbw&G#lB2=aTf=V?4wF5#fwCSPYXNhQH#B z1x=;AXMl_J+kYcX*8(*lz1-hdgO$u*to*tbUczdB1lQR!4CrRwG8~)ijAuOdxtPWN z@KHi=S^CY*y2v3xchXIJ=z)K80YzOL;oHq%!~MqnmJCkwje`@H#KaY79@(_&M^dva zDh5YX^|+_OUDXLM#PkwUx${#Hc@-)CbSiZ#nXi+ieS=*AYb-{ftPLx6&1&9g^U+MM zX6(SC9#SBNUE!P2{~d3bh-))S1Em2(c^hBrg&nQ~rHDd|4%9#E{v=wbI;RP@MVYR} zMUWemQ_~W|g;iA415j+e=}+1y%BpFmUpW*Y-m}Qf?SzmQG^Pb!%9I!S7*q|~#Yz)M z;T4QFE>=ug9+vAb>TcB&vma?)&U*a^1JiqDE=MzB-u{A853D^9m=~cY+0MTD6Lh{N z)T9LdCMku_jf{z)!xYOo96zL`%uP_#;7~s-R3eKb39IXD`=G{H@XIcXb31ss?pqnMfZ zr6XA?#fMEYkB87{pkiNs7huNipL1bU&KGrP@DPovilkI*@5I>Vp^U!m7RtzAtl$5@q5L+K>u29n6JyuadT zo_e-_KV5qOMtq8ZO{0_KH8~0tjVLN{0pwR8NBJMiB8=Q8OqbQC28#wO1q9EuZUj)X zUWapey^*nW$h0`JB)YX(I zDx3_xvFBifW}#8_&_6^DnP8hBv5{gjA*d$e^Uv(#j{G}NSS*d{{Yiv0^vdijXbznu zPCb~}W3De%(VZK;vi0ek9k>>KhBf4nOl`ycVtmnZida^_;Fx#^$AiYI?5gxJ?gBHVh!G*AG1fByDZgWAzNJ_ix$rftaxhnXk8f}<W`SH}YDgndWVLTD_*vQ75f+pB)sRpb1ZGq2;D7SEr$t@y^>W+#_PDs+!JVYo zNjXf1o|UXUV~gK?c-pjH{Ws`LV>gTqz5y*CgHtR;=utS|c<=<`?@mm{urPL6f%)o&R;8H;Dg?cEIG?q{b5i_7$b19SG#!7 z>M@Tcp(O8L{O4^f{TeE6qUTMxW}d&=6XJ7OzhLBNx+FeY2bes}fpVdKA*n>S!3K{Q z6U(%YR(UbJ)q*;HCg%NS=fZXesv4n7Yx2z~vt$w|YaTZl)!_1en}4(v)MWXD|0-gN zMJ!F?mWjga2;ChSQF)KGAlO{h%EI)ceuqWW0Ygu^2W=Sh(&6*$ejB+5&Qc!ds!e6h zt>BdEGf!+Qi{^6Vgg+{LnsbjeJ-AEmckdbhQD8potER2GJ?3L zN)2*_d*$I96QOUo^CE}uFmWgBS42jdrs zj8Q!Sdrq3A#*B5m?yU-SvD0XyrjTvE7-v4(C4UHU^;p&eCieEQ$XcG=c@Q(|(L4{v* zg@Cr?%pp+Z1}Q^~^z>R!z>fzyx23lLEHjg`0EXi{@8Vb)z5u{MBeO)e+p*#P^=D>K z1?UXGY?!HuaJ5z5&=wVa-`_ECnH_K42u2AKb9_>PF3slNH0ibawODqn^#8Zglmx~b zTK&9n%VYE8miHp9UlhkT7ahE07W;1)^Qhkbw8L%r6u^^mukJTwuyZ{g%H9P1iwH_E z`VTWmV}@&jI02T_?{wVNtrv~)$Ti20%`kd0=%g{~+5=eRCHTPRoPm$(Ylk4}3*<@7 z9?s5EZa=y`90y3k8M6NUiLmxOd=Uy&4%!vTi1Gz+(96_wceAAy$CaiiRBAauP4kku zcFNLz2?TXTkG*iJjq{97yI-F7uNAO-$f>AK%)4@RpZBAKmPuwEfX#?SDOlZl|LLTF2iU&< zO*1G(%syrI8<|Km8~l3YyM(Zl(hzm)=(au#~>%DygZy z$5dDjzUY|noBYc1;5Sxc#5_pRe5K{iA2{E}gd(mT^Oz&D&2gnbJHQI&=z)pBu~i)C z&{~;lI5*b0i$roG5>>{O*H|24ycxEzjRT|cj0p?-z0nTMiMXtgzpD@HQ0>3m=dZCC0CUq?> z;R}J`1SJB_e!7zHRMLdFcquvD+(iqAB}IW652mp?=sPyKx0U8eO9fSH?kM}+wBCZ@ z1Uvan8c?ao0_R7^XV}FvRYZO)&Yq@9!7#?s<R#vxWtMi$h#}>-?ZUmZb;GzWiO9H66?Zxl9oBJ3$Og z`2oTcDb^s_fh;kp( z(uGT#jT}s%MDs>cTW>Q0`OB~a!~e4Y!h6>&OPJr{NJ;jf+-HZ^hx9Io+(VKFyb0Cs zDjf<~SN7)la3R^3k;|=xHbp#(!ERLMtLj$+_TaHTm~>ByZ0{H|NF;6RIEP7(h{e6# zY}C9w5w3^{O9c~+7l(;9-KAS136%NJc=Ovj?7^0e5+JJb$O4UFA_=#W7M68RLn`?4iTigH6B`A`zdp9h6f!SXcJSTp>`A$m%Qila7)4bA(gQHk@`Jv#LQd)klV#9rSt*aXxunjC1u}gKN7~jMNyST6;n4)6m z(n|JnrYnA6&5=j|!D(nENv1C$s+n|uQ-asFa|p_lH9zOn=nbQ|7)e%8Y->i89~Lyk z3_?N7eN0eI#@Mm&q1829glBG*mXp6A?e} z&9I=t2N-CMwu2ivLN~^!zJV&aY21I8q^L!P^t}gJg>C_pV&W$!y6sO`Ux-Z~dJyrA=YQ00!FBIb9SdRxjk{_|yCQaUQwc9p-7+lZ8J z_$;ha7~Qj2bJ^7oYfY1E_P8WoGM5u_gfX30_m+9I*Sq0|8bM^aPu1`o3gW_%p_!+A@0LX7{BLm8T1+<>W@(9ktI$EzYYNrow_jkv_m8b zIVn=>GH_9MIMX37JG;k=0=;V_j9v4dUL=<_b9Z;bECEMpzm=Abh^SaNbv+)6A)`!W zLVjfX{YVEzQ3Jy5siDLApxyh~K%EEu2-p*jATC0VRBXKS z!c$Ic#!%$dr^_@p5^_pMVHex=v59Yvnt53a5?gL9uQ-jf=>V6_cT&hQzcK5NnnaDH zME5~|O(?$P)U51WJpaRcPEYe6dFBz4>bH|Nd*KY6$;Y!zxlw-Fc18pb$$n9dABB-Z z1XMBX#Fgp-qE=Imi>aSj?SdGHAa*JMVQ`-H`rbbhJ`2BZ8 z=w6!)3!|Bv8~)o9X1;rrI@l|Uf`gkrm5 zYS28DCgr!^hl#qeWS`+3q@VD+otr@aCQyFrtUUCS>(%-h_f-B!d+L`w$>$fdPp7#JqmqN`iz z+syfS@prPnevl$G;ARO$1~`h6k@W$?$o`mDlWo=Q`r6fPXdHp(ry!8gzuwuizqS5E zAeY~tdRz1W4`;nAJpgU1A2m9V0(5r%)e123=Nz8~cw5mZv{pT<%38#)UBIq*7Tay`3sekzDY1+)_iXc|8drT3I`&c__Bd_8 zJZjdl+A~}Sb*X9rZJddw__drQj|<(S#z}5WtZ_eS4_Ee)#l^+g%h|*0qz-;d2X+g$ zQr9!7rNA)Uvol__H`**D*3;W5M|3*Gm>YONlh05HG5(&(B6^mI7f$L+V2E%ssL zjQBqm{6QQj9@hnqoioQCtF`S%?4?n84tf_Cbs+=9{UgS;tv8M_&)h>sTZVViOx1TmTeMXendCcx815)ceD*6RU3( z2cM9Z0rg92)bzs;ZT!)L(|-s6u+SGglr4~q;@Kj&Tjzi0O5x`)J4pg$2Cn!W0U>00 z1xqy_z%SSe9ye+HC;Y53>LgqE=*Bi*wREk4lzZd~L^-$KKg+5{12y!2A=A(G^;}hk z?Zg6W-n4=Le%f=2s`L&69nvE^pO0VwKA!vg4bsxmfa2a+_E<_hkMhhVCpMEcRn6;S z8|!dy9C*U6zKe-(g_f)9fv z4vK=YTKrfx${d=fY;p1{01dy`oL1@qka0WlTXYWl@TDN1bf5gZj}8|DL?agK=wz;O zMigiLX&ml+sgKVK;;S)jA&CILWqPW?85*AtQoNcgq85ssk!w?FziQlUsfbx6b zh~K^Nn*`=}=5V<;c&@KyLS%b|`xD8s?&kiX(ZUdo>LV7g$--145t0SI!mx@R2?orD zA5&0&%tEzjL0O#NN1|=#4A7<+1^$u{xa9N0$*2+PuG<68%&5#ssdC;>j^-k_e%Q!j zgFE#YCchv@>J9#E%j>b=w;NHDFEr>`9zov$W1LMdkHmSBfB$)f+kMut+;0Vc>zC}(zPa1(m?k|O zC5FKoR>JkJB$!_D@-11N6V*G8p4s$m(IhFC>OjM3wxp_iD!ngJ#IpnhlA4^xoC>Pw$n9XFB z#UNcGNF119m?h&trVej7=Z_0tg@jl*2A1rEWFy}O!+RAtjuHl3ORK!P2sdaDp`?U@ zq%!iD!yc4szfwcoC^!BF`xE*0e$0`?BklX+UUlh^2p!OsD$WL%R&kp_*actRF+?&j z|C8RavB|BwjEntJC?M`cz{c0lYOb(Gnm=R{Sv(@sw|7=o@#?SnL8A8#LB2r!yULsh z2!AX+j~uZ9G6IQy)h&FZ0Bv7r_O8lhY8IY~CDx_s>UL zGKSXl6TK_vyf=SVB?u!LZ{lrVo16<(*Ozkjm@z`^9xc?hH$RI&<6*XKFt?j!ddbbH zDbX=oL-!QWf8v8?)X?ovK6LF z8ueZZuw`I5x5!9hzLC7n2&?Ct@+#ZzYpB3SXCGa7@Amsz z!u}*gM5IUFdCum7U~t(P&kQ?WV#nvIW(Jk2!&jsx${socY~_YTdRE*oLI9BwQV&8h zp}iwBtA%<-SO)NV{zlB5@dzn= zcx!o06oG<_V|`r2S^es4EvZX7&smu-SV>N=Wg`ZEI~)n{@W?Ho9X8k5VJHRs!Dw1> zBt&3q@^f@=H=NVLq1G%vL=TS5`5@W59gC$iFCYu`P26p(*+^BG$yO6OhCZ}f9JX9S zOll7i_ZW9K3c!{~pZb~naJX;QnUXeA={su>6Nm|lDqw{Dr&>buer3r>G_*-5?^e2z z5)HnA#zsU(L9v#p+sy*`-NHzvt<-G(s&00DuWpf#tl=QfO@t_i`$E}2V!prMVrKsi z6v7IbvP&&Lgy|}nK_aMl2j9JYulJpAdcH^DB0SdjR+80N}_KG;9VG#PMQRoK*VV;!7oJ7^B|hcACv~H4&0iqI{`3mgrwF z-($=6USQC88y7O6_mKc6^Gt+jm4zIhy?K5Z@;`d%+KTg003K~aSupP~7#4?eUF>wC zgIo;Fn<~JD>$4S!^K@dt)RPS8yXslS1DH4pxR50U&TK=5oIsCH4Kj*$Z?YHdv?aYE zmD0Bp!q2wOWTexOG;~cA`-|k`Q&W4F`K6;DmD8;jciXiGf_Cka@=-FddmUS^hyj~L zq@}fRHFFDZ!P-2tr9_gb=76@b!Ww%{o{>VyGTPz1&Nex}{5TR)53L^B(3AcE=cc!^ zWv7VMW60UJ;TMmvxzE>(c`+J@Pq`mf9Dif^lBYj_S_1rR$?*z1yk?)uyr?Xf2E4sG zxEOu96O!+k?mk?^DiQQa!!E-*Z}dYfA5P*_4ESm1?Pi-P3u^f8^l$wW|6E@+R!krw zf~%?)R1FG-DKf)s%o#%G^VDkh%@yR7Z|-wv`R*4|h(}+TR}gH7fOyCID>B4)T-8WV zlh8jEJEIYiDDRu;UExYGoM+j}Dm=$9H9s^7i9R3{Jq#{U~#Ky(LlNPAg zGJo@BetesXMR}MKI}deKlK@uAG00Iib|ORDlv(Y$!6k19&a$paWe{h4Y45j^P8j4o zRtydrk@E1XnItaf@DE}2iFhFDyM2vdd$NQZTP3Q~9h5kgGY1UnU6x{Um4NuG@!}C{ zn+2ydh3bq_pV?Mleu35M(R=pa-#m{T#N0G@3cJea_Iw*SC7Az59?L6vs!eav>%8m^ z9TKe0d<5nK%eR4miIB%AXg9}elb*we3l_+~Za&BxS!EJ9dX3c)vO|HO0QLWby1i@n=c~r$1+Q$bkTJuQSi% zftUl&B$Nu@pR@jfz$q0!@JpZRdVE-V^k*8IL7mMs4dL5hK%z0?t(8&*$;SNh&d1n~#_4Q9u!^f#$9vyXf5gRJR`Z@aHz@ z<(7+|AK-Ae8UGCiJ#MleUM6dG0Uk~hAek2ss9ZMN0|W&D!q_$2XD0(taI+$!*UjVA{Y>%L=(mE`T1C_@_mY{fREmbPiC+QIKxE|w9nksrO_Q{3F9({i zd`KBKGmA^(m(vqxv?6@V9Q;8T)Eh zUHUI*pKE+YjaHZf2uc4UG406}{v?A5?S=bW^cdU0GRXYxlCincLv%IoyCz#Puw`bjKzw$-5*2L5wGlqRlL$s(oAEO< zXTB~oGJ5vo`D*DLX{wzWaf-#2V}*@F*h`R^Sm*f)Lo_Up5Yo*NpaXg+NaF~nVTm*8 z-S-xG{=sfnOyBC0`?7JYGIKUnhkn8$Z2W9k#Xgc{)K2@uiAh#%i!mcM!y=RRI2`& z%-EAEry&v(qOzOkP+cbKc*>fF?~wf6el^Q?Vas)ej%fLdEt%g6e2a(;y-amSqedR` z^FCE4gftP=T_7f4v)YC?VDZ>no5qSPNz!5@2MIEN% z_n4X&R`SJ9dq&+wP(1yGZP`7y|BZJh4_3$|`}|P}Rx|*2bnmBtvwHUV#d}uLkDaXr z|B%(vf!KU;O$wn8oA;;=Za)5`1bIVqT4wxUHtu3&T<}=aXQtYCpnIv6D`?n;4B)U#T)hCmS_D2%-x0sgT2A`CYQJ0wG2#hg!B16rV0m3%aSj zQ^mnCMB$v=w+B?X?wtxItstvedP{s11P`p;oFPJ(FgQ-{7A<_lK^5kTjf$>*H9>tq zH8$@hE&c?@kD#|!l)z|UPp}e_{l*;s<3?Q-*`>{kaJ(kvkH{T}f}WXLB~9aj8Z;i6 z4-F(WnDuzyn@t8k!)A)X9|PMO;PrD<9+FX`ZV1g?bj=jEw$<=4kD3l|(vxILydJ$- zCO`HhPt8r3sq`&3ixcMdrOvORt*F2_rC^aHZ4BO{R?w{rV-R}kdEDVFTrXt%Zn$z(QSf5V&$%oHapl`-QfV&(;yeU?Rok2L=*{1J+Ywn_8_v&q8!B%wf9J5?BG!oM<`iTiCl%M(c2KazO0Mqb_@<72n4p1myO&?Rj;^HmtL;Y* zq6IF2)wGtxOrSTAT>Q5_vX~tQ1|_wsqf<8LaJpcA^)^a-NzTh3GZMVr!O-^Zs%_Ii zmcx&0vLO(97w8)O_AFN04}?(755&MS=ebaz^2Dx5uP-TuT7_}mFN>Dp5#BK>iI3?t z-Vx#wABm>;!~!k^H9s7L5=OwVcN6v_Q8o#*+MUSoT?#+iP2~91&L7wN!f%Jgt`<)e zl$4>jL}rnwd(?>YVd$oHEr?o5E2sxSC7qPq^{_GftqcU-KXHysZV=vN2DNflpep|d zv1~`u!IMTkXndctAeu|TW{a>d?%6zhg-;8hN}Duc=sQ0{+{~W(lHS=|R9zV_oNDk= zwTyLHysG=8qug=1?aZJhF4OM0xk7rF{byIiV?obI^w5u=Jv;DfCqGw+#Z7%(gTTG> zP#|ZtW$eVrO>GO?>D+txS510GnXFF;AzRMB;(k8~+Kuwz+#f}sjv0x4H%FyVs3PA7 zZq_o^yKs+Qerc*qe1o?7HW&e05Cl4VEYhKK`F*{BF|`L2ee0j@ z_c@S29ozjJ1DPw%gVS%ciyt0?)?aQ1JAnqJ6(ny&!62-nqRw`-pRyq5+d6x>&t=G6qmAkNF$=OoAFaiH{&Lf4ah$}wK%wB| zsq4^6YsYLM&Uwr#3sV(O9RbR>zM0t3i9SIw4cn98Eag*;)F=HDwAg?-^>-?xn@=-ey9k|qXY~SZP}Lfs^dftnezUpm(<#9P1>cezbB8W| zzJA871y;L&LH}*5!QZ$~(NI-g_~`Mg)$_g!g~}T$emDI7C{4uxsH_0Xu~GQ!`yggj zi6?hfgO)1i-y%8KMpuBb4)8Jj6xz*>8PBq|M#*LQ3s{;7pYQTsNUgKa^?}$?q4N`z zhQfVH>VIJ*A*A# zs0~sq417^6srG;ki7lXTQV5pC7&=@}3AyzB-2o9D8b1FU463nPOe`neE`+LCQcPgR z0;~R#Bk2En3(p$_j;j5ZL6DCioCA>$Sxte^*q~vW&{YlOH(jkY1lP1{|&}PX(S?R@rU{dOvMy#9b`cJPh z3b;n`CgqTAj-u)$`TyD|neCMRe%F&b@=vIH$o|(yR z3j(dzB#S8rft%Km7dHr(Pcf`e&&x(+WJ2CDv@NN9bV4>&tZrqx9+gxO9Oj|COe0Mtr4CpL0&AY|JpFXNy=e z?!z~*h@hNM()5omA6o7-fvnKQDGbv`O!Xv-LMmJF>J) z!r(!)A;Um?DTr11z~w2pjs}eMyYl`7!}LC`FlLRY90;$9(I)W^z?uKC$6m5m7pY=4 zE|M5LXd(|YM-IX7SWPBT;Rqvyd!{$Bw6wCwwh{Q#)XcM0A^qugvVtkfBW7bJ_}V*r zh~G)1K?_9LUgarqA}p=-8rssk7Dj**&0MmTY!Enc;Z+DPAt&RTr#iJvNWxkw3S_KRe&7 ziB2?Bw7q&c>74v=tL@?8buB5ds3Ief94k%5kFN%P>UgC>fiJzr!Nq_{%|XH#o7rdM zA0mN5s2R&JEt|64A0~x-36cHuuR!7KEh1vdH}cH--GiD6k(|a`-=M1I8}YJ=w*PKi zch~cUr$279#cDclmaPL8yo$~+cWwt{bNW~YdXdOUCvp%ynGl*Lq%bfFKim(^joI-F zE#9chx$m+{Zv+kx^*+D~&zLSCJ_BRlI~ych7i7AbIK|fnZjNbn>nsREWiaY(&dn(N z&!`vMd@$s^PGsA~z67f08zVPG;|IfG$4+?HMlfU*9?st=M#hA2y%aLFD!*onI0MBt zhJ?>e-1##hIm;NYSY^v*<(!K-whBERFg*ZyU>1`mtN@*A=Mf2}R25P6B?3{TqDD2} z)KIA@0DWxt-afxs{prC{SYUQ%7%bc-lIKrS zq#lB#p(Uefy+1G~#UEJPj-7njotT)Hr-Yqp_dYW{kGk;<`$?jsDwWzJjeHBB379^< zCf!EQzoJJWfXUJ`z+4j~(X1V-C903swUSATouKtNKC$1W=k4kY&Gx>uiw77NKhSLc z$QHyUu8u7(5eCPocRKoR}-W8o{_f5-JsujHMT@xDG z0J}Kk_j?M+k9kJ5pmG?WznyV@!c9P!O=Ch+-^j3T$JdLhvbQ^qDGrnvE#RuuS%ugx z^SbH4na= zf|F5e2nx0reH=Z2NQB)V}+FqecMqBL&e& zwyzv-W*EY@j3S$=A@};-fnsaV6H2!}8Mmum5>7v^wIBCU(leTu95yQ|Yx+(R2v_ublhv*?DRKMpQPe>0PPL5S(EB&= z9%Ol~{TTIoFM8hhFC&t3&1te?Y>6&_?-DQ_51vLB!hh^O9|TtZ?2>m|%^l_FcEIO+ zPErcQ0j2^1o+rPB_MZhh_XRfnB7pJme_-Xm=i3)c{GS)qt!vztK5bvXa6nZSvEtlq zUVG5px)1aL`vCKm9EmxN!M_kd%b>it0o1I3G_Ol+;l zGBO85$2uE-IV1=oC_Qyk8r>$Jh#mpO*P+vUeW3*G;?)ua*dc?>}$%;BPUf=bC}EAsN%A?F8g8#EnDQK;BXStr0K6}2it0c5g|r%3gss8 zBLSaNmgI9L54L6h=0+!L7|pAnG2IiDsv||f`bc%WinWSn=hQXNm$*^07}}YXEjBQd zxhZUaGVca(B#`3TajWzjTzrbLO8pZ`nY`mU>aH+ebF*U^d* zZP#T|_Jq+H(C3k-f+Y|?hVgxt9MlTgq@a}$6R6M3`n2Ws3O==sSELoFeCogDPidJk%c7GdWL zeYB0tF8RUVHHt(@I0-6~?<=UxZYGtt*YjERUeYXwgE8dX6elpA9E!(@q$Cl&$rvSP zDSXx-iNWLC-^eHLa1{yP=H(m|gz@|zQ)d|!)dThGVTSJR?(XhJ5NYWcVgLb2=?>`> zB_st2>5z~dx7dANT-s&Y4+j&OUqp_VYyNqeb8vl~h{dM%^I2 zASI%M^XsN4Vl|^em5royqy&(;Vk%y#-4z6wB89KQlkIJJ(?MAod1~l$lX23n zH7R>51a$S8?D~cZgA`)qW5juh+G(e`J530(U!bm^Ju%-UKcPpC7~t`Nvy;vv1Xm$= zdA+in;T<-(G@l~YDBIjvwc*{};N@mr zt&i5&iu9H_LTA0G;I0^M+k6ArQv}33QqK3Ee&dE3Z4;1c>7tIZVyBcx4!wV0Lzqv4 zq9y9u*|+fJPI&53dI;D?;BNRn8VuwA8mY4Rke!FHVUe(_tXy zRII_WNCX+ZNo!*nWG=CW;ryQk&A4$vr<)t9U{jgcBdwpf5OPR-DjZzPqkuTBc1m9K zfMsf0pO8j%#K@I|Tlxz@+nO_19F#ggJCl2bYs8F3T!Zgm#Rwn84`UKNn3qOHtfL-R z^AC^0LCoWe21tn9Eu3mLvt3Iek|Kkqk;?UGbP39Bn z)9CYkg_tQ4)LaN*TRaXDjzNp)b2x8Fj}2-!a;5lkD&m(gn3SF-x7NMlwIJq)Um#k` z9-<$%IbnnF2``=K1MsI@qimzEKUUcWRUw>9DKTQbxcIKT33YD>@Nd%xQWttncCT7M z*`Oj$(sj;MIWoOITP;w{BB%b%M_wqr)Xpp5E1bc6f?e3*qd$jmJ4l7&Mp1ZDv;>kp zwxBH3Hoz$bnXQ+M@speCyQW&jo?6Ko3 zGJwgzkSMl~A@x2(F$0!;ar%FrAJ$gSZV5E`l7GS6c*w(DDv7ej;i*X|LZ{{vipvVM zt2UG{EdE(q(tK3v4x67f^~AHPt|2&966w2F7`ZGRijyaWs|STrEB*HMp=~KWFtUK^ zC?t&S!qt<46bZp|OyAtX6cOH^n7brQHy+hESp*e`OQKd;!XCu$7k#>V1H%vsutlKT z4xd8pQP8xeGYQ|>b1rpCD67C$yF%xGC-FVC);VR8`#YhxsNET#lpja4;u6@!IZttN z_Er@+VjI(WTBj#7kBkWdMA#omGG1($gOaJapn#nlSjfd?>`!()P=ltMn3Cf{J-SQ@f z7_ek>Qk}L973o`zui(Df!3%ZpI~y~sp(_0vp@Uz>34eT$z@Lp0m0qSnM`kNg zm1LE8tsZZ6r*<3;4&;aUGq)?d?cUlP37;y3o`q@N4N)$^m}tI*6pSEP_Mn0jhu7pNU+_)Em>Wa}$TbdO-aiIp+h-6R$rE_WT$ zkgTic^!>R(#^zfXa*Eqesu0@4l?2SU^btXM_bNLDgE=3hNELX?>_Y9+_cDsyex32U zqpL`G9{@4CN(~#W}&(IQl-i*_-;VuYY;XK2t3<#57uLjVzb2 z(Uk8}i+$^9NNM%yXnxO|wG`Sx>M9d=07jc|24r<+L9 z$xGkQFsFgT;clnlZ~LQAq7H|wF6^iZTr7P0qWKbxof)w?}aMSnuEC5+trfbr+$%B zjLuUGP{ymPHUJBCC9)#ACvC?S^vsn$+LArwaW0{oR@=F~iScP$Uby6CQnd+*428Ap zuN0L(lLORQ=jL`?`jy5=j$B@c^PyZOO<)kvS0P6zC^x9iZEV{}difTZb^4n;l9jL* z1Jos&q`V8sf$sMhfGj$ZE%z=dWxGzw#i!?BgOpe)G)KbQ3Rqhitiw#V2qIfKOZ1*S zrlF?p|M?Raw9;wrK4enN>EE&z$7Rw8J^RxK3Smez6nOhc%w-$_P|--OPXQ9rAwlHj zPj?^OP$M7qpImg>WyXwS~t8wZ3ivJ3}>S=_EBaCED z>~{~L?`V&bY(Telik(Z=QCR*~_!n%w2=e<*R<7v=c6>^=%2b?X6VU{;;$jTD$;>r9 zu`d_+Z-;)UyV9dYU_rz>TA$oU94h-Pbwdkfr6|#-!b1{fv}$JQN8Al}HVti1kh4w$ z6Za_)8LspjSwMpq$mjY5vrNTYo{C|;2&G4*V7ET)uZe5FJoWA`XvJ!z#PrKQ`c&w~mjp41k{QF93kvlq z0~*fasnqI}dE@BsV=@ys_$9#Ibs6*T|BBiZEJ0-_$tWH_d=f@LJMC6ksJE_PcD4pK zzjnQrKJmQ>d{apjQd8+IwIBHA*CXe~*NQN_42}(1sN2`rLsoys{d8lxnK-uK@}TmK zqOF-uyR-@DpO2-YwGD*}7Dhe@WrMfuaH9jfOHtboV&6C`@F+*e7j|F#lEF2uM2;lk zQY@U5O@rBQB@FsiHB=@PsjeFAL^RGQPtYi>sU_1S;1w&J6oOEw=g~RDm{6>k&gL;l zCqAS8(tj*gd4p2mGn$(CCU*yj9{-V!0v8*S7>OcVYfo`8%KkCe&NuMM2kS5mYI|!H zH|>{(A>Sn|%X`u0 zK_(k;8DK;V@-;*xJ@0l(K$XwrO7$9113#XeY>d z*ILMSe%fyjl4H%P6pVbiFq##BdFqQX>uo87Hn=H8x+&X;UXekeZXZ%RIO_*ZnLUY%b@x1w4NR&E)9A{R>X9*s#|KhWs{2z^s3W&VaO87g1^18uBY>26 z3e?zx!*r?0swkFS5R)l5NbP4PnFOthC9tcV_IpB7#pO9 z+l-_m#wZ(zn}d-GU~e^~8RT$;_z)zIEHtetRJbu|0|V_+xADM5k1;lKv`54}uZ!TM z)8=S#{sG@vw!`<&|Dxn!NEZNICR)EfCUh2b&iV3im2eFb{&i;7a>ZYfSlR6Kuc-H| zW4i{9rp&!0b#Q=GZm<^vKw-{bIw<0c5$`VS_{=ZMsZ~ct_gIcO^c6-vgZJAUiMuYF z->{#I1G9?^^X!48)>GBc9U+vSBfRw{e@#w0Btj@%en<`k_S2)X>9bXz?gQ(%P~Tv3 zTQdU*$sSk&Y5+azNV(-gBRz+>Mf)y}Eq~%4z(U=mu)~XkEWfrkSjV}3k0d{IPSN&V z=@?Ru=JO{6SrgNEYt%@_4-NUOpqy|t2@djg$!O;%?Y&pC{D94`0o%)zYO=^@{VUp8 zu+|CetzEtZ0?oh=p%PD;Q5ul!!r3@-i%(3n0x13koB6pFlkf+mf2}bcU`oT!DP>QV z#%&&pbU{+EVBbYHVC@_lX|469aGm$3gVht9b{h-ZY8}8KQQD)=3-08r4s@mZhHE+x zl8a>%gp@Z7=7iS=6sXx!@uy#~5egG5XFJiGr`4g(3xQj(yXtelhtwmFDMx~&ics(} zUUbxznIJX#A>jD)?wWYN11Yt0D-c>Fb6DHaEIXrgaQ4RI=TOL-rb@OUiheUkr*)L; zN4vGKV;*@y6kSGKhm3x<$l!$nN)F+UIVhH689(>Szw7xBMVIR@_jGH6l~!nO)SbEx zg&2|Wr>jy$v|3yf(bcSMZwULd23nMHOi)bX^&{vZ!5uD|aTZoAvsON!dYI!zHX?iwe~VstZBpZH|W$&gALvdc{aMb*}de0M!qGhm2d!UG@m8iV-Qg? zz@d-B{*yU6a*&<_TbK(;*OyO$n48YMhSuDgb7DckRyAipv#)0TO?;BKbr3rAB|^!} zm>C!j-domCe^W^MOfiuDPUjbFZn%LZw$a&hV&Xu6N3`E?Q>;2rPUAN^VMLcXyz1*U zlI$$)gu5mAYzMK4nbVu)R>%uE1M(SM5f$R18@FKn`Zzi{Gjc@dsa$+idtr)#yxR%# z@V3biH;w&aYgnpQe?X3008XLIrADz-gnM{ZQ7i9T65OG%{R_5oxah!(!APuubiN} z>Ae1le`OM%sHo^xU`e(+KTvfMRi9)>o_+sp&Pvl(Cl7`v9k+WA`;SXrVAeJjYs@@` z^C5Ep6I`RYovc;$N6)*x5*7GXJHfu!PSaH2iI2Zk-NU$dr^G?sjndt~^JC}64gf=I zyL-KJc)nuL_MqN&Fy;NnEWLxkE<5PXJU#Ge^?{2z;Pi`6;N6IMCG_#c?KUD-*8 zYXD8K$U`Tna!Q!#JGg zA+I(OD1ZAYR00Y&z%T7_BgBesM6nF`T8SydT)v@iZU#Yr-PKlB7iUGj3n4? z*#DoJ?do7|z)@;)LsmTbnwbjzNc{q3D)cf>1kIjDym`irr=yvDgoBn_&eQ3PWU>qV zLpQW`pZ<;Tj-~w*uYird9Ix_%A$mwtaa5%Lesb`uK{;L< zNvNH^@M^%@1i8W#>y@@HRB6H47zn{3r$EREzm0G4Or-We)#eEUFZ-2tMW!fPpiu$m z2wJ>sfW=Y9nwmseMO!ur{)owz_e!<)7>s|?&CGiBwd{i4R#8^S=0e%BZXj~$NKDLx zz6mJ}v?kNKCYgvfmoUSoj6Zi&g9{3-6d^&Sx0;=$l@*Rwc>P<2onuIwYfCXdY&Ixt zLln$MrX_b?GpwI%9T7;|Tj=qAIY8%Q$#*evrC03rX;bfFmuc7o*%TzVGRRH5Z|CAP zdBU89Gin zU>7VeB^$=w+jK4hx;M+a(^BKIwpOcpcb8t4~6 z1=*nDA4jV+2zNv##z0dH+Z*J4Qg*O$CE0AijIi_GEPd{wn}juvKUW>fmB(tu#M<>E zzRL^TT@#>-h{QxiKQP+k3J=}5hjMs<)U$Ol=WA-8@p*fDeI2Q27-U2RBWBwWnt>LW z5n`bj@JR_-nnr^1F%cevVhU^`9c_UWjPt21Yi4Z<$a2BJqyk*U&s?Cako}U8-?%o% zDnvDUJDLftp=?kca~9WC9~S@s@ac6uN5Ct3L*-11cqVU{hT&Hl2)q^5dI=wfLm83j z>i4&%%}7s;UgRgDnP0jaqYzOJ(-R`v6VlgkDig|Tpww^=x8)XmSR9AS*zt>w(9)aj z&;9-XSpZmxo*=9B10#7^!As6Q{ZOZ(X9$X11YA5RGw8?iH!P+w9xb)@c4VV(=2#K@ zsG<_M;iWbL>q0p62%*Tw)$r%*mr3#v62)L*SWM95PJTfUO?yyiP<4`db(BU^f<{xE zb5l6*i#x8yWyq(q?#;KerXz>G`h?)w?`p45IU zPW>dA7G#Yeijg()o7Dcxf>VB`ooiw^Ilmp5&k-d#7PikIKXzjsF=g}`oK`f&69SXo z>M&q-M!vuBG3MAZS*YF=1K^C9-RWxt3<$2g$~feu^CPmTOqUeWwJ%0U9uGxtyBFSt zWG%kx;MBgp$K3w#7fY=Kn>9oDctelc62t(g_I!M5qFHETkkIMj-Ep7)$dY6AhZ5ly zt^X*WwvcV2Naw} zo-!tf>EGA{#oj4X2k@v#(eHxbz0<~79mm^zy?V9CSEGoTY2t&O{iK^?!;}qgPShB^+P@*H<88LbCdoaL$G_D^`<|q$0$$mqCQVy4 zpB&g8%la!d&q$#QQ#ubv4mmZiTd?OZ54ajjM3nKh3Z%L5i=0E09%LPg#-WUm3!Ud? zqApk8erZK`v*`F{RnuzDF}aABX+Vkv_ehG$Ypkl{0%Z_U8Vf0+fO9ggBzFeF67a%b ztmK?49(K1`XK7>x-h*m}Y0emoBH-z6M*>>N%qK=eF~MByV1d*g;^^u!de z{rU_cNx1dr9kNYJau%@V-Jh}CLZ1RF16*E*YM4rUr3g~Kxw(1Oyco{!kd(=G{+YgT zv1$6FKp@d|#RuCzeS}HJ+KK)@Wdw%j)cwUa+^-FQHs>|*J1K*!(k=cG%$>RuyR^9p z#euPwLz22%qoP35^@Hk8x*+EU2mX}jli1*7FDRCKKkT=|S+_aylWI@Ns zZIPMi1MJWvk4gC7E_F}=lj!(4m*Ax(*K@-7l9EcZI2oTgfU;<0w|-bZ-d(pfviL^}VD-TMAL-@5I&X|tP>DtY=nvHrT+?{(YLoy?Qq&a}xl1_nG*+ zlV^ePwRtDk|Idzo`TO+tLs;r?`&!rq5Q9zaoVx_E15JpbzMxjWFJqO6@S){2tL34x8;4} zH&^Ak=xBaH-gfNS86g5J@igz3aX5OYPFFuUfG7P=9AzKcRXvzWTGfEv7@y^XAhc@# zWuEomhosd$ATu!h)jy5XNC#phbEWr47=yvn2C}$cOt@W~bIv~h3)z$X%nobq+>*VU z_>7%@g!A?JkS&@)H%FK;ksJfoS`jizxVfLvH-Pao>GEx7h*SlPmShm#jEO-K=qNFf&c zlR1a1Y$G~`)f6rui{wWbvYcpEBp>LV`(M(u;rl9AgVY#D*e_0)ReKwyV z5}uY36%H65H=q7^5#@D%!TxRYDBT1WJ}_j@ z&)JUIr#+&__jO3}2BJ747s6s(49g5k`AxORgb-XsyHfSR>>6%s-pn1h`osk;{~T`C zTeVKtAoN_o`1FB~bwqx1%6=MgP#dti!i&60d85dA!;z{CE+>z#$OdtcMqjj6r062E z-zjwY^HOHvUx7ay%Q*-@KX{NtA{;1UB5D(xI%(|cfBZxrr&inGRY zWBMZEnx-Kjj7I1ao8pYbx6=Z5mYN2%?*<`6igJcoIaj-OQICL|Ed(;!6+Bl2@8U)Nux~}ofYx}9=*P!90k~04 zU3Yp==$2|I8ghVQ69hru5SBxf#oB9+SvTzEEW0L@ARjW;d_jt6i96VxhRh_%iJqa8 z>tZa(X1&y_Rukn>Lq}UkNry9fdxRCnS3}L8=>FXg%4|dsJ}ap0r1Gg&rz@Bi^)z~O z&>!Q25W0S*6dh(MliD9Q17A!<`=$Xr$uGb>mUBIv1e8}jZ;l)S^P0uQJ&f>_zms4M z=^OqXDHrmd38KIRW?Xx3$W6A%c^Im_(zV86#3Nhm{zom^Ax+ViVOks^R4vm_f9y3i zP4uJPyWBS?u})!K&AbTauQ0^A&4=U|xK;Ma+Nvus*eu?nAjRgB)61WR!06)Od9>Gp z^=5V0m_-$}FRoEvD~4S~k6PeU7>=-DtI+ZC;^0H=+cZlg5tC%IqhhA>=zZ7~sTXW? zBk~<(blMS}2Fepg2?%<*Q3NPbLmM1OK!k?2Ip08$@cffC#?nOe2wW|{@Tv5{I!az- zVGmYx+bzx(sdp`AnBx?Rg((a!n*loE8?wn=U+91>Nx;G2+yo5oKt{1_(S6$)$zHnoqmh9mHP*))W4yswVFrqM=CT!bG{1(DX4PHucVVU*F} z0}~eOx2Q0q{C5>R{0CZh_)NZg5=V{{ThfCrn91Xa6M1@JtWXS$ZGjg^S_Q zdvW2e7^ANoEfkRpn`{w-0p*SjLg))#-A^<&)z-4P&>?sig7U&2sdUmEtdnQat zm(^=3xf^$#i08$_ZTum%n3sZ*E~dHGXWf>#c)+YLrhpZpN7j^IW4lr=B%A@;6=QP& z`DcttZ48^4KHSQ5u7I<7;{BWSLFhoDo6~A7Hb?+UF%l}Ij++N>W%Po81kWjF5f?1d z&%T3b`Pd=+IImvF5%Y_1=@z-ms!Qaj7-^ldvO!P)oMquHU5|cL}bizDMG~y zFx^qQ4Yp%)>3;p9KPn!6!+7+iA`itD+lu1{B9_luV8qlTa}+s@Q-MYJM3mg;qxl~v_LMh3nN4BNPD$*+T71%B9$6Vw9dh$3aF%nX0^qSu`( ze(NthVz=$u^@rY@!mx}h2{b+2AK(`mH3y$V9AHfO%qXw*Pu4ZvTwJii^fI6fv@$6g zr4G9Rs>C%M?qod-w{HN`qsekStz)Vq~o-2D1ukUdM^}(8lGv# zZ?X;Sza%s`TEqgQchnqbl!1=hKEp;QqsmvhOf+D>c5_6z$8(-eJiEL=;@kjth;zpl z@xwh%uxOlk4q5d`T`vS@mdx_y$r+G@oSA2Ca$b%N(mvESx@RzZTF2R_H?W|(!B4li zru%0Nom8Pw(oPe#8v9H>ShegtL6+QnNqypb;(xZ2_uQ5(+`VPtGkF5t*=4;`3avKv z=i+W#1A|5t??mQ;kg|l|_A?|&hWRR7xL#_j0LpdN?Y|o|&!iJBzX_{@eI}Al;o&&S zUaS8svBea=&J`w2*dysQ@z#lA;$UQiwQ^u9dwO<>G(0KrvXGRFtgGeffUnJ!4(K<( z9@$v11ELJC^128otz`aA#$Cpu46)TYk%T+kU^WiiJd>Ljh$B7 z$bjf6Vj4{VuYQ#wURN6k;(0^VbJbh>1DN8U4l+mzqn|$k`qm_YaZ!fcDc9p4ondnw z*Fyg`%K`dK*z0oazWteUN9n!J+qlk*iQK0E=0!yI;y{toq^{ z{7xE|oU=Yo++7_o)JTl*|MuCy0!~>fH}KlX6o54e><>8PQ2VU?=dEox=>&GZ)eDJ+G=<4Jw_oj^klo#T9)L_vCz_a23vJ(zqrtJnIu*UUBw(JrD z|F;VNcla8BU&?9x9TjwH(s^Y9q>?QX?oT>tCS*GC0IeCJ`E^~o5|z9n0EcbnmX;9} zjTUKJ$Etl^xhxi02fS`inN3nSUrD5?;J^Q0XIZlLA|(F_3rSPUoiU~`e_!2Fg;fGS zo6?0Q30I#515e&4e~B8UG{W7eyj~(ynN;>qyd10ArFaf%GUP+yWNgnbaog05#g~tL zXFa?v?5Eh>Wh4<#o@!P$Qaobd1qt6+I5onB}(Zl*Ya?j+;(*=;7pn_qjzm5Cq>dGhKVsB#hHE59hvQig@ z%{Qye;?y*!V&n&B+1%R93HBV;cIT0El50GWF6aJMpp{tXPw9;7_iO1RU+KA zbGIQnN8sYhlaaAA^kjbT!Z>gY`(&;2%#NxGOITnjs(j>bdE^d&(!QgG?6@~qytj44 zz1SI3Ip+7F`RUxmCoLW6WhV)y%JHxl{5S;f!NVM}UH)=A!3>5aP6zXzWj86IN?^ zBb$*^MpZ21s&|V<#ceV{ZK%Bc%0>`X(1#(?-l8d32s>@GQP#_kN?meFV2vhR&g$Q( z9rc6pN}Y29w}uq&c?zH0$Gen@=MX8kYu#)5=_cF-v#>9EyRaaCM-Noe z_%I4QaMwt}1>)*Z5Y1C_Hb9ssx|`DBMIVl}?DdYbLt62Itxv_fjF5>PBwoOTu1WfQ zHH%a_ZE)*vs2LkkAP+HQ21p@0{3v#x_Qb7eTfFO$4Vfa-G1m(v{=J{s2^L?ty$&{FG=4tqbQH-%>Ot#Y1guP`u&~W&PU{ zfOa%4s?o~7Yy|KXjn8S`Lf8|R4t+YM$HX325FqdhN*dJDq=S>jN!k@}+LnhZ=hBT} zp8x)mVrRc8=5E>An1=8zvwko;Vf1@u1F3z3E3lXg?fb%^D6=>60&O3^{V*^_qctI@ zb6~Xf#eu(V4maA+EYbn=oRc#g!iCw0ytP$Yl@~GPkHCHIXPE+aG<;9$BOfm%zgC$~ zCxz9cw4C^r9Zy}B_sa(yx-x#Fo0*j)QL#ekYpdC3P7CBi>3*lW$Q z5zK465&jzyv(}E7k>)y1=Pkycj?f?7hOaK^3GaL2)z{EWKKGPP`PkPbLdlB<4~-T_2i$MmKd;Uw4ArZkER6r!*9Q5gNgj z+j-#SxBJeY)Ww@uTsZQmuy9_wj$p~_5I%b(QR8`!+L(9v?5g>596wJ;c5g-y z4UzuD5X{V|E!@8S-6U#*KU%V;tGjT2V2A8Ou%t6wS9sst>|Hr%@*0l}rfTa8kYefp z-6LbZm6WSN=wnN6IhC4~UEEc+vbobObXwreN1u3`zLW>8%+@;OxK}-);gk>Ej;JU! zyFq1EIRC#_v6t%in}9iU`l9LtanMyV6>J-tvQ{qjVkQhD;~ zKR55I0Yd1yXpCup*JJ2Y{qeQ1n48=Vz4tg*`sPwy?#b+0S6-2|R(FqbIC%N;W&OV1 z>?hlFEN;&}*dJA0Tsp_*HJcEwUQi#;B&n<3vNvFV%RPN8v{E%i_-Se2s{OETxpZoK)0@Rhx7M4c{Mk+@mxc z_y=7UN^v^|e`OJod|xhezd#Oh0Pe>;fqO5{E=uyuAtv6fW^EfeC)sI_fKUNQ4yny2$}tdbf>wU#?fHkP@rkS z1Rf#YwT;(d0^fe3{ ze=5(swr7aIQJC1Po9Eks+n=!XbpXYgb!pPN+`l99ywm(AH80!q$wP?c=Jnl_(A=*o z;M_DM*AcTlt`|JaXaDOf`>$M31F-nM1IY=u-);lzzjQS#Ln~XCxt~sxp6sW7w_VXZ z<24zO-X`z?k1S-fRc3KL`jus&m2;IQQO`FFGz%Nz{i2uYe}Jwxkp}Rop*B|WyJ~(q zZoAO<9dI$$a00Y>!vrQIfki8Q9R9y6fZWxw>hJkiN#~Y5<})&P_wn`8!o>J4J^#;7 zPbbe`yk04$?l-gS0UFkFws(tffHTe9`wpP|F=Xy0+t2)+C>o z{73UTJ4>?Qdo5hA;Xzk2UG(oP6Z3$ijD7|DUymB_uKopr7y<>@Y_Z^Ui>GyCnK!hb zirXzkWRjnrKViQZ#>lvQeHQ#%@c!ujzuYh30uB~FMFL)8>Tcl6iu(Q<2s}zF-5FfJ zMK+S?d;0sTqqmVOs|Gghv1a{CH@Dwd5Cxg^S8+a{r}AGor!m34V{eZxVIZNEA^$gQ zB`tM#bTyM+H|<;eN1b@ou~(T7`IKS%>7Pe+fU$fTj`MOkbM@N0QyOEIZrS z6vaZKX(~7I4)Q2Ju=9xDzJ?oZEwPGxi+c4=QmMoXrU>$kjJYtUZX(zxz8^vD)h<2% zpeyJ_d-J*w&!{Q}m+B#NBzeo1+?a6(M*u^0tlV0EuLroJ4l0^?3~Q1OpmM%&O^6AE zRje28zOoA3zGGhawp|j)S9?N4TbAg`;K9Ypsv-Vm(IDFl3C`O^lr4)qbMl?40OJp| z%=(A^mRiS^aA|n*RC2!+VE}y7eqT#(=v=$|Md#~GgAzV68zN$xqoq1dhE|~@CY1y~ zISkUG>xw}|WSF}R^i^^rvS;o9n?fpR7E#REv_qu!Rd!PtctN3(`cEFsh8j`;vw7ye+0Hl+G}#3;ty{9l#?gwa#t(k*;V7p_mMM zAor8|uAsGAvA#rHMPY>_^Nh}zzJVQIc41uDMqjBJi^Q1F(k3GTGBDR@i1mht-&;#O zIEZ40@?0`0(c#r_8D@_rZ$_#d3i^XqvZgj~Gf(tk4JUJ@GLZ{kQaBMxU1APsm$6y# z?-zz}QUq*d4Fo6gC=L(Bt@rVx7r6OdsBOfw$P{Fx2*>=>#Y78Gqn9-ih*Ri72}OrZ zAc&&@(u+aSv!;WW9ee&edV5R!_qwTPlL)KezN zEm>BVRKMf(fpUU8^VlXW?$*ko~s za>aY_M-tayckxQtYrRm$JuM{R&D9%4CzV*FadSq?Rtb?N|GLHAMdzlRr7kNMnfZh3 zEagUd7J25c@@1hXYE6h+{=UDxrR9t#cV&2K<-+hLX}+Aca($ww_c$CKIxWT$qE0Qr z5wea7+Ejj#$G)~`1$71Sh`&S9kla(u4j6vv3dl~NGnP?S_V(2LEEUtpuKKVLB6U=@ z+abd^=nOFd5-u2wAbm=sm{ppHiar_&Tl3mme>nmAjmx;J3W=hG`~WY}Cbx(35l1Ro zbXmEs;ObNRiE0E44H^0`?uq{0eE!#c7ne-{ z_eV}3R*Erik`;Nn;u9B7tOWKuM$)+EYpo^vmkM{UTS#4!tZnX5l^YYF1C2+d%e4>* z@8jO<6g(ga?4JAy$6nY3FlOQbq7AY%n(vr#Q6!c=_6U*25PQ)$P|}oup~x*bAG8yq zETB;T4ekg;zbPNqMM)Va1U56RaT^;be_CdE`V9S15qP|4$t$SK$=beGB?pD6bnXgdc6j1}%H>2Itc)|~7y~sCPv1Z|=j3B> z8a=^by!srdI&$ggjI%;RvCh%{(qxacnAJ8uom-v-I)|%wob9y=BH=toLuW%-}o7}vho0Loi&TH*I{pCO3u7Ae=L>6z|Z5$ zpU3YekBCzCTJ|gE2&%OfqTG{{!6Y2QpB8EH0Rek1ZYu@oZkc-VNO(%V_&NiGof4*5 z$*K^>uJq*8g*`;YAi*>!Hi(A9yw8Z_qq0`2EtdQJP0TlRdCbVG&tboFCe2iggRtiN z`T7}`h(G1aYnKNUfR?Hp3L%l<2Y>bu$}og7QvU?KxIxB_wChw?FO2+t?D(CCNFub% zv+}X%xoi(+-8tkA!D`tcF#FS?nr34i0vo~Z-WQ6phPvikrMoU?H<07>i)#dl^ea-B zv6w}V{aLGqitd8wPjZ||0>#coUuIDxAV+Rb?5rXAn!|L2bkblMBD}aeG*0igWEL0N zs{aHEW5n?Xnml3=dAUMJBh+xC_Z_lq%r1$WD1Sc2F?FxWVsIFAp!(+0!7+Bz*&pdz z2=F(LKfL>}TO;aMV8EtlIL{-ZHi?B9a7%*O3@!5pg$Iyj-?e&U&m9kTsp5f zyeD-X6;@47b&!ugnUW@!_N{ZDdM}r|10+Pzh`C84b6t^nQT>Ka11N)b(K$0@G&hG% zKeGuSv|#TCgjcYHCW}Ss7qW#;y=aNnn`*8WeIXX9ZLpFVtqxNCS-B1cPcocQz3kHh z=AT*qq2Y>^GY^~-o3wH+pS;O=li?!0Qe_DzqA;gX=Qid~Vhyc=x<4DfnT<(4$B?}W za@vpo#hh1_ohjr&?0r-`N>(IwnD@p-WZL{V@adlR=&djL<)i$pr4D!fsB7@eV~6^E z3zvU>OOWvS>c}WF{x>~cKw5dfv>HR<+>Cd0{cB*xW$`@H=lX~jm{+XG8(+~ci+m5?+^tK=#D5YKfT>Z(p+GH8T}cT=zPB_{TwD`wL9~@HR!1cI9isoTF8&Bk+k(2 zp1@Z~k)}?EsJC9S@(X*SK5)mp(#IovWi>20d3c|J^omfDbq zzgyV4{hg;dk=6Q|Cs`?Z!4nk|{SnLavUkv35OVsfo7?46#w+KfkH>O*(ja!_TQS1eBC zvpK2awys37o}xmN!=E$}_;hjG%OVr=DyulR70}l-1Alp%_>yOt3*&hd=5anRb-AW+ zds!kQa5N8sM`BU9PAYW*0pT?iAv2Tq=Pyyz-Aw6iNYCdy&&Ky}HD_!0+qVeou8Vlg z!cAP3?>(&>k9=3}pG1$jnp{>BJ0F*xh4vQy{kuMJ1#A}1i@rZ*p$BdRe|VnHF@OM%ruyH2>bZX|-B!1g z_8X0bCkJx@RUD~Htyf9a7M(%qeu2zy?yrxU*TOJLCf|5(RJ!EkPY<`K2b_k}^nNlh ze_vZ$t9nFB|4&l|l;89#&rdk&A4-;sw7P;1Im9XxU!q>|A9p?nwf!CId=HFGT-B)b z(wM0I5BY!!`~B@d`0JtzaEFTLf29Zz#w4(7OlV#RsBM#&Ul@Z!?vJ0(vhAK|DAe1Y zr!a(G-vkAGU^oV{%0a}_0uwS+3-K9l889KepX|to@fFn5nUr4%jl^bBGUr8J*}P8- z;mjnk0j0x8&1-PmgK(@13$=th*l!IGQuaYQb9%h!#41PgQ=hGwW~G^ew0W-dRlLje z{r0M%LopKuD=h{IS&|ccyrQ5JDT%nE4mr5{=c3v}+%LHFTFNo;_^#0|*obic!X$o| z18DCV|0GIZAToYUmAKUcU#j6dc1&vm+1A=n#o90Eq!r-qhTPZv)KBJ_K2Cj;dV2;` z(=pg!wZAAe0-Pfr?ZVtv*}yyP7&T<+Y2Kjqa*X;8IW8M zlD!cmw{I{8B0IYzfr>k&4ep$L4g>uJQ_ZN9n8W8rpNd#H+>{T8Yc$0f5mebd)Fokh zDSRF2JCJ(6xWoMuJiYwK>P)=PW6b>NU#5@xJ8FrfzCuAq3ZE`$HMCjOtHd?*4L>$a zDTn4kjfVJbsq{jOKMtT>C>ViMZTN<`A=*$Z+>A-S=pG{RJU!dqbsUh*TW|p+L^a;! z+XHBaGmX`Vj2+Ipj=U%5su*-Hr*laha9EnF_G>8DfUPfF6>Hs6ac>I2rmI#be- zqH3ThsbIwW3|UtHz)J};WGbUrYl2~FLdmqmEn6xA#ix%&M&Z``JRsF4i^(Wi#pv3v z{JY)q@L%G}^e_8q`CV`Bk<>DmBH?f|w_*ae1NxWgRTTZK7%WUwTpNFsa8s>K_IU<@ zD@!C7T*qu$h|@68Di1S!A@{8txg3r>Bco^@JA0Kl@Y7+T8?1o{lp8zE)5CQUEFYe1 zZEZgh*&)6oX~xIQMFX!|=UZf}MBe`*o29E6O?2c8cNiiMk;FvW978s)lp}SVJbI^n zNe0>=Jk_Cs(X$r5-8QxPd9{f&|GA4S|MEQuNx%o3EihP*yz^}UgcuYG?Funvajdvu zQsX)JFh=(Kh@Uks5Baf~Q-xuK>_wJ{Wj$w@hiO{jq&?hl1oIn!9EA9;0I6x-ex>3! zwlNd=qa&Fx=3C{$m~A+S{6}0%UYMNGwN_dnVc`Fv>8xYg{NJzNQfP74;cPJ6-5D?# zW4JTihPw|a&hTy681C+_g+YPAfDI__4n>B`fTy2to?n{&)1gOxc6jGKrWbL0WSfy?3^+XGg*SLFP#}A zi`!c?fzC;R))$TT(2#P;pT`;bAQ|Jlo>Bf@UE(h(3WK|#3&WuY`Zi=i?N zVkPQIGMD7JwCO&Yr^B^BzJ;CtAc0Vp9aU7wQ|W{&pz)v5*=HAOkf<_2(cUCSWOWgq zqcxroPpsd35nF*lojH7xw=pObKK6fF&_j7@amdGp)!TrGKaiV0alc~NuVRlhRm>Ny!y|?dM&e7+wuFEhfEFNS z?06rI+#Gebqj_Jy1x>(Sz5$9mlbL{P-0IZ#^)MG!^6UG0+$%3($f7~H!HdIO zJ6P2<9Vi@R@K_)}=^%(AX^S^W(E3dPxzWKE4uF`3_wede1&4Y+LsNAkO4-+ILh@>p zbsk|j*UmqqVf?9v5DlnO%r&2Jc|utbTBz5FmPKiTFeadY|Cp@G-H8Lp8j$@#J-O-u zjLsqV{AUY}qv5p?DguKiFI+Q9*i9Gyte9>=51dsjRoRUHt80)!)Pkkf;4}|l@KV-x zB2<7^$MDJ9mAPtaOYA1pX&Qo_g#KFpp7?kCSGlb_@%n5b*zC?&iE}o(9DtX22|e#5 zXeoE^-}G(OnMD@Hc^arL6u8kL!b`K2YS_YshUSsZ=!uU&^G{>+B>(j~F$x_W)zgsh zI&q2bZRe=iWbrp4jVR*2b9*|Pi#1JB<~v1{#hiX~nL7p=yuaznh@Qf9;=q&M0T1M9 z_#xatURIjC(r;T^g*SOdF+89#QK z1n;6dEV1~rw}i4u!aHC);AE=Z$;6m8p0^ME>9^oXK54WA&zNaPHiJw^Z~V?(m&Lzk+Mun)b5xvmkZLCK=9W z31E~yq1O6KICX5Pt(K{OZH)n6e2+o$;X(i@t4GSEIZX}Ez*2jjCiEbUc95_R3qqYt zHssDhEXR(@>#&pqf|n=8Xyjba;HXvi->GQ&*PT-sMb%FFMct&y8K>Ro(40YZR~b=D?LN?>&}B_$<_=&-qI)qm#P-W@s+bxNb)l8tm_)q8uc zry+hq<*io#9v=@6B^myx_w*vA7M4sP#0FHoOp@4Pt&+CHqq`fCH)wr)LpcATv64G& z*(ekAhu}XV6wR~#n?1^ozssR5IxZsMG_QwE1`K)P0dva)p~E?Oy_J1RN`LilJ0=U2 z+DHBzmx(WFKBF6bhPdM020B_3U>Vw7rf}BVrP^n+GI&xYiXoE?5ZqHP!D_AFsFnm2 zO9aM3EMI@6|1SP~;Zi-&AX2K!k^AS;kDhN=S8TyqHnS(Kf)qB9-DB1+3!Uj_(sq{_ z#)i4?$nW0=mrUb%nhuysiuGMBbmH1&rSX_W%~a|a&Nx`0hNS$3 zLXE?4BZuR$+Drf%NZkO5BRUa;T-q7 zi0eEv;jB^If7!XZANcZ^Ky~TcEorN!9Parx7XzoIxW|C}1*_`8PcL&!`M?zeN@ zU~-;*lmHy}pF1MyupYIMVX)*qb_)H2LPg1=r)srrGK-ZTOH}?-+x2RtX;5bI z{{IRxh5+`CT#2HVczQMOKiTE@lP5`C%>{#~CQ`H1(#bgupd@OdbO03Rfj|vAFM}Nq zzrieSL;$}CWIu~CFsq?KQLPTSkxjiK0iFWfsp&|_pC_A>qhPMl#QQq4nT7i-&)$HJ z5hA-6DooB_K67z2$Zh?;dP@WR4ES*+?*x29x2Xgfmr34*VG*;c6FF@&C&o)+hJp^| z@EJld-;ll{OjZ6w`L;me(~oa-Y9UKK*IA9%rn>%RNzLQg$NT?17`@5oJx`?UPMT+x zs*Rd`P~P;RGd8Z1E-U-=eodmDe_k+ewP#FdpB}3EL=j>KdjP*ZSuJ0qE15!4+xSW{ za>(gGI2x&b7}2QQD}?k23a%jOi$Pd?-VsW&2>L-qYsTzJFv+EV^!Uizi-RFye3GHm z@pp>I2!?flo=M`x3eK#U$@q;vj!Xa?T&X*$(RrE*zibMvxy-}EwgNJ5?_Inku+hFXQTYW#g42b+XrM=Yq z1T&lv$}R&M;E6KvSzsl{fKc>Fhxk5p>^dwXaRgv-htlVakQq4BPWr0nm%ZsS2AOIZ zrknFmcJZ|r9qstCN+6p8&vCiMM*JOv!pL=W=_~@6masFz(W%u~bt>gr`jcFDuEbA6 zX1j9-p%i?f^!LbjrEhyi2s;*o+V9R5G_QwS zoCo_OMqz5vE%sw)3-@Qlj02uce{k#I%;od3b2=3?;{K64^i2(3bqUmmB87yreiz6l zHL#9)1t`y6!N+np4->ffOxI8|k09}Q$xEXG%9IV1qRL$?h*brzcO^C{y{*2*1!>%4 zdqfGnRKGZ3>m`7Bhfxe~Plqx^Qf*b$y>s!P1`HMRTj;t$a& zfv2O`QT@=ADt+6R_};?i!So8|M}jTp+!*-JeY%whA*Ez!xcHW5bfJ1cVU1XZP=Hv& zsw;mx6$JE7j2F%yE&?#6LYfCRi1p1BIwF1Gbc>`Pkn#v=BB4p@_}|ss;9<#5(I2_D zXX&T%u1^fXyS~8?R1Zrl?)$X{Cs`r8KQHnS^N4VCoOMgtnc^_)I@4NSd=8|Ycd@Al z7nX#C4Ha(+kvx?~lx|I`7Ip^YRD)sKU@IXzAkw}0>d<)w2QLp_(x}b+lp*pmApA05 zo2`}d1&mX`_M65vnRg~1kHD4p>uicVJrLxp=rtpi@y=S+8W4Q83vko;+_)KMNI3@h z4JZ?sR>AlrpyCOj`ejR8j_=bV&WVFAA=_J^B82d@6U*MgI+v8I5IseyMxDTik`7UU zI=On9#GBp#TsI1h93eHI zODdazAp_nZ0AxaNO%ueC9|~iL5#hEd%15%cDbN5M@w#T~stlIgVp1!b zGz?w9m)JfQl8uI1!mqXN1N*mP<7(=7ur?hh-`TdVu7R#xsnbDV)OZvwHgDc#e$tfZ zTn12#=&FPuR3`l_$vi{%6ecPq_G5p9ZO0ydc`1};Q}4cJd8QYHe1Dt~i_uFs2sVcv zPwv-6DboAddl87_C1Bt{<)2B0*I_?*P_NdxC@5goZlc=^!Yl0>a-^g(>Yy zjQhlRoe3KQxRvWPI*wfC2ejb3#;FV^9x~PPAa88G)pz&6U2>|_n!OdP=Bf~)QW8VK zBAa&samKDo{W}-vz9+#@n@sH$)s2e9qi{$8IU(bGT`Chy4M=K7VP?$mpDO!C;+aC( zpK*_kdsnuW+!>?J^G|$+B3qg-ZEO98(Mdsw(wBaR(JQZ0V`Xj&T?@G53>~vq z5*qWrp&|POxZJX)n-zSIalb2ehY^H+*pwtE*uPT0=-uh@rxc~7dNrIogJ+o2>$JjT z(C>6_8^<4|#V}+2GtBjYCRX^0Y-YkRma?6aCXi)!j*!UU*Hyz>I<_Ll?YB+>ThnU& zHqTxB;~rlTkk;8n`X!$rCAj=E4uDa;1y;T4Q>UE0W;o_h=mpl%v@{?Bxc$5va);e* zq2C|j11vJ_aA2YPBKY^w$adt38t$Z%GdIF*I;&?`a*`i_w~0N<&-~ovFF^TwYn}eT zF2;mx!XxZZHE%U262{vUs!6Cm!vA&Ugwwl`7 z`qh)~S*2++PTC9E738Z_-nSC`mt#`5JzV3v-3)!W>Fh z$H9-47xKMb!{<4>{iW~kGI(TmCPNCg6?3l(@($B~N1@nj>3^bj%XcwyuZUWEJ#PK0 zT>SQv{Mvm({=^-SrK&^p*RMy+=|)Q%B(dBc>^YvMWNsCmgsA~Aln3F7w!Y{6oG(z^(idXWg|^0aZiwG^3;JGUoy^;l201Pb7en-p8xe zM~T{Y#UT&Qp|b`RKuvV%dtc6>KdD28R7cDws5g|h+{6X=?k_4W&~VD$#ERRccYN3E z)G0aE{XM>(rFPslo9)dW_TZC_-~Xd^C&InE-+{7j*`;3jgaxne%sAWsWZgPb()$%QiCp-KDwL8MtclDo1<*N!e$y2i4roqXbbgvA$p#i{4dZBOgnh-|()89@=N}U#o_?hc$;4;&UM% zVgy;59$b<-#vXk^OG49F%TIn-F$Dl%t_D4UoN9f|8(OHX z>Oo}y4GT~Zlj3#MhCGKtfyCGKp-i7DZJ}7The)hS3pW$5ur*AUloGG z3Gp&xr(0rq`S(fn8l}KV+);cAGHL@xUF<_r2x7@Ql8`T|Wug3j?vkGO6&=gVnBxAN z$0d6c5^~Cgyb44<-bj@3CYOkJwjP!85`rnpbkrM<8BFj2z`|I{d+*uY;3T~|44^D7 zI{QpO5zL|6XL1ltS;{CjMXpOmOC&%uEMe=N|39RAM>z+|B=95n$D#JCTvGR8W^A&m=UG4xju zrv?ipHS>a-;_0{PSd|EyoVcap^u0FpS^}1@ zBFpatpA!N+gHd^TUhWamnMk7nv0`l?8zcf6=m$>ilW>m$qCHX{hq-*0fwIv^lYV=g zPf4_uC2(D4PJW9_7N^!gTpJiKDdlOCxgmRr{p)#`gv$US%Vzon{~)3+clXfqIGilA ztWB@(z8}#9ZkKz{&K;5-g8LhEPPJf-^{q-Mu7}YY7}JJ^k)0LCiV?2Gp@L=H7knlf zKo~JkU~c6~QFi&s=@;{oPDK(&J~97urLcS1@iiCmQr_*3I2%&cyh!N5tT7>WmL?6G zFHs)lp@HI>pKog+GrLf`2eqDKi-F)A zjYK?xI*+De-H14;3@12*gU^{Vf#*|TEzCGvM2U|csd{UvHnw9k76uC*Q zrPnO0uFs9-_BP1dvdHy(S6m6LifDcP9_2@?Hcrr452i%Lu7!(c>F*Fvqsyj@?sO;P z+*#@ZJI5tIFx{w^ywWnqnPk0z0_#e>H-&Rmvwly@zV1S1K5Kg`MvWZ{x9_buE?^QP zf3Z3lV2BwB1jJr@&)C`fKK2c(6XlXfU#{5t@=|oo_yY}kqR*C$3QVN@6%*?h%adY3 zyb7b&Wnq@_mqt@jT=*I4J{Lm_kaa2tS?`;)J#~KES#Kl-P5i3pGe`@4mVHo+1?k#L!o}Y zuhEgEQdd(cCv`@X4q0z$=iULedVzw6Ub2ih!Jh$Lok)HqGs(&bsX;Dxn&E;wITfNn zmpWKu_uFYqNkp6YHvg91vJ12HG&$p-$LRodary5gPA;#^9FOJonF*t(Un&d13$y>$ zRbIhmF2t|-W~I8Z)O*=G&r^b>7XLS$;G*El{)rW4dpQ(rJa=6=qif#H>v>U0hV!G( zuG5)!>94QUUj<9sQw8;$?Yk_aCWQKrC+Q(q6Tws=hprFr05G$A$0u$SBa&zH(CkS( z_~Gf_Gt`%n`*a?HA9C*c4{xa_NaEx+)$BHPc4o=yR+{@wn)lLG;x=>Y`MKx63-u?# z;43?7*d0a4-`p-ZIqfLw8kOuyu*=fC0i0EJCF`RhHj!**)X0*XSb%9q^Xx`+zH$@W zGqXAUZlOMrIMa89)(_=Wd~(H|#!qN$+L%TRz9V-ka4QkVmHfy$urlsuD#1HWs{wq0 z7oAdn8)5aiA?S+J(%6l?m|nb05E`2O{1lo{eJ^oXG~`(0V*)}UaevA_pIvmk z_HGyGy!}K&NQ7@GRhHF%%Za+fe#xmRzkD&RH3I&TI#VesBNj>Q=6zcyl$FzErtF)L zkliR23nFs*@{1^wL2b~^HGBQ7*)4zYHT4ttn(ndj^A6g2 zhM{yj5COgPfZ#hy2&x$s;K^fl9PQdrC1T=No#anX0(Xz}&mnOO^7hV7yaM>WnmOg< zwhHRqG}NL1g+@b!7pI_5;2X_eNOSXN0A%+t^qlP007F8+97m187tt8%0h)|qu`9rf zcW@f5ekeLf^tF4X_fBufjeX)g3_`$z$2+lgmkhv87tabx2hj1>2ve%5K||}!Sn2>% zE=fm^3ozV+J58!hUVv`h>Iq(%@Go4j9Iw*!fjZx(_Z~4N0<_|K*ysP1k zNiTiTEF9>J13I6D%t`)R^iLWfWv@J}g9}Vdzw=)c8{S;K1g-A3O$Y{|BdG4`F|DwjelJNo(GTxcCQi+x>IUsF-0 z`_jKEvJ^_B)CXOiqZ=;xVNlLNy_R$;Tk>dG2+G{S$AEW`LurXl+RyZXF`C#f^W06% z*=8U7-IfQpa9z=mHNFOE*dA%W0LpwMY3w5gn3ka3+m&|H>c4|LAy^@|wU4@84_C%F z-7+^p|KPPx;yn*9Ly(90TaUE&1 z2tYi~SjDYJPN&I!#{R@*BLh|QeTc}T(x@VoN1Nutw!o0sgU`)TXY~3dL-isj!)z=# zr<{-_I{&QqeZu)O$izuWxxj7KU>uD&47nJvZ|P_TD=#5DpOVAZ!me3}R`i{KPiMBk zXU~H&^gd5e7}ah)@FK&RmR*HHc@k!V*4WqIVJS?jZY?L?KECt^#{?s^5?%3J7Ld^7naW|2+DDKsYDRm;xw$Vq{a*NqQAP|nBJ z>C9MA)@*5XH5XDh*d0g@Ku@VRUnA0*34g*oC{#7a9H#VL2HVjhL*L77oHAL0(jwUl z(f%BC%!r-_YKX3JBA}t^3^fp^r8IV`B68uTP_K7Na|Q~@Q5cOW{i5?#eUbI6>Sc{{ zOK6l)XmmK!jTnHqgg0}WrcX@c%H0{b6#Q2!m{1F_-;hB9h)yaXLWzL;@974)OURBe zH}`nHf~Z8N?0VSYVVKryJovPhguMpSgA8{;2^-C3fxikGIH{Kn+F++k&a(Xo{P6_fY4_iqQ86%+5pF zon`zyz+EAiDnY--QyT(^lA4WL9Nvn*up8A2RkjW;FEgq$!Xs?X zqadnhXdi#p-qse$&r2tH132FZ1-qmxNr20e-qj7^GNJlpoQaPgfHL~lb z!&33{<|%tOX?~1*)G~}U7^HF3Gbk*o1kw9^zfV~7J$;0%4l;#_4~ja3(EzOhGxPgY z6|8hp9V*`&vZ>^&JH}%?E19S&#Ky5m0|r^R3{org;XT2kvv}TA{x6P{!k&S6yie$@ zo=)$c*RiXh-$uN0vcs}NF>+u6lHqS$O|Y--9X}o#ulCRH8&-noXvnC*!w59C=5wW- z?77-cfz8yNK6K-a<#64eUOGROeZU8t+}USC*~ep^=Tfhb#{MO86T`UaA~*_o5{7q; z87KEtM(S`TX$@dG2G&JjP@^;cGhCZNYF`(N&gHP<3ISsQw8^)5xP+gAX|e_%*0J9h z&t8>^j<#I0Zk@;=hc{G*>GD{FcGwp7RpOmq*3?0xj9a-+Bk6B7|E-)nP$PXhCG`!W zLN&g>>w(GxB<(}sf5m3!Fz#Ij1IhCWcEm%I{JkT|4>%`O8Qg5yrf?^w;1|YV8{j`9Wu@oJ-vTZ`R-b7EO-h?edU*elt71 z0AP4wVq3_vird6#{oAS8Jq}ua;LS&dy9vOk8*8*fN5`H9dH>EfeuY?4I}cEyLeknC zsQ$ckZLoEI(JVkT)Y;-f6VTE=OnCa48GcZnzHPi*syC~tkoR=?O=WDR&sp8Yx)hP4 zx7{dp!~Fg-UjA;$>U?07{v5VTdzBP85tl*{v@&{1(&Lxg+4q!@+Fj)y^3CJ1!X+es zcSA_(elu-WNROxeBkRqcLE^T7>DycS$G{D^5ZR`+JqV-`<$sO0Zqrl%>~=XLN2LMW(iR) zD&LLcQ*EE|LGTv>dMhgm+*(~xX$msPJ4yjVea}N*D6+q=uzHzIh>c=-XkmvBR<7H0 zD9rppU`Z74EU;lBNW2^5x8||mKO348D7@wNaZvpyss`0$DHtU4xETUmz59Bywh~?$ zeoHy@d%LXUTr}fr_~5&dT~P$u!t+<;1_WvFzA+NoM4u`uTPsd{!@bbl*p`ME)s=8> zu`GCBQvj=3x08=!_4l6>@%mxmUvg4NYE-u zfO1KHm{zNez?tN}0K_L7!$Q@wkY9vo8@6z*BHHzQQyjwvn3~(`L1@L4u*xVagYShe zck=2&BN3Hk2Y*WYT6W0@9qL}NsMQ&{)=gu0XV&N1f%HOoQvhrjZ>g7xv{k2x)VLBoNA5F3YOW;WPZipsAy4u-u_JV{h24o2lzDM`m}npZT4t(x_rGN zZ^DAx*M$x7X#c1QjeFHo)PDJmQnJdeIs5N@$m!F@!}aQQ(7?CMaAS?^Aa2=iEm-&j z%X|($uhkl(;pTK`*#QHNrkn{oFJ+@(UrJjW4Ye#yo`0XhS71EJ-bnid8&z^Nt*G;w zD+c7C5_Chz7nQ$;{5Q5`(fo>2l&GE)@>VhL-76*D=#@2Or$t@|0|5&}M@cd?0Tep| z{3LK9^joP)R97g^`&3COy9Yk1pGhjqo-`b1+>@mPVVZfqUS(fDj9+G#;lCRtgWOcqI? zQN~RLD~ZV?07lSSZ_O%(n*cbKpCbzDM~C7w=$t%WR+L$;@%tF3OSL@*7>z}2Xq$(v^a`GjvYKe}N7R6zVuoHzes!@rC*@Rs=3VQ9L!NBt}g^M^Mr zZ~oqXz7*m|Uust>DkIgmvxRPD5x4lS(UwUgJ`Mb%;Rvx}Xug^w0eKdNu

eJzLtBDQG$^Y8dAft#3JNYYg0q$037Lzg9M1aZGJMZR}GSqXaY+G z(PrgZj3F6u-D^}}r4ZBc^LUwm5355O*lF#VYGa<2Tv`(0-T^n0(~WyXvB|}A1VO5k zuyzrQ#)&VaCt*yYE&_>b{d4a~ilT_-0O{Pqw6YRj4N0XfVolUcoFHfQlcRxuTvy5% zSQ_49m={U1C%Ab&=Io6&)Qp<{{?Z5G#DOdMZMuIC)JbG;ePB1shdHnNg~+0R0GiA3 zj#-DwFt6M)92v#HnqOk0>#|pH3WKcgYVF>LK)K!S6GTpDYJRCJWX;7EW;6NllN zkaNprW@B$WHG$q0e85bP;8U-t=3hPg6vjjIk8YP1DPz$2Y^{l^@}8 zvkw{1r1vo{V}Bk(OhR&Tw@|VLPKtfbU;0O-C%(tV-lyxo`j0=&f;YUI_5Stq{JXx% zecG3KKsWZ~40-mG$SH`kwjh4f@ASGm@cQ)naV6v~)5wz(C3Dnv&;VycVt;-$P|U5C zjGOtL_4z;XBbFGSL-amLJjCc6TUbN?dvFL?MukQ+wVoz%mfIdV?aN|iWa}NAGPNm z+@5(3(g6=QeHXbbH8V50y#`Nz{*D_Y;?)S$c_zqlXL;@5*(9!!Aerzt976E-(Cq#h zophTMzO1W$HCgAf5GNT|v1BG_GTXx|TnmzY>|ae>rHg}qutME?`;8kbOe5K2(B zo*^Xb;5|P1XPE+e*h9dlCB;F!#?ayBQyyeSm{$T$UOquJZ%9B4#YPJR0pcBw_wyfu zK2g~_x$x!GukDP;W;|;O!&;eA>j8b-^&E?Er&;ScrD+3X5XH;kk(qOs-{U52SxrQ^ zQzoqASe7wgs7_g%GpPgV$DoV$XG6C?Nrk|I5Fu_OKB%XZLN-%hKjMO0j5W#f@goN1 zd3g{y@j!6(dUagwo1eLxZG8bjXxz_ru)Iw*(&s4|b=^CRHvb{SDs5Pq)2ujy=?G;_Xj@vsKiAGj@=^4Z+GM93IdC5QCV&U`{*XMXP6FnzMl^o) zbhA)B4=d*Gp})bs%Un^9GnsgLy_D1`=5{u9!C) zS=5Btn=&+abZ-byn@2ypiUj5=(ZTR_4BrQT>Yzc#k7s5G|Dz+t*V*{K5Sv|h>>Stj z7>XcD-Z_k>QiQ}8hGN?BX=HhbCD&@j=ljwqrSOZ+1x4HUDpix6TjotX(^h~F&~&8E z!I+rv7{0}C6^)?VKR7iw!@f+eU-^1Z`a>tMP~lF5Wqeuv=og@K3|1A=qrb-WAF-XX z4Kt*fF9SW5RIAt~-c3Uj>NLI0403xP0!gkGup#NPn$X51o1)?#QW=C6^_a$nU*Mg; z?qMNK0aDIfILug2C9XvZZy?78@x#zW>}-67WL{PZn5PwxSsRmh@m=lP7=8??m*t4! zn~0-q<8Z>-jZ?$X(EfTmRSC>Ncbj)P3I~-<+m9A^CJoERgNf}TdR{Q=MAjM}N)*b@ zd{do2y2xPiURKyy=?LZu{)!!Sc~(vwOU zr4&|n*S#qTl`!S6k{N7Fl33CYpUj0qVTgm^mCf~S`K;XRb?a{U$fr=ZX#PAmIm2qr z=rAqpH27U)juD@+VnV13V0PrOc~}`M5w^POZL3XWCDfW)+sEXhAxwciDsiz~jrjzCgletwVpye)d!Ru_~Jhr#kiFC{<2q%18z5Ar3}sE)-@fuW|h zhJnA3;T`Luqm?mnBVR)3cLrVBJX~DPVB(f$wb8JCau)3%i-tu))rfjh+#etK4?-n@ zf2u6w&zW)t!AD~cr)QsQCHi>h=vt-$&7G(d`0#Aq1}w7k4J!d_F{)o&|4!b^E~(jg z8GZ)K6^$Ddoe#iLBsA)ncV__|1V)^E@|P|=u8+NRKpCzgmtErQGZ)-n|BB)$vCVgx zmFVT~$XO5@xMne|=uVQQ*~mml%YqiEBTfO%GF8)De;=FGjQm17e13k28r{wqi;*7mG-g7sxLU2Anq7^ ztyMPiDn!LL_s{+HF_13egQ1Y%eabyES{}6=k_#rOjLB~nCQM}yA-4NeI(KAs-fG@J z9zpt)@b`l-FJLL;6wrHnxjJy^vU~3e{UedbIe*IsXnXBO7}#QK`w0w1NG7FQ#y_y! zvjIy46s35jiuj`%j2%sXItA#99f}?+*-nOej$S%Ed6m>aT z(ME5Zi&|K$1Vd#cBz|adv~*@;n0Gjnb_^uiBJefeo%*v4Ci!H_P1y4utQ(2aB-V*< zytL_@2GOSugcBIIxy7rG@f6M%f3ee81u053#^3!$mS)LD*8m9?#$jJ{jD z9JTIj8I|Vb5UC6_begR;L@5~}LF$!f^K}1GIG}>wrbzz@G42V^TLHEGIKkb>Q5>mS z38r9i+1Z;pp`bsTkFU0|5`wmaFgv$rbf3b!WlEw>%0Ee^S=~)I{7CYyeSe43b|2Q z4OspqnP$hnYW)4j|7MYje+RXX?8i&(;hnxG8?7PtzNe0;ng|ov&Hr{jy{XUF?hJCY za%ML$eV0%#l@`);eVla@jFHHDmUku3v%iTU-HiTq#9>zA&yqYbY{07xT^R>>Bt&@d^hN!=$4WM+fjVWO%%Fl*>dm^0eQDg{Hkff0dAZxtO9 zyDHES2p(NU6i3u@TAV&!-}9Gm9%XaW&mvxP@7dNpPf`5)k^!>~=Pvtu`<%F ze+B_*jlFgJxU2JLT#RI2FEEHJ8Sb%jCZ~|oGM6p?TNp#S?Fo~9=_#uIAo6_T#Bhh| zTr!`4E+(ZD)9(9(P1rgXCX3RA)q&LtggPt7#m&bjHu{?0QicbzHU)4RSZZx{T8qgT zbtoedBtLa=`JIzm<0Y)*TtuK}mX$D41rm!E7~3u-g$H!QgJry(7JMw?zrhzKuwB|% zn;znMqC?&95n?shflk+`{(B9@u-rf;tH)~af;ME9rl>gW|GWTEV^p0XUR|!=Lq#hD z@W9CfP)%nO7UUbDaE`PN^KxbIs9Q;9OtUN|6Tj?g!fq7Ff-IYlnQou#IBt$Q9$1)~ z#?qxcHXArPH&0m{-y=;qhVk-Hy9zFl0 z54v8f#b}PpDLek2P~E&NE?sOmb2>VbaG~b~wz_~zv0EQ?>-gy)PcYFKM#xRI4E*s? zzLOD#3MVs{%SLK6TjpG|SmArT;P!cIx5+vU9GXE;rDly{&Luk>V&J#zYSElp!LSnV zhBNF_F@B=kns*ynbQ{S{%g&rzBD?Oh`L?7#y6x|zg!^Le_Gl?<{6;>b|nU4WlK#VWAOpBjVel#Y*ChQyKx`{ zfP}Aq!>cmv_X6DtL0YP2ZVM*mN(Jw*34H2>+rLVs8B$uYXzN#v0MU$&~O2oNXGsjUyOGeJYOdKGC}qzZrYYL5(W?hc7t ztX08Q#i^;io^u*M{TlL@$AU(w>wLWR?=RP268ewVLDM#TV%IJk=uoNzkvJ=1ow!-;Xjg1r9Wa~% zM)eq!dx-pNgpJ+F)tbwg0i2z$sDa4Nom>SAH|WZ;lgm0#zi-4@z|EU)k&bm3+dusE zOx^-{C80VMU)La|O88>jx|X>S%r*XHMel2?!lKxsSImBGU8f>uD2FbYPD4=Q0FrwH zEfRDs+oE)XKxaZPjaQHYaP#3d=pm`q6!Qj>*!FOfNOa?*nJ=8Hv7f!}>2}F{B;=UamNpVO6fm0eG_xdzn-;#}kJf_79=A!wWM47kV0TQsqeW;h!Yw@5; zC0xjG{1aC^TXMh#%_@!HJ0JfCR2H1NAZ-6QPz^EJ4T-QxLmTUPPN1j#*VAc_D|UCG zZ6LGN&?l(Yg32EpGlp-z+fhfQi($7*fr@a9c~0E}3F8;fXzecyxh7M-X}*#CpcIN5 zdr?9Rv;@xDzajc|RP54hekHC)lH(me$&X}O4! zQlZWHXU~M6luYhaCj!Ua{WrGN{pN6R?_X+}?)e9wmx8`40c=c4S=-*?ST<(%f} zb4%1rNWZ!L*mIUabW=nz&B0omyM2e+bj+lzKA?EDovSzSLl?Mb>OBG7n%QGzNWy>H z?8`%!pYjcGfR4W-u zQ|JqJ1vFSt@~66qhc10pI$p!swdX&4kz6hnHE)Pbg#7xen=BGo1t)Utxiv)DXq{T` zcSw>ba`rzeh%<*GhK8pPvPIq#yGskuxUCQ4Diw%zY)5?^cYN9J&|U9tz^=0>&$%sZ z(>c6orNa><*RRL)dXUe_s9oB~1%Jl|7=r_N5S zN4Qvpxg4X#%PD{p)2=v~?0$6SPktqPi-A%#Y4Wne2TOj^n126?mw`3f)pWnaM-EN$ zA%G?ZEx=bFtlea_8`yj&3aOv(B`R2gQZH9u4)5pjh+Kh>Dgs_TS#cH$+pblM$b{2B z=C`_`luJ%e=$kRT4{Nb|zB+zCIK1mza;j6b#7hoXv@!Kd3r&XH-ClYWaf$GfU?v-V z-M(@UG)<(W9Yc`7)lp#qwv~WGNZqn?eQrrhRWo19x6R*cR7g;9!H6y5V`X34OhOz~^unhC~KlG0!n<8n)1< zXyMPE^{Eq#Gg9MN9xSZo;XMVjf@0$Hf3NdMm+D#M-bsWS17L7-Y1~4IE?U6a#7oEp zkvX&-5Y3Qv26O2h3hotv((-|QlmS%60r|lDWt81NwfQ~Xo;l9ypRb-&z(|3`7Ba<{ zZbYBX0*o8kp*acTY~gZ0gh2adn`QIyYw*c6KbsTT*i|sUH9o%NBgqS^2)lF`n4f3U zsTA&7>Gekz?^r6+1wU9UVC-0QnzY^GoS)%o}p;#-(~cc|x`_dD); zq8RuX-HipS6E~M1a}E*g%wVEmCe&*$%-u;^#RB^AU4|?*>+-D6Wfi1kND`B!Z-PaJ zs%k61Xk>|@d#53JzLK3u3e^&&`(`m*Fqm$xhkht5pY(od zVB`X8aFs4Ay5+>~B_)^v#0gU7w?Z4`N3CSYu=!APYSXIWYYa4guhH$2x^TiEPBjGe zze$3g1dC>P`2DqWj+H!Cwj%B&0}hsXRmFZ0U62BLb(kUza&^S5pX>+Xg%vRYVbKR1 zOJ4u_?<8)HX-P+NIiKNZwDE%`v~{oB-Oxx~92f5j>_ObkmZt1YDh{445=I&CtJM4;f7CknRJGeL!(L9sq=@DULvI1$M3kas| z(X`AtZU8ZF-Na(t-B1lW#!0LbGEyH?)-W8-rv9096Sd(nl)K-&!vPoBW}pc zo?hhKW&!oGgmU98$%pv%(*kIbD`sd@Yn#KhS-b~A#7RqXTdW9@SkhVfSGI(W(5c!f zwqd}jd?i$uCvP_u72)KgULx1xW>h0Dp3VN})bno2th(;U&E?T7+3u@Y(hL=z^BX!* zPJ^Ml9LK3woGRTVQmTUI(uI9=cATq)xHOwRq#OdRVk&(RC1u5L2{Ikr=K8wvGoz`a z%nqkpzqeYkh)Jfj+6F%!SGah*xp-N2`gqPhU zR|a5eMl5#R`aF>Ljzs>qZTMo&$i&j|f0Ufi%lqC$FtjRR|3C3X{JvFk^QJFy&$k^Y zm{2B?a68-X{+}qreRjxePMAYN-m46d6OK(sBF{8=+B4D8(h@8``1*XmK5C)-Dd5|U zxj=dt;)mdR(E11XF@!0q1puIzM7((0(jA6&`(##!jgc+(I>voo=09qjqa^*nLluJ) zjnqmIXYY0m^%$a(X7sdA^r0Hq@Dc-H*qQ+|x%K>bq~wk3*31TyrH65 zej1v?okYY#^N0C+x8`h-YtsP@s?C@7@2rqb>H6rn6 ztuD`_B6xZ7acQb`qg5Gu8(xDc3;NE3C5vaTwxKTXw1`fIi9?W5TLXTpW=Nfe6}o z6F?(vkbeoCysq9|phA{X-gE(qzO(MokLHQi#VYjgKVntk(^qbtETm}TdPpo%E!p6E zw16YPR>8O?T~H-42E2@$+a`wA`=;mjx=7vwi4eXve6B=@dk0?un{>w5i*=^2tCtF$ zR8{D68oI`34nRQpkMtmLPDMlS=DOaMUYb&qu^1=n;7X}5MJ)i4h!JE#(d4m@i*UC) z-_22*6+z_eZ1iYgTRt)8(jJ*uJ$GX3GH(hr0PR9dL(IkoRWE=xM?+oF}SFmMZBUtWG zUHKxpk47UbMJUjxhqM!MEa|OUEL}8)-XNMF=@5 zX#QMs!%d@E%VP4*Fy)v7-2H9EJsd_=-SSI|au~wI-D>dn4SkK%KOu<0E^8NPv3!o( zOTnKhBMKXM!?TrUPUTbooaKb77mlg+6V#@|^w`Hq!%iF(m^myZ1(#W3&|o;E^5qq9 z#|F$=P6LV<%_$B`c=sy+!!(&G?uep=^(&=rcZ|XF(F>nERwa`YG<7FlEFh1H0Q5mRzl6bs# zF~IT4y}yWU_3hU;-d4kMt8IqC(0pk}5pz-wzdNubObaVVLM5GzE!mQNtaSPzVGQ_3 zhNZ78IvXI%xc5)x@hm#tnyk^3Hjdtsusp5{XU{PQ4x~Gh_CO@1M+2O#VYEjPkj=LtiIc;e?6!Q_60$2`5uEQx@00E5LaT_wntVPFK z<#PT;pz1TK01bRQ|2`6rdzu-`**$1nZxhd{U)fXN%z~KhXoKP9r;u;0vF|F$Ei7va z8V=jjJMTbEn}L`2U5E{d0W43?JlEX%uNsleYQ(2rQ(u+Isc@H;mIN<= zBe$f~``vv_M@^2x*Bl@5Fzwq4V_9IFZA#$m;a*J5E+&u;>qt~ zv2G}6*?#f3n5XV^HmKHhhTC9#Kb(GdD#v&Aq2?-A{k(y1r)<)1=y0bn_c0swX#^_1 ztUWxx94E>Fc$q{xi23;_pNegu29~&4wu+@%2>wS} zXvITL|NDY2pOyU)msds>cE4^l@YygEc{A_uig@ahDVd2ee0{H23s3@$ZoxxrRpw~R zV|bVfS5Gg2LnTQVlV;B3u)WwN;vqm8e&mGkYMC^ow;GmW+a6J$fS(v;_v?mN)_H}d ztEcA|xdbx8XS_bjjh;oYaS`C3rWk*^M!zSf$;ie9ZT=`!z7?$q8jKBj7(w`u-B75 zgaq%M?*+u%D@%Xfls_l?lsh|$?_Ee%k2AFfdBDenf+uHQ!e>pwS>kfAUy&lPKFt#@ zBub94y)xaU;}- zNC5@f%-dBKoWw@LGa&TD!*SWx4z|_8#kd2g+~P|k8+f+4!`xZyjIk=QC#?T%sBGxx zsAQ92Bjh{MBA?UX9^cjP8FJvnfsB(q77Fu+7Z))`jw4a zbNq_LXDkTFjZX)6q@L$Bx{03nv>miID$%e2Oc@)x5Jedbxs3e;&96`by`};C^*Le- z!xUn9oy$TEHmqGJP&>ph2RoDzAowA*CRKG}E9uqWk<>XqUjJH$fn+jywIiM|;(i4k z4Tpd@t*4R;X1o_``WB}PaFWez{S4$qfzpEn_!bXd`Q=eM z97KFxhfIH-cvg#2`MOM1>d<8pC39_8S6M6pPe+D7Gl2E~Q2#PD+*)l@f^Ro3u>@)Nj&FEt6x!Ifh}d8X||G)L$mZ&osPMtw#{IZj`dw< z(9Ns%(!Y?JNqFxAiq|qB;3Z;YcKXk%BHKd%ZBFD(n0QEGZR@wD&N{d?At?EVPA1aR ze%TornU)6Mh3*p@K)Wb?AxV^k_4V7Lk69K~;JL0kUVC`& zaO2V+GwL~L;qC*R`8`w%_lThKK{Rhy*Z82r3e!RKj>nHsoih4TJ1G+WnYXZ!sj;M$ z>odpQD1wM7q$Uv)G#e3il zUIyf0hbs)8@P^INMMSMDr9o%U_#Z|7jHBU``A(qquF&2{J9yX#_sE zyQU~O+9*hF+Jv=aG0#jTyi!|LnpE)T#KH$Egm!8jAY}u_U@5Ki;*#TP`%xH7)&|zI zlVx6%J6}EqDx@Z%yWv3zR(ZtyDV^Y&i?3H4!Of4aw$UV1QU2HR}e3}Cpv{6Vi z?mqVRN$(kLzveKH&PD`~`_G-`J{AlMr!Bc{77TWdK9d-4ks{3zPscF?{j8qQ&%P>jJQBTb!|6OA)SJg61b8kgPwm~y_x1w`r_YYpymJMTS>lgB zT#!Z*&eoN{&>B#Ehj#8h<+7nL5vjy}$Vjc$`bMZo+@8?2vz=*E`RwXgKp!w0r1&cI z*)2uQ`CkKXa;`msj_98^54&xNb6?-| z_}go=osJe3-rY_Dlv(p`qk& zDX;IU{{AI_ADz7rR}N7ib;*#ehAoR`%U}3R^-A^&cFk+HG5g1_4!qFJs8742q*fn) zhIzmsVs2Z*h`_nJZx>&G?Q6xKLTi_l)<}c9=K5f-#9M_lF&;|$JH}n$O*oCrI|Vc) zbfl_@G^`?!)`vOQz3?+kXAKlA$zScLSFpc$8_`7fs{kFm8TW*qU9&>~Pogi6I`PtC zBbU{ux3^?uY`pXIw3^OM4vMg{hK31bQg;$zNaFs-EF2$~6(@UoK}yX@{{9$r`|oZ>{Wkq|v221~#nEYHEJ^4bt%7ovpKp-W=xUsG;_ z2?bb5&UZN=By!*kO)++fHeGX!HEwjti^%+Y6TT~=1v>U408=l*=l+aLx1%X&dDh=n zmce11%${Mgo{fW0qF~0{ILIq+#I7qZ-B<3s9naf|vH8(5m?_`0o>M@kFNUW7)c?~0 z%=r!foBW-iQ_q)m%C+&Bs@vs`3udDl(s$-y{C=MmyB}K^1l0f6=rRK96qH_h+rO2Z z#i`l+y{9lc&uKHj#Cf9)fK42>&jU3i31yDtDtvJbbOmw{j{92<$bv$0@^a01cY6^bn4|v#DA#0b{Ny5wj z2C=%4DoRirK;i|iPiu*iX3>ZeiT7f%jw(taWeWf}1iE%Uc1FA$Wu+gt4EHEffHY-s zZJVs!%9y9(EO)COY*h(UW#8biAFRqi6E8X&&&57{(+LVPslRcENq|s~fOvSGpFW5# zA>tk7<|KM80jGRZ)2gIAA|cYG4mNFd@EjQ5^J$|+`2ZhBDeNQ)kPYLAAhlp4OuHIHQNY} zGxue?t6K&Wg6XPid{CjXRQDhC=sAYG_j8&Ar?*5u>3>3ZP|y`ol&UBA4;sz27nkc# zJ#*4ee41FlR)AFtd1riaae3~h_7aWr&Do1ViX5MK)U=l15crXA1B%W9W62M5FZjq- z!~n~LA9&8lKIcn>J>bzFhQyQBb@K_In8K0D}>SI{M~m7|6gytrY)@c^U5M!q@2_Xnyzw z)KAzyF837x7g{%p#|Gvi04Q1vvsVX+BkhG~p{g_JeF`|?&ui!u@i-p7ES?lawBCoJ#tHt? zEm?uJk$&2GdV04OC6S8cH`+SZ;gRAK6O;X7&)FFq3!y7&vz+8Bz;GXeL@mu$j#LLV zYfT9oUeTEWGKq7SvQQu0?((k`n4wmU$%tARs9%lSGpLhxh9pkxU#U1jI-Z|NgcHpg zdUI=$zn-tH|2*|nU%k?w?fI^AIO_PVq5R|%sBP)Hrei=7jKU;e z%=dgokL8oq0lK(;+#HaT!$xH!FkTDPgZ7QD{LtNge-VGfo2%SCi5+D7!Xc{Huup$a z@Ojh0o93dea#^a#ZN<`rg1}vt&&%hZXTw(5uimKonb8OJ%qnEZ``fCjE#I=o)OEcX zRvxE@vdOyV8|0w;G~)J7FUL6=VkBWmgkX_Jrbl-eRj3Y&}?rz?5Q_Z97H;kM(8 zHv}NKS5=&w+f0`8Vku_+XIfWuwfIv3-GL6d_#8QxVZXvYQtDUy>7RG{JIkTV-j3^r zfu9^NtA&)-eF>$OCSC4TdjkhHJ?hj-{(dpp0-YwT=9-Hk4|AKa&)0Fc$12e*-*)vI z`)J(VZBF@Ng*2@!tt?*66DinoG1|3jsp>m7kF1~(ru+!s6SGN7udroixVxQZXXC7v zB}o7$|Mq?jFOIs1)D3lKgiagP*o>wkdx43MV@9QvLJu{c0q0`{i%` ztr`1ai&Nuuf9HnH8F^pfqbJqIOMf_*;oNRDM>T>pxdl&~^<0C^{oH#-HH_9J=K*TV zC)b6Jpn|2q$Jw#nyoX#zzwNt()l+W{a<$T@q_TZ2rppSwpKeV5t*cL|@ip7ps{8-D z!cwufpRm~yd?_CWo;EviFNI_183j`Es*N^`c>lV0^nmf4t64s87x?1)fw_gga?@Ms z;e0B1g%CIO5EPM`D*QN${3ePB{_zWv0lp0x$~mb_Tu9d=l>j(<#dH*48RQRyX${2T zq8ZmB8KY<$04kPdWYptKmfrpP^}SW#G}hN(nroKGdz};OoEj;T$eGzmDL&#&JDhuC zkhV=L>LeD1Inso#Fo9O|J%4`UN>v76Se#nGEh&K%UdMxO4(b78+ek;*)@QJ~~GNdMjwb5GI2)-Z%t~l@o z1BW!n#!hSjfi}4*7kObGH|b7ccy_G6vMNljJg4&D^sTQr_&ybtC=@+tSt&;RiW zJv5%$fYd2MYYV0*^&E|^IsQZ5Ct{C3#|rtX{{pENwL2FO2cq8-tyLQY&uvV>B22B7 z303&^5=3IcTHV_zYl{kd+66l_lm4DI@DBYFba^)z^5OtN6i(&m^v*6=ElK~adLRHm zIky0Bg)ES%4l9IJ7s=Vktwr_t^sIQr^zQ`kdZzK#2K2t*o(hS60T zSPxlu_6b-{%)yp5(zb`ccsjcwSXZnm&Y}F2C}pxg%12pWkYpFD;Yc;10~tslQGJV-zv+fx+Wzlgu| ziw~?=hrI?ya^b_(ahMV435W~#|8p6bJZFXdp&UQ7%xKv|2p=w;pv#}=^N;X_W2g*a7R#n%KJBq7D%L0}DD;R2=hI$1+>T`wD}kJft-xQI>u{NE4ckJp@H) z&rtg9BvPWeRG_+G0TOzpMUF)+Isk4|%0~io3YL0L^^;E=uBX4 zpxySa=YW7kLE_%j7IS1O_dWp>sIYPPWA9ElPGyoEHx?A2Tg*aDB$yM~(O2{qlJ?W) z;+ReZLsi#|`+y4%6JYji>+-j=1=Q|edue6_P*~v`S3j_)GvjIx(7ZBL$>0(Lo>*bj)b?-Hbt_La^=1FigIU*WXOV49TIEI8iguc%7T*6qz zsWizlFn2tgqP#*Ye>pr*yJjcxcq=pc|Mq3H21lttQ+eO-M}nUZH1Q76ksJTkP>HNaCbJvsU8k@#L^d zYBb55R)FV2uA`M=gfk6%#O->zx)(U}79c~n#s-Sxei0~}Hi`+2nk3^m9mNtvC(tDZ z9W9m$6WP_zJ4e6pB*1Y*WufBb)xha*rK^a}N->2GsLlYH6l!JdgFI2Dnhqbx--d%C zgWfOS@#hWxK?_3gB3~uDn_a7lAwlcRpu0XTPK4&s(y%MRvUiFfSU7hpVEjNLDd92+ z^+|NjwXe}sgLfiq?5Qmz^+)g-LO#E5rFHayZ}0OaCT-;CGkp=TrKIMgDCHLBZhOQi zYA#544tmVr(zgVnic?F5sdj3feYNf2gs;6F>-7t1Sx1Kh>i+Ilt?g0IK>;2_$4hjvv z*Db{KDE9Y-PvYpPdAChMLZ|2#Shl;suXby#+JV0jihw5;cN5bW_jt2{>;H}m>`fbs zlEvy0Drcn~(ZZB_PG~f({V>(M9Qnp;Fz-0)v3ulZ6Dwy@DLB?+Wv;mZF6xd@h!DOZLv_5x>+R35W^}2DI7C9iE7>uQ0l?z%urT;NmA= zV8&+=$9g@GSkk<~8dv|PIe?nh>(|GcY9+?Oiw~JeOvM@~-5kdesbb>AZMz~vX8({so&NE%_@%raN*j%L zB1Nii*IlNZ1Yn%N$D-t&O=$z1Wc&n{@5TJRj~NlCPd*@ zekhudH}2`K>B(S@G?U1%0ewV1FrPTW*>NPbutBZgF66cVe`YZ6DB=Fy6Z!TT-S@6n zpao?eG5Xnc9xGmP@KsF^JTGO~d0LXw60TpJ@q~k;tv{+iOz00%X)hx$|D`>^=pctaO>l-(15(rOcCFktl zVEN=ALGi%`{WCTxgaV{e-qrTIWyY4yCm< zB3^uQ)nlsj##|gJtlgRn(Ma+C>6kix<%YlC)_&hy!iTFbvvTR-rzkB|&nISGN=4fy z8js@XX$M6bO?j~XgC$Ta;+RP!!fml(3J%pqWHkfNoU(>czZ~ip1|Tt5_J&9~$c+ix z@_ZZn0C|N6sm(ijntQJ9dMg`a7{B;u%T?*M_OUH) znC}kmXvUPsY#B z(b`{usX0Jv6%Q(5(*EJCfRy?eLZb&u+=(Pey@d5OTn- zz8o~rT^f?i={;gGVQg{lWZ}FShK}?{CM-d)kq?-<>?WrgAkvY$x}te$u}oDbowF%^ zeLLsic&(stp37rTMqt1EoV$8H*_L{Dx3xyAVWVVyv~~Y$08PdE%jhUa^?VTK*1NYm zEkdv(3)Wv6^?QXU&V^P7+2O$Yx~8lyp@-EaBJq0jGX6>OPkEa@Taant?w0?KL4RQP z^7tKwVGhXT2K~>d?Lk(E6#S}ewV}6gxA9um4^gabFFW^5g&J*0ZoKdJ%$7sUPz&AU z34D(%%7V6Qkzq#Ff$;M?l3f9wXO>zmM+hR3-KElfZtqem(jlC4lr39cms1^X$z9kvh*dhf2-3&TuL#eJjXY>A@OFWMbW(hm{3GK!z_yP3;|bD6UpK39r7f?z1RI;gJk3YqOiwi#x+Z6kNq47T!sUxawg1GrNRtRO zLnihgz2;`w=zO3E!!-Hs3cBvqFQTpcNAZ(6vX)mH1~2lON%7&{qp|0c`LgV>|JLWn zWBhRJ<^TT%_Hym}2#$t~Ep;y^zd zhU&~JAZA}Zrg-_)INSBnUjQ;h;0Tc*sAA|^vdFNSZ9;Q`I0w_5oQD)NB7KcDn;@zs z0>7N-y`%UjKWx3vIRk86s^&v?cL+Q;9EaUVa>~xhjPX7i#Ksqw$ow4WwgWO$jD=m1 z`8!Aw8q5=__~gs;v&DF=%;;dkt$U!%Ic%5YHpaF+zd>z+NbmPSZ-&r*=w=|iEzpUa z47tS{IMQWOqXmHT^4cYc&GOQKeWL1Fd)k*1zy@iyHR=DIltMK3=uq+^e@we~Bu}N6aTKn9&Yq^7nG|Thnhbdqvc$ z0_|8Lg>Xo&2qe{b)b+qAFcWN1EiX5>�>zO=yklb?uGlWF_-rFHEW#!W0LZAs@^7n$=K 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�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

GC;d2{N|Nc!;Kso{0E+Z!$|p=c&}p zE+#Qxzhl0@LZXS~deti9dtKmynoCo>PuJxqT+j=wW07NqkF%nuU>MM`cLc_(t*JHc zwCdjD(cO9$)-Y+t!34GqV@3>lYSQAqq;7cL-RFg?iAB+ zR}l$loF14|GRx3%C>V%swdRQ!m)u8!R8RD?91jpO)s#TPFvN%>RRn7avz#(UVzk5w z2_Xn@c(vYCflN5&qo(S2kHognir`u0LVuHLow?o*eXkFEj_YSAX-Ld@sETQN61+eq zp=DDfdiNj?46t<~P#E=NSVbqwy45vh4mZ$E__uskf*Zd$y0+`^? zw}SLBp5FxnexZ>!dhuyf)Z~{2YeXF` z>O(6g7Mc18>w_Ig1-D@%MKxUwkHSrpOtbvH3?Ma6fF4d7a3MvBv(_QO6xX0ATOWZV zJ|tw}+K<(grVfBBCR~<{pu?Bh@m|AM(^z*9ANOmTBT7oGCSlBn?EJw-o`?!c7C}07 z85;(a(pD)=ssiL=5kaJFhRLveAX0US`nQRdC$@Q!FR^-fREs)7f~t!#;fR7r;X*XU zrX7?S7dlVB~1v`U_J$$^#$M2($xjbv$_@)>R#^uJup&J=USBrUl|*y_Ig1|v%| zJb9-z7#-Zt6da*h7&v)V%7;8BMMpyF3Ym=*$c=yUF;ij56~oSm8tqC`Cl(3^uatm|D}44l$z+ z0fkv>Ul!h3n2!p===`kVeiB{i!%x9KgR;9da7|#ucYzUJAVww%5#JGrRC=tO3oU7^ zN1Qf8T7DN(GcLhAl$gxyG#*z8

)rr;uju>4>OhWX5GnS2++_SK}^0O@GX2vPeuu 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⩔O#iRUvh`5OO#%zw$SC^ko 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

85nLkM5U;sLmXmZpHgbmL5IB((821`MQ~ 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

2nxR5BY(fZeD@Ll2zpVJ(CLUf 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 GbyKk8yJD7n>rJ;{V;R%VMTg?yvaPCllJM;@ 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

OuBih2 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 z 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

k>?V7UCN@C`RL8eiWm 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%0D3Y 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

hy@9pH}3!)4jt$J`_| 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

XY1mu;0xaxi?%lc|2I^0Bah)e{i$~mW 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

tX_Z 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
i%4a45Jf-B5Jp3Jt>M)!Vnj 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 literal 0 HcmV?d00001 diff --git a/assets/sponsors/MOMA.png b/assets/sponsors/MOMA.png new file mode 100644 index 0000000000000000000000000000000000000000..c1d0f40745740986c9ed1fd9955877947eb47999 GIT binary patch literal 408145 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 ZMMboE0H(Mouae$e0Nz_R3lSFsMw2+qyFthbIqU zZy78HK2!evX&sgTWF&CI?q@H|8^`S8Mw0{|d!Zk>Ud?c^2WuQZa5%^hpEg$9^Z-_~ zXMK5Wv)MdHWrd*B@M$w8 zpiU?alj2)6oZUt6{p*}cM6qUHpEBk*1CegFKxb>`X~J-xFdeb*&mE^2q=;%5AoUN( z_L%GeXbI0N00)RLknb=$h%KaE7b-U#Am863@RHq_klC0o4d|0?1xsPQj>0TiOW!1a zWYY_H=Ol1SJN-@LA==olYmKm^4c9^kp&(}RUDWiJ<NKtJV&8TL5eo1^jlFHPBttxry_11QpR6Ybex(hNK zENU=X5PL1lukyzewOd5f$AdCNGJrJ#i&=T+fEDQda8lCJR%P(P9%diIX;-Na*GQ;%o21wM-_!wwgoOV#5;M zq6o@I!BY#y8;d3@iwYN9>aAGRyJXi%+kDUaI_FdA&UZ_};(bmV`Td4yCUg(%A5Bl* z$^u82u5KIu+l3`x$ar(Oqr?|=2bs%j7!UWLtziQVj7f zBn37P3qx?4zr{U?OU6nr-i!!=d3uVwcK_6t>GcPfqVnnuN?_}Ic-0j3^&iI^DyhsD zj)t47`9{xt?)LrB$GXZ%Rz?4l7%vq)GQ?5uz7vnOXK{Vdc+UUWQO22F1SmmNQgV+T zvn?)&_6G<^z|V$(R&MaL136zVQU#%Z8cbUFtwYZLBWtFcyW8MwHxRN)=%G1`+ZuGD z^)fY`E?u-X{5S}{V=Mj!_B;Q;@v;ENYpk96e7T$m#iH50rd4`=TP5^1JBLN3LzVV0 z<41aX>eJE&3t%t8A^Yevh?w&5hQ5F%1(%@#v2X9%+h3xT%H^g{oL%meLLy41KWnvg zHUis$#nJJ#W5DG-2Uo~;>`)`_zdICQ0CZgGe1rBr*6ggq`a{#F`xZ3{X>K6lo$lX zAQg}fNu|3>5Tv`)5hWxBM7p~{q#GP?knSO8=x*se_n^H9-P{Mvx8kHq zKI@$bj{kpK#{SoT(9;7maLA%WrC*%$q=(84!X(_Q7Y5CYN;{h7R7tAKZ;l*m7?YcF ze62Kb)aU)$^J$S-gKPaY#8j#KuwK{Xkt!*!^T~F1bcd1YfS9QyIcOrwbhKkCB3x%% z?OkmG!(G5_A&*I^@Gl!;%RBHtDhaiVXZ^T*Y< zK;=vgC#{*SOoM2+`YpPp?r;zkGPe0g>(Q$%hc=uKY=h}dSR*M*ihWiQqEgXBL!|(6 z8cj`7kryd-3xqZq6ihI%r}kmDiTC2_<*ngCz7{N!+en32-5?;VABsQdju^ak+{41-#9M;V z5`XO8aw$%!n?~>WsK(puBw*RO_hV6YPg)>r2p${!FwHTeLQTSOB>hh|05&|$GJ%Xs zeyduz$fmQtU8+($;FRSpUVAeddaAjz4MS1yrmJi{$DbK|9T;4ay+2V0g z`6`Ol&tu=bz(el18$BtBlofG26)|R242_SG^QL8I*rR`xA)DC4`pH%F$! zV2)$FvLH#_Kl8yZ0CnZYs$&WzB4zjT*{1)UquzMf3S#!#EMT5DwwZd-1SYRo?qDAZ z3le*R6h!;_NWIORzGK5ic%`HYp&hp)SzHM0lRmyLE#dQ?i(KGz@iX473m>?GJVXKr zZ9C!Cu*RFo{pYsu_doup^lUp?@S*j+?ZFt=nf{0d-NEM_mL26-x52PNN2jMn!{IFv z916M5g<{DD$3Nm3xp?%9LiU|%7~jKSj`2cCZXylKk!bTWJGeUGa@Jri(_=ZYi&TUW ziq(gLjert3l7r=5J5*DQjbJ(Yyta~GD|aT7>3P8V4^Kf6^v){LLz6fC%E8(6<{h`U zo59+U95qWYvP7I!QVP`gT-B(1dfot`IG|f&S#{ebwpeCT9rA|Z%||opg!Wqb+uvUc zq;wLbowUS&^5qqo23hAflT^yZ$tJ~JCozsCou;)(-H`l;|Aq*_Tthu;%nr~oL)o9d zow61+o>X_D45#=~$Mc;tBihn*_F@=Ak#Z?3Mui-HlpG(wEB*$7u+Ni6ZN z`x{y_r~?+|VH|9VPmDVE$|)k(CqdCmW%P?k_?y~@k=28KFfY#0pzap+^gW0jsJEq; z$#Vp-o9F$oWV3O@XZlla;ncr~=0~_5+b0{>b38LSo9w-$iJ@Ny7+&LS_(c+gi6`x0 z$7oo6>CLeCz;*OvSOpB9>D2vJB_wcIrkHvXNAN`q4hGabnJt@MsAFWk3>qU5cp}#} zji1bx@S1=rFW}-W&G^fFGCsk`#y~@2tS0Qqws=+;*x)`LAvn*#lW50pdp355T#)Cz^mI$EUdRiKzB33 z2P^mHz@uCuK&^bOv3OrDJ91~ghxJ1Hg5A|Or0vTv-G}QAdaN?}WhU3wURp>`QvJ%2 zRb5A5Wnd~0j-w7oeGx(X&>f;q|jhihL2aU_xn9L2&{ zh+QU&*_tfX?_w&~6gltCP7TuIROs6I&w_`WhJn~9cmgUM|B%k-0+!qDm!;Z-RKKb{ z6wdf$-!cD>Z1uk+v<(f{fb9KiK*IZT**l=72j59}@#?*B!5frPFnh9)S|FZ(a8~Rz z|Kjt^ybC(9J+Jz3Z85>k)e+kKVuk5;Lnjjf))xj%qE{i*7hiwWmJ@IJ^8VYguloj; zp*ZIoZGw?c*4@t-S<{F_TvUh<`N%k;WnDCJW5{l8F*jIBz)A%v zC2O^}(%xzBg;Wj6A~YUzCVMUaR$VAj1E(6|PmNvul7B|B6^f+G^|W?d1yK z?lqh*Rsnb+*hjEGO;tMt{cHVXuY+l6wkGrJcx(B|v&`CN<@xingBy{bg9Q3x(N0QX z5L+K%p?mJp{ll^5`8EJua+}y+b&%TB*zL12a8v5{&DS=XKbr70j0*9yiHcY zAj${E0dGOeA!$tX+r!|{hKOy-tG}BGAtb#o`UoC$eQ_j*Vz&nwz9Uq~wJ}XjYR4#( zsS)$4H3>Ft=YRXHLMb`Z=~#~Z23Nl!kw}TVW(K@b3fG|03Tm6s4Do_Y(T7G%GJ3nj z?4=i2Ax}J zYfWe^#**`j)&ykLFL(c?l5dhdnIKk|eBHLLi405>Sd z6uAia~o8cn5{ zy+B#8qGj76p1Vd^;gfCsURdFj!+EXV@H+hK5C3PGM0fHnInT<`)hNr{3Z^+-`73Vr zRRkXDZ9A>uLS6M^$g`s$E#|sGX1MO4`|A;0pT&c*eKHyK@+q8I(cHF+utB!IyMKe^QjJ_YI>jiItmB=5R;SHCMK(F57^v0hH=i&I^N-T+H|o<;}=iVrq{FOhgH5MEeM6ddv1GbwEL(IjJKq|%pj$vrr|{8VOb>ne2D9#f zsWQT7-hOvV^dy2%PbZ(J0q#H89Bf#an5JPDv)LTvke^?eJ8qh-FkJ6N#S9BuH-^`F zvta$bKj2irI&!+w7pk9B^aq%QaE%+NeeQRn{4OT0(P5`u`!wrSg>!9p&Lv8wg9EGD z&tISr$zuQ8BX!c51jif70XUJ2sPEf?$MrbL6;X?w!N201KCPgCefC1|;+|DBtmyIM z&$}nHBh$1r141ITNDF6O;lED8+bVVHH5Ov)ycYEfkQBQ`Rna^{&G3y2PpD)Zmaj#_ zZC1&~Po*h@e{-Z@_&Cv{?a0Wkg&*{~Najv9rCL{IZPK6N{ zSGwrXLJGET`CQ`vbqKQJJ9k~|ckgHLUI*Tx%PH*fi-`na%c>M=i=q{X_x7*BbP56= zo9^WVu7e9$!cg3GzmULqZHn446o*}fOzv>MeropMjivLp2M;wt>uT)XE#yUMuniUgkzyYpT z>Z>3H=Q8(L>Y_F5VPg1g)hjsaoVd>$b+6H#BweP-N?p1DGmjJud97)Crd6n#Q>Y}C^=cp2HwX5@!zuBypKs%1`OtA&GiuAXDnM@#dYRaF`bG4lpv1^b(A z1izeTZ6{rp4qP7~myy@BcX`+L!k%q6&$mayYq)s$lsW?-Bet;f6K`<7)@1a7`@cB`Dm{v;S2FUIqb26?8x|Rw=Z{@72|jKIRzTe zQzn;D8tep=(wB8ef<|D0H!6a53|rNIYSE+hXuA#F^^}!923X-@N}iOq$G@8_=ln`;tX~+ zTaMT=Uy?~|AW&keE{pVJPutO7l&ceqt61{(xm#UHXO3M_Fg|3|41Yka`wGc0lCs~{ zBEQ>=VAD?!X2sn-J=<2vG;R%)F=n0~snf9neLML&uf8A-pH)S1v?oF@d;PwgQPj|( z{)HpQUe)?ja=gr)F*D{xWQK->)tDTWXaNR^H}K&$Y0wJsFS+QFn`A(^j^ssky5~`T zA+X6#1Rws!JJb?`B-rD^Lv11E-H>(7`9b&S7?3`8PEyfZ8b|x^^Tj8?g9l(^rKcR=cQf(iG)oGWoT?8P!cXx zJ0>IT$V3niWeY{?M3QsL88Tu%GND@>HZ^jrbR@vS(uTv?OBiN=p1zi zXAuXYeH*)j(FeTWYg;YL!%AQ|v!fP!mbVQB&hFg(@oS7VcYSA6OjTotdu_y3{>nEI zXGCeKmZ}O~S1N$Q4uX6oam`+fCFl{@Jk z4Ufxhp4@n%jvhaenv2H`j1{DCehlZkorVX-eA-MjK}D71c?*RYC(SGTfsowUx1J1NNzj}dkHtP5n= zgitk=DXqn=+g>%t+aBluw}-;^ZvF*hkG4mxZki5$dNyV-2;~l7&5khoyj8}lY3%uA zKnWO+7R!=`Ux`%K#w%nUEUb{WC`F6u#CO(?h1G`uJ`q#_Q)j7xJ^XmK?CR50wc4tY z%uz6R_#<7Y@ZdCMzU>yyS1sL0tB)#_`$cnjT?ITtsnZP+xR?FEkCVx8@f>f=ai$9V zbLdhf4PQ$<9_KDdB2>$S_%&KB^Qz>$QcR+pK?77QR^=Y2DgUG5MXUpe&VljDr-kSM z-GLt+A+QCz3hhxIag>LH)4m0l*3}=u4Ii$}=1}d!4$gr)ZMK#B=h(sA#L%Akp9Lf8UsY;Tj{hy!B7}z{V_L`?m6Ck_@4IOsC~Tyj#HlH%Sct)p;UmaLzlzpKQies+jsmjs-?#%61nNeb2fa} zd{MQ^5gkBeCEeA8-%$c^;~4mT~Oe zLkvw{fCYax3qh$D|K?nOU%2z2Xx$GCnbXuRa2I^wrCV8??9`MhnS9;%SXr>?#FMl> z9KWr2B&sIU&p%Vp-FiZuV8!jOUpuYmzavtp6RKl9Azq!3yLg1V7iHIi5V~_1YTUCI z&=(CCNaI4&%%m&S<;$r24g6SpvaGd2y!hSUPfpmT<;%C?WRJ z=u%Ow*8%5g#nYaItbicRG)sI)K!*N$D+-dhb3M4tEimV8B zD~g!%xfcv9Sofx1Odwo8Ksm=~zX$1mDIy3QwdU0nW)_z(%XM#p`~zzzI>7eCuPakW ziF}nQ0^3WHho!g&6`sPi{p5^Aq34Sc#=PIr(kR112z^bChlXMMVgADWo{F)rM2RV* zQMVNfYyI# zCNYdNoQ=hFwm(#OODr~tVA99#r!vd65u?CCn@-n{s?UWCb0MzCnJxmVmb!ShqC^P zFjMtd2a@bU0!z}uTZY{T*S9QrSzK2mE1IXXr2R*CnSM}?d%EHO_^$is1(D%gv{G@_ zOz-}gmy*cusp7Q_&|@1wj|By-F8aUYE8Yz1b@sf61*g(9qSK>d?8Rfu2Dx2j{aQQf z_|cwSnYoexpJ7REA{(*y4GULAzM0=yo80c-eYz-|7bpp@hO*2*lvYsolpP)?d z2jI&VplpI2ZWy3@m`6x!Q#}Z)0*t<-j#|W3KffPC1*Q5364N1EVRZ*Tgy}=AD^SWv z53lC(WpIJ&){w^u4%PV<66AGg>m+er^!D55+RCE=F=4h-t=Y^c<+Af%O^#R&M#bR< z|GXsw=%buvH+m4MPDhSkolHs=F3_{~wHD~8dQ}uX{n6Kz1|HIzbit*zw?#W`Sq{cr z;NIjW`w^4TI((*l{E^VJ-}|2Hv9~o$&A+I;M7JHE6dFqEcXK#_up3~`Fyb{E&yd!; zSBt?bw_d;^{wklZ;$ZG5^OjksA1^+*U|?C|c%3g8U6JqMQSa2zwwC@+Ld(T@I4BT` zC_GtBtXQ4q{oARmn5>H`t0BKQl4?Qk4jDis)@;}rce7QHb~YNjJyE(PTgF$%7`#VZ z<(S@{l01|=id*_7ITd{dE4O#+omEC#&KS~(V!snP(-?H>MyOWd_iAUx z_x!f?{J3WeLkwFyt&0qq11V1%>jo_3VE*>sB?1Ld<+YTvlAFGm-sbZxvqBcyP7bl5 zTp?JHXXC4C&?;@ZP9rs1#>^6jRJ! zCU0#`njX!h|ITj)az_Uivroa0mIks;CHa^mrt4Ap^?`@pNhTdC905w+7katoIJ6F(9;YUayCZ8{*L*dby^faM=T$c!@O zT%-Xq%?tOj`1D<1axfp?rvn=U$sUkpC{9m!<9&kVg{nz2%k}Gp2Ckk|+J(BDO|%&X z*7a*gQDxuIgB~B;%@2!-QN+m)wN=|J?YFi(mZ@~cQdFKFT3s&_q0EExzD@|lVSV8J z{MY$DP|TB7kOPHg_$XaK-HlyY#sr1g9dr*`R-0^@-sIs)9V=o;sFGY91uBV`plSx& zwWzFH9uA*@StQ!Eh$FiUbQhq7$~8Rfu@6kY)lHfCHN3&&e58B;5SqHlW(};`IiCdQ zGkDb#3D|%d7J9Z@9FP>8L%5&eL?&&nXzna9`}uiE*|5#V7A2}CG01S}Z`|J>Z1E1f zlL<2bF-RC$R*ByEz%Qme`e4WX@G!Tb3FaplT~wr;?oh1OMyP+V!nsW&#F*mMuPLf6 z8nbQ1&x~Fd*c=IrTx~Y-_2k^AJuIh~dfL~{uI$v{n=4EE0q&q_%DqpEBHvr&$95kZ z3Mt6HWm_OV+|0I5VT5?>Hp{Xv`%z~&K$-P)U%f0!3*sKEg-PweH`quBL7>cH(7 zlKK*HmCP25Kj77O+4I2m7O@taap|vl(4Qwt)Iv@6=7Be5X<%fuyb$fQ`y<=DR(|pxenwKc>BkKRLkA@apb968muCS-$DeZN7 z_@G(2xL61M(ZN$ZTL1Yo>5O~8TTEdU?J3$lwM1Ut75KI#FCe*H8PH|M8WchZ>8nh1 z_=J>}FB8!Gsl*IA50G~^3omsD>v>^MH3T6aFL7l%n7(jroutB}r;0qXn{f7Ozw-pk zN|<_=eQjHkvd{_*xf85kcnU{W{(WP`MQj2EfU~^&jkLhmv&C9kB{pmK|c$X5#=)S-1=2a!Bdjo(x{# zBs2{ue7r=)Uye!#}(ZSrgZ^3JMnoWAUi5^<50rJ%~QzgBHc$ zDnaurClklta&#V_HZMn(>PRXt+V_*w6;SS6O*;XRNe`>YOPT&~>3+25_Ly(mN!?qD zts>&f2gF-82wU`c%;G)nZd!i6d56!%)Z@)+;C|~(VNdBvm)%i@MN9)U;ng*6-PG;|BB~Mx1+ctRDD_|Eszdmzz8_tNoey7ag ztycoz5?@|T%PwauWA3IM;J%AdR8YhT=3P6gJIStm5Z{_}I8}<=5z9?)9jS87dq`+0 z#zKevL!{wBbq+v~;N~JIwur|;f~Ct3Q$US>a@mWs$N8vKi)5b;Dtl3ZL!Zu{%v*V$faU*U*CCDoMbv^9zGE|o)U3^5+CVHK4 zl!N7tX!MBPcfP?evG8BOt?=~}IntjQX+qasltTO$>K^N^FUM_FUYyoN{)ufLKi(P#K z^6RocEKBpPd1{r{+C_c|)^@$lKSjFhzFG#|!UpuMF136;TD3OD)Y9_>t70lzUjqy_ zz@_*gXs&O_lmRwmldPJYNTufpI3AE6B0GFO?>O8F4a87PxMS&pc~7gDY9JrnmA0FU zA&wy-!XI?JQ1>83m-cTj-z>C?@kH7$eMLIE-T#1r^KY&-J%9PZt%NikuS)`NK8@Fj zdzg}dPCDbJ;$N?l0{<`O%n0S)vEG7E(|H4Lllt(hXL>4XB<5M+_2L(Xh@Mzn4bUG{ zeNDvLhxvB>Jma2O`M4IG*nwitW0Y%*r=*b?%!_Rp!gcm{EHW{dQ3_}?-7O64`0^Au z+jk8o*F+B7=VCcZw-a221gn@yjH}vF^qu#gkDTMZ>YJe0{K38dR~@&BsDSOR z!C`NaxYDmqts2Kn_SP@DaUIXN&;YboZ_ioa27+V4=8jb`d*NyeN+D%RxK!kA!yO`j zPXuG0nMDFL=>+zUB{2fWHi7l4?-nCQ$nH;)a_sCN{`OaH^^Vn#|5beODGoc zS`nx_06t60Sw=%^^NNIdvhxWMJ>I{mOrAqlJ;-IBw`}XvkI2|Ck6neuPU_fRsy!hu zTWX(IATbwKG3{>opHH1&x}1W|0JfautE}& zGBncMzgkxE$e}(qa9$Is5RZ7p=j+BsnwFUmc3bs_jYKrvzepu8?~2R@ z>8K0MT5b6zQmp7dQYsX_Wm<5=BjXeN=ekZmCc3do(=5o78N%N=!A ziN<9+#dw+9{&^{$#fu&#Qm?UZ%r4!P#zA!XNMXul`?|O*W`yS4h@*m#zuN|y)1Gr!mES!LDg7(~a zsfF&zlRy~V=qM#o_PB?5bU>DXl0HBfcQ9-G)+08&yygtQX9~Yzr6$tg%Y38I^kcqX zXq+k=P!EYoP!GmNTWJ@HK=L_u6x-hJTpEaQ4gd66L;3N?a$5F!V|*-H=8&Tbnc@sr zje)Hr{`5f}{*0!35SJU!7v`}+$H%EqTrgR9jk2_#$GFzP1OPZ${$c4(xNr3M3=;EK3 zX7xbMl67CY=z3@q)$LJ2E72dzm^)>GrCccJwq^&)XkCnP-LmZrTw}tm%MPUTgvf8@ z9v>e5{-vUVmQiUBNrfI}3ZCq)&Wez;qIIQjTVHxDk?V%qw&Y~8L{T@P?cnLIXM0E; zcF(6b-?GvJyrkn;t9L^fR--))NCW)6mZu}0>@lvDqqnw7o#X$LlmA8em(v`WCxIIQ z3s@k*X%7`s)`-+110kaNCY+evzmuNM>YDO2-Sg&# zs;LCk8Pj5NruM{o2rMoI)f4Mof(t#lxgjR{R?)OW^87vi>O9?v#^8ziu!hFkLo__eh5nC2{|iHw$^uxDgnK&L9OPXOy7L}#KaDg+ zU4*c!hBtGe41eM{K*~d$0a^lY2u(3*|3kn0w^t4IzwL*YcJ3lc8088j2;&RP2KMUa za=Uhd6}f!V4N0WoP3OuvV7;1GE9BB!jwHf_5U^LpKLc~r&+_@3F@_CJ1Q)+;&Cb`Q zJtO*bG@bQuqEzybH8;|MvF(c^H&Zv`7f7g3BOhHD3WQ(?Ry5)0b}fI1)%vf^*m`SQ z(L`hJ(t+^}zs``bRMlu}C7h}} z$m=Jr(&{lw!>*cV3;l4z3h_Mpl>omUWFupC72t0>!?#a;&@KD9Sw&n&498dhR;; zze%pF)i6Fif)_MpLqmxcA|HAlP36Bz-XZsh*D^P@DVP-seGw|IwGjRvw=yKH_cdNF zYtT4r*mO~(I2HX%EoWApG{MrsU}cx<@H`BU)e2Hn*gmdHuq@dYNOXPal{caU|9B`r zq3dDy_-vdo$_W&RLT|jRnGCON=CiVke?@<|IsD*cX6uf0^I=2b_yKh}-cj3i4z$1~ zU#~F4u|YgfeM*sHpG!d(`))vk4pvd%TOG-5jN6GNAst9M@brNh zg0`e>mNl6g`Ab79ah?6-F{_*tw6L3n9A|a&CMnmgIJ8F8kRykObzr~bY#G9Mr1#0+ zM|MsvC{N;%dj)u4j3ckZ!|yDTqF4ycDo6DVlU$G2vTamjXY4weqiFX?yc~Fr*#HKA z{MF&&LWP3)xafRepEM)8o9m&ra!0AQF?UViaZen#1_mYtei3ZWU#xyMM0hL0RLhXte)RX%gf%e2>;AKU7$JuYhsI9 zw(?{SJ_+;8+};oemDuk=4lVnhZc*R-iTtdan7Roa6C?muN8gM0 zQdru#B3v0862j*rVFhC08|WwG6%SuGDP{>kaTHZdU4ZzyapDJpTLAJwmkafc#9Q*4 z9r6NhjviK5%$4Y?rfNTxb_ClJjIEe7>)gWz^HuLUtXaE1uVei_6Zjgq|5bU)mXQwUg1bln>rXuJfF(zO&c$4@E zo%Wlda1Wf*G>|angl^fYD5v3OcbD8nBnF(vVc&q{J)Se#QTn_HNpMy3GF^)@QouQK zrZKZ}GRS4v-+Mt_D7-!7BuOZ+s`swImyB2lQ#YBx*4OUVjvOJ_bKX)nc}=!Pta<&s zgvJQ{z8p|je#upbZ#zKi@M@UYe44Gv4Ma2~Suu%Q z5wRb;xmI-bdVFaN-gZKK0+Knw{biz*Su1%F4V6|o&-;rQwgrHbHcRuD%DG_M&r-m^ z;}dU^`JH6fVuq%UT64mPEx@$n9{4$kG?UxFyfidiB$l{lXccs(FAxLwN^$+-x?6Vf z;-$)81bh26!2LMRdwW+gw22!~^-5sxzM%H}>2$=7&OPI2gTDATV@RAZ>lA}|YXHR% zcAsTD0%fPg?IdJ7C_a3vI^i1kt0z+bpq&*umLV}~sELBH%e}s8)MOIu343z2dJjes zK12v@|5y#jh56<>oWxJhZ}g!N7gDXe#h<&wJ=xy9J}=Sx>B0KTzc*6lDD60Z zUL=*R7ZzZv&N6wJBl=a`o;Yn+u*#N`%19-Ye_!3#op-exhEnNWzH}FH$>h-`P?FTX z42sksFo7`be0T($gs0iTY+{Y$PRc_p5^AD)6u-#ys9r414(huF#~uvXx+nDxCk#WM z{E4dn?N6!n4a32yJXXecz@fiu*sTh2b@(b_ULEnqhEBP7&P*6FoPaUJ^UrB`4t;dj zpQf*g{6=5CRIzmSoNM>TMa|}<(ekHd9$$RVe~(a3uVRo!u|1!WmkzdimrQuXJK){lBc*)O5xoJ(}v3{%ffj3 zySKDNulss5waJe(a!qMUjr}H2SQ@e9AKe(BkvEPpj1GpFnP%4}KkOoPU8pF$u+FsS zBYa!+%kNfYP9o^*9aDL@*nG{zKiEK^&P;sW-9hSRbMp~* z+58oPV^KX3Y<=||{wP=CwWpx2KM#EDGc_OaG=H}9$$Gy-=3hZLB4>J#!=NPmki>#| zDcDsWj~(I=!kIbTPj!2g`cyp}wZ*k4KK}>3R6L$Y*=)Z&9e~Hq$achC|EQzPa#e>I z-%;@s4zP~~=v9%&o-0)@!^u~Pu(iK&AO(nu#sx?aw`xvO&0O*#`m@=5)ds|)Y58TD*wJr za#uD%UD~IAs}re+e#mI~(e3LmhlOI_E7V}Rb5TbKUG}r}>uI4=opfifyKemWt<3yy z_vGBPoro%wp^m;Bn>WDyVtI^}jr`QNBNAY-xG$CBXkbm;f_pHbwfXAcHcR~PD^pV{ zxMmt!;k+Hl%qf6G$?sQAFKQA7Ii24p-bYZi%WgY)1<1aKPn1t84Ib_kBUHDKl;oXE zB}CIx0^ir;G1`$k~P-tPeb*Uy~78uyp6H0Lb zkd`1gaQQ$!0&UlgC}$r%{Mj6K=>H=xBIWE02_@*YnV7g-Q>3)tAXz@#yDstUKCV5u^#6ywLVGqpsf`bqSOG zIaiKJ7|b=SPIi#~2yF1fy{|*4l&ee`8{Zf0D$Gi}(bIJ+S)ZqR?4H50j7xn@Q9 z4hPs$c0j`8+hF(C=TFnXd= z19|ivb{a-sxAroMwb#MT*?D6+NoD4?_2lP=lp#p5o#QN9Fn?=LOQmN|x@$Qx&Qa_+-~Z4NNFAC(WveVs_k{a?H@^OH@}I zjO?magg9OFYHoyx)@FQv0a?_(xn}`kf&rgr1;yQMp%|7I36aUqi3qUpN_v`qwmeq7 zbJ=&b-F0~Rh_6WN$B|eL`4yZumPa`J(;=|TS|Hd>5-hQ88sS#3X1fB#VUTln39yC@ z0?I+%q;Zl;bS<>v%$m!iA@z+ln8qT2_46V_F@U@~i~$Y^DsA*K>^hFSmV5z#ys&BW zlhkgi^-PYxj_TkOAt3Es@qGdZB1PH#rEB9gn#N_}3zYLq8c{MccUk^n zv7@Y^kBAqmcT<-IG9zL%)Ftd4nUI@Yq#@2-Pjmt-D=eNm|2S8ELFGZ0oAa2yGUa8T z_5A1CzaDliTQMbcA8qx2eCL@zphWSAb^#6;`5I|(ZjXc=o< z(aUBtErT+HM4KTXImjaxL-Eq4XGvkb7qOp0E3Ml_!-rEM*7aj<1DSMT+4( zW2vjwFhVahXl5LIR#`}EGy$C2AXbs-rj;AE#|)r0gtZW^@1y^Io(d6}D75j;KQR~=WKyn?!O$DjEF)N>sslF7YiMLl4|AfMh?mi11!_7iBc(?Um6f~XA@rVK z%uT1as)xicDUR)vQLHq3ZS8guO^+;KF(YN-kUTj%qxw(Fm;+Kd9%wQ?A-pLX<*RuF zT@UXRm`y834F-I04_t9ieJ!p@4Kr*zQme(?)+Wh4Yr<+-0o60&E#6MQ?vEd>KA6D90&d zFB?~L2g-A`>05-$on;HoF#~yw&~w5X#z`evfry`Rz7F*#@w50;>j@>P|NMj*epr+4 z6)QcJxv5bzPhGWNp7CJ^`9hAB;IDmE@E72z#7!N0)IgBIg>p_l+lfxN<-o3*zZ9RT zcoG|_;PLCTEY6Ngq7A>1`lt<2&})j{r-xC^!M}%&sfJl{kiBB1mys!$0pqkSMZF0d94-G zYUaup*%T`XCsD3-OV9B-`JQp8YMXy3^!enKxc=dvHkbQQ+pq?5+=3ZikCgO10UE~g zYK<7m6&ca}NIQN^sTdq=g<$_OvW8i0ZQTX2;jfWe<6&v4qN`Dxli2NG!A7o-Nt?;@ zc+(B{h;xcHehwWbcn~jJo3_zJHtW>8J#X~X21 zf9Z;T|6Yv;Ci|$POay`K7174QtTdX6i#A^UWl7Ouz$-@*2RBi51hE4nG46kKzti+Y zjk*%HIPYe;s~PxCvdlWmv|;?OJRDr>Kc43)dc4@*qVNlcj!?sG?q_SJ+Fq`Xue{#4 z>Ik^(q*LTP5%VL!DbCZx;=v=T!g5jw;na@3HzXo~I zmnY-F>-O?Mp1`KG`OEkCxIRvIurSdrG9fmB9?qwK$Dc7pO^41X_7T=q{T$k{8Slh! zs>82|yYBSJ+u|OBeV*6^G7T^M0a-r^3XEK12Agq_scNZG?ISZ2dH-g_!|xyyc;T%? zaC=@7Mzr<>%P?MSIO@@mA6B?hTVJm@mDxgnQPtgnMp5-?<$s_LnJo01Ijd>gm?0By zY+}K(2Vr^X*xGWx*3>GrU-PQ`KBXwydvEhx=c#v$O*3TRykWll_C+SW@<}>K#0op> zB+oXFKU1yEO4#ANkSSMw6J67mWxqeSN&EWK4=w6nyV3*bqZ$=Oe%AEIR`c$>Kewj? z9`2aEMWwGNsx!cO-`}g7D+-jF%LvJIt+=<}w8uF!O-t~^x<&lPJxJb#siPOZ4GCDp zVCFMdY%HiSSAjt0;_D`Y=p1N({)f2yOVeQ~!wN5oe@2I?Z2?vB>U@}Z7GhC-aYiV^ z!0wiVR*i$f_`_7iVw=QP_w!+M7ofzqhH{1j(TDe?*#*KEH9Ir81(dVK$-Wdtc;OI= z+tYw}4K8bg_syZY)-LTK^p?!D*E26I0hZdV`f^ug0jKGl({wM6K~hpBt-2JA(A;^N zIrpQ^S#NV9xX=VZDo#AV_Sn{{iC22EmWP(A_y=Wo+P|B4<=!@(giB9}9#BNXsBh6X?G?REQOsMm?-P&Fe8pY)FFKcO$GHZJ?R#ZfXy9_ygQ zno0gTi2ruQWU|2J{IIKgrVIcmAr?EKogvqNaGSp?lkK9TUB1(m=DP()l>PQAaOXSs z|F|;>5WAw3ziCObW~1f^2Rzr%1lfslD*~aq5yXiLTN7W)%?^Wnlya9sOQk)Y zWiO)n%aVX{@QEfG9pi(0F?sYJ5gj}Ph7gz~JvnYJd})&eIv6R{c;lU+`%k4nna`%e z7hbeIV+XpCjw7)yVDE}km#I09nhxilotC^${x|GtHCTRGRE_6>smN(xMtN}e&|AIH zK(_--m|WWN0;&>6CXqc@@MilrsWxE$9t&9S!yZRScO9h|ev+eJAE+^cxfPQ5QG z#HGT~H`jkR*Fl66w6kvUJB&|eJloa zKvSF@)JDr4_4VaCqlhak#{a^UCKy4pmNOo;4hGu@Q9GquX}LR?JV6@J->UV@&qaG` zS5-Z6(B9mdQxvuNBWQ-EG7#0D4CHl7vG%~<=5X`(^=atH(=V}M28@6UJ48qH@x3zX zLxksRborx4$HuA3DE{RJm{(qa3U0gBgn9<3>@i-1? z>Mz5P=6cwZ(OGu%q>5=IkMIdw8`|xOaPXW|#sJ0-@n3D~5~45;K!@NS`pvKB4YrOx z)a-LDLJmY0==R83SuW*Vy4`yJRlT?ej+aPiaW##n-vW`dAQmWCeWGc*lAXRehOXTs z&8{owPw!$#=($?2B70?&W3x8M`!2RyO~)Og;PfJ4eyX#z#%a>}DNxtldoFq~G=#ff zo?rb~8kDqvoT<~L^|%^gg&JyqO@|Gj%cEoR#FcNTk=(QUQ7l1!e{LJQ7&CISbO)jy zIwfj}e2r2i#M>M3>QW94d_6%ybt)XThL1n0ksOxleLKg5Kjf&}Zs54x;^s8?ALP(d%`M|{%hy4ao&1bE9ClB7`RE&cW9p`|Wy0H5<8|`=En6(&DV~ zD)i?KzjY5ZVB~<_UY~1YWNFaU_?uLe2C#NgC@$L1BKnh;nWFE!i)72$_|Yf2LAkxt zY>(*sS1?l9zcfDs%!cu(PBl6_(32;w=lroO3@;ls{u3xekQ_{{7a>5&D=>xsdyfy) z`&NtgPhyAYe2rwq(^`SzsO;7GAQ#>7Ix&AlW9RiBnDGe|9#C9OKTEYCow4l{F$MUBm6M z^c;cQ%6k~JG({D!im&Cl16FIF4)&VyPF>bKXexurtawFY<14|-nFN}w(ciFlb@~WA zn~LY2t%Ish!)mbN)^-o(El!V9Xd*Y44H@oMi4CNDfHPxk$%h8r>XU)w@=l15W>W4-~baYY$@}5ou=Lnd?#M`XUsosRtl|FzQir?-?^eA6! zdbh||%U)<~V@0MsBP{;FCyEzD2P&|UGh)h{V`aKe^C8|b#I%p?77iWBvAs8AvdeR@^_DG9 z=kV;a%JcEq!$ZiY_{NIrL(Z!4raUWe;=@*@%G89ga|xZT7px5jzYVel@U^i;^gg5`Vk7`O2$@K*P?rrk6@;)V`=SE(|uaLS?y=5P}OSrt8(dH;yU)r zA7wTbns`4lu{y7qN$p(Q3!vTvx+5pj0VQCA!svoyNdKwn@P5tZ?ik`l-znlnjLNB% zJgMnJ<IJ$bcL78pMfs?J}_M5bbHopaJH*M z23x8+h5N#Co-i_D!a}sJLJGmXG3ChFyr_4AOfj2}R2-E`qNVcE(IX1hw0YCXWrxuL zyk@;UH=>J8@;d9wN8kqzF8NZXv!T@|7C2DPx3!25Kj!e)YVRC37u??i!JWMHXKr|T zBuym8Rz$GI`^n2ChsMzvWlQpSm>SCTItbwEgyo$W;A>{^*lwe@Sr~6nM4pLNX&+)) z({mLmZF~IQYvVcH%5UR{fd&FVo+pVez8%Q(l#>@;y#De*EPqsvm(boOd)mGtd0Bny z^!?Wc7iIMMI&spzPi+VqK*|)`P-eiI+Xt+>F|ToPKeO-!@%ivRA!GDam$;wk%-* zEi$ooYdUcoOqhu(6=A{^9fnqskOdlJy0zF470AgR8y@f$WLQkrIGZUAm+Mkj&kwi} zWDN~YFcz>nyEuZS%!-{1B_WUL5BlejePX8|X71Pn|hlN444-Fz^7P<>2>6O7u74KraP=BL|XH zj5(0}RJccP=pNFq=d?Q!w#c&_;Y6xK?vQI*S<}Hq7>mUsR~+MW1JDNeIWxynw8Zd` z2OKMQIKn)fTG7B_#20m#=y&uD-c0lc1!^}$nxJqW4DL*J+Mi0hQhz(bKf%7{oM0ll z{=r$ib{23R%gVCV+w4y_!?}rACb^zlVLZXQwA_e1HHYTo;osqJfA+$~G={dQsL1N6 zilx%=+WwyGBF6>_SbS*A-o6rO6#w4)Ys6R?VhG%!cM3Y{Mp5-uo5*8CT)4;a!bK0| z*81iX9#F;5vMi7s_SKj;H+TS8KksexR#@K7H(*+zrOM2i(`!9SM1G+81X=;VHNvd7 z$x=Pm))w`L;rX)S@tmvfqup<3(QBP{BYgk!2Qb|kiX9>xJ_}$lMi68inI|&41oms# zcixE7R!iy^ORqE!1A>3o3eR;%jyyU5gn|AIGg0S_(BzRWJ^Z!16y0g3S`=iNn-T8t z8WDRqz;@gKXi^7gW?KZFH{1^#!Y(5n(X{JA@#};I zs{OR5kg!9m$sLEYU17PrCr~6E^iONa&9i0ile~iI&}#n#UbZ|Vdv<@1ViFWSv9xzr z02Pl&d7o_caN=qIy&iwF9P!oh@qYX4-o8+G;<4R$lQ@g^4tJz1FYe4%#z;vQoP7|| zV;zQ7A8`j{L1t;z**OcF#pB~?1D0^oXwR0T@`VAUi--09q_yl(rbzcKM!2rzM z<9ryfYxuEk~%$v74 zKV7Q4Y`->TW7+Yv_LcG2N>-ovr;L#rNbLiBb}X0`9wDLdmAxb3x!X8Zw!I#)vc*Nz zz@}9bdS%C&Qs}^O=q@x^4)=CvUX0)BjOlzTE4cv7kbY34t6W>vJ5*t4qK9k942j!k zgHl=vNaSRmW@FOk-oKM@h}~3AK_)6PQ(tZu0?|if# z*|*W>M)5YH;aozkcfKj)oJ@>?kp>&34UsBlntQXH@Xh zj^eXc4xc(zTa)(NXM9b=t6*exM;EYR4YJ%IiX{5BdjXN0ibeohe^oBo!#dd}q5WXR ze0aii!ntTwbGH>WhAkQ>AOm7#El|7TB_PKq4eXD0G>>C2QBkqi^7bI^`**z6EoNE^ zatqcH%<@-_s`F`BCgu&NhO@cJPwDz~R^C^FFeme$5U8!a70{DLIXCy(mV9rQD%nJE zRHQk3Rr+@d?v#IbEN8~X65hRes+m88NJT0siZ&O1mA?r^B(J@oL3sC?CU{l z040aU-%9qmPCdl9#dJ zw_D`uMr{G+CILdh0aXRV(X_1q?Gjq6M^jcKVHRFKYwMg+oK;}WgmF~cyS=ukmK1@1 zx*N?=2=pwR&J;#ElgOC=3XYfJ$mgZux<6bSX`Zl*^!cEW1oBp;3U1eXC3KHN&+c*j zPULAr|KvW;k&!GZJ53;eqL(|!cYT;)yvu5+6)ODUk$JY#u)`8~{oYw^j#9smFE>Y- z)dfzCm#k@w@j4-L3gKiVylp%(62{48(e?u>1zP83O~S^;!=m@S#LtqU%me$^Sxs1Q z&Fy<<33>z(^kAn~k4=A=^6TYYr_z7p#|GsyHf`G;rkxw=)lET}ci4wlG-Obj1QRfa zt#%lu=ND*IOs%hXAY$_!rck(tj33D@V1DCcB(6G+E{1JH(Q>Ic)!dc&ZJXUq!K?gM zGwY6R2=NzGzMFj0&O4a7VPl6K{`YSdP>*M^=Z?0jOYQB>@*x^c!@er&>&7c-&f@A8 zk*!9P0X18W;D}SCj|Zlk!ePEFeh5-MMOfzh4v&r-eIl}t4=tY_Jn{^8NO)QKLanKJ zO6O~vHNt8 zbQNEaw^cU)z)^sv7RNgRo`G?6X_s8LwJk^*UVTKh@+s@vEUp_m67#852Rkimyw+*s zf2ijhVAAJ$$Aup#-Pz6>_PDJ6dJqhBHx>WXD$qrAQ!)D+)c)g^6dJ(w&Fmo_1A-_( z(?R%1Dftx@+tj3dqi1S0mZI1Nr1s4o?D*5kR3ug(_*54Dz;Gd{5gAg1O-40eo?aMdsl{?7I_kG8G+PUXll;Uyl6;?|9kj*P+3_`mLV@-U z+xdiGygMV~+7qC7U3e;rV{?@~-*-w?rbK1HGI{Fjgbz%d#Yk(Yms5u(0c$Is>l4tC z|7F&deTR&X0iiX}Jj%hGLlbHdn4hQnzMc(&A8*UyyekiT8-9^#c`3rWK_hYka8?er zuOUNzKx49HEnap7y#V7BgX_*CzbOIV?t8`p)o8KKpTS!KRK23S42BZKoqEoebP-Bx zcs?T3Tq^3rLgQw?C^Y+&v{g2^d=MSKeKocB+e03jxKa=YNaS0CdMoCm2plN-Njif& zgD-^6rKYpRQLm?73v-L(I`l)^ z;l%aR>fwh0=98|3IY>L7w#L)dPYqpEjR+d9HqWeTmu>a}XzV!UovJ2G5&0gt5=!1D zh}-T!39K)!2L|s-*Qrp;L#VrLM2yLeeWdmaKl`CupVD(P&2L-f5)69 z$ACF+%|FAYyQfFbsaPO_-t(J7(*QOr7bW5(dyyWbNm}kw0w7c{TS*0pou2JfF!+5 zb5qRqi3q5mMW3AP8nSRY?~Sg!h|f1UuxebcDKJ5TcS?Te^TT~dlB=_-TH^&3@v}~b zb`ut?HE!puZy2i!Z;B|I=TMleFQ04RwT*mpnE+h-rY50|kjFTBZH#+NBgZ)rf&4kQ z;-Sg&_L5H|8*}3ikapjcuNJy_uZ-+?+1K#;4@Gmc&M`rnP5BF0;ELA*KfuAAbWM4( zD7z8_bPs%RR=-M^-nXt~qa4_I8J}6+u}g+3Hz!BSTsZG2M_r8e!VH5=C675XCE2ot zSlv|Sz4=QaE=26C*80B47FH`KacL3t&pDq@F3i;({CXx0B0n?ELZPytBPccpJp;Q$ zJU>S~8!I^No^FH7xK5%&+b88;^xbsQHzq> zPmlUK_ee8X(U?AIm)6-LENzxl#)YZ;0Y!dxEc)=A=5%`q`SonvlvYln!zVAii{#F- z)8Tp8K~;-0u%R5Jj~i9vjTg;Pp}mdmMV^|kB;T#}tDY&RTOG3DCxlm`)i$PrremVw z0wgeSq@1_1o)F<1Fj~sW80X|rttnmRR8fZ}$6~Mf4C{$)93jU@3T+TM?CzEO zD#F@-e9lLrxT7f-O$N}AD6hxd927p+#jV;qok}`+| zBRRO!o0nNNaDZdkJ~WJJg#SyOBe4ke_&9S9Q{V`dGx06--tMc*NbhAgA6;?y^>@sY zq|&%)FDUbh9urQkPQ-y7OeIv95je%aF*8mlRGCqJx$PX5@YZuPC5291Uit#TvDg5L z2ko2XPK5CejN!xz-=~H^#1fw=wsc?UUcUd7VZgdxs>eD#Y2xVwo|GjuHbIPqdM!Vh z#P)QSjC|P8eV+LRMBYTh4W7R zqJ;vy)!0MMIu1Wd;jLbozzLEoTzrieQ?CFbZ=}vx<}AiZtpd6X`NU|E_c2zKE%d3LeKQRXlqh3SnA)^>PkI-i zt;6BMb^S;rBA+eS73YbbHL+X8=)~>{pIojkww1p8TJj>1vxQv`l~rEIDtJPhlVS=$ zJP4t~DWW|hY+yeQBNP{sk#H1W+g@D9{LHW-ZI;usV1D6GSi<$>_stNeD16{d3Yk1B z0ubs+!`dOV^K4YKBA1RHz!|LRC$Tr+62YIO1QoNVc9}?xIaAc0i3e9}b5upc|c zdlnCE*N`|e!(I?Xf5A-aRiV~)o{FC!F?3;BD?^Cv%IniK3ncr}P0dIys_lGO{0=Tu zs_x3Pk9C$WYiZd*nD(S~B4?u3>BCb;j!%3tFWr{^g(z^M!U*h0@`yxVa}7Mqd24o2 z=HzN$2fOq<7Ux4*vcaU+FH*W+E40h7V2c=UpNTHOb-`sG!`|?eYcibeL=>!XQ!=r` z7Yc%V1}6=C)w2VcWC|*;pI}NqjuiSUH-vTb!lk z5(2)Br@Z+;rns`nf>)y@>0{Fb3=-yHfu@PtpIyJ z_lFVQEAQ;A>^oNv4ZBmR+Zyi>@1HGIN1}%Ok{*{px-FhNgR7Zh)t9Wj(TntUyQ-rl zZ6k4*X8X(m1KO8(CLztToZczRa>aJ}w;K71DhnlwrauKlkeGDB7KVw(sxw3pf22Pg9nR(+&JYS<@1L zr(|;zUB&b^5Sm((bNjZYUM%qpjC@Tk3)mbh5f##TWw>6#+CFNw0&4dX7dGaz%GI;K zIXg+%LE(-koYv4|wd*Sh3-IH=!f0 z>p6zsCI?(7Um`exAxxLAIyTzIjzL5Yh0k|`Y>>AnGJ5<@We%}YR~DO z|M5C?`tV;A$nX!bvWf<2C`j|j<7eC-6Q)!MWm``>uu0r@~Am!^a~*!mQC1H zMNs+koO~HMT;T)MG8H2Ci9nj^$KY>bH}JWxTQv`4;4Mr?=245PBLf9HjbW+XYq7IcBAu4mI8+4d+3kG zJ~HTV5S8NizftNjY0IuQ@;I zd&BR@*ys5#8zK|*4u%XCJ4EZSk84>#R5}&5AgnK(nbB~b$~ejm==!Y4$^n1E+ZIai z*Zx_|E*NydK(i|02By7kcf=Et>1n058M&M*9n! zJM1Ia<}IXheLfYs^wzM<5UJHo`;`e~7vUq^kWU9;fUVfH&}!G%a1@#qK-J@s241yp z1>u+>r7#V$LwpnwuIkiLyA1xJhW=T~0aG#oxb+m}FZZu;Ln?{O0gpn`X#L% zd8%t_YaHo4Ye@`>X`2Djs)A>$20aDh4p(r4F-hM{ibM_+*E7#eo^@)|B4UCew=7hA z`W)q_%PnqBMjdlQ$<}4)Yop6cA&vvt*quMKg3tJ1+^DRbFWNk_&V=}DW$ky$`z($W z_B@+#RQ0ipMxZX41+9dv<^jzw*dM+W-KM0)6YOw5CgdLQ%6l;9m6FaYljG;ME~(#M z#?58cj*2!EiWzV{mO19}>_I*Dj3NnXtqQhb#I+RI~92JA4ywZ>UNkYiW| z^;yzJ@$=1FtBJ)_+*-l`_2T*VXpe?M5fE-7?Eh8Si zd{jKNU4JFzWF>mKsUzc%bvB?o#FOVfKG&n|ydDsNUBH``y|h{&qn-fwM^Dn|K6$NMxhCxv~W z=eWE23SB<_>E%3@E5T%OwmdS_v;Jh@BWb)GfnS;oCx*kYc{2;lXn@gmh52h$?N^Ta z`fY^T87ZR+k2b3V@inAd65l1Dj|K{kxr4-_Xg6J=2VT&X=R#M4>#e#U5x?V1;v6Y+ z^0AF;q0Fs(E!3_cV=zq*kvyk;MtP2pg-bpn?jZq9Ftv~ys4Bz$;Y zJVONs$W0*n4m4hof=Gyx61~&BeRbeOlC|(mP|>FLrM=|T6YbI0Rn*g`O99rUHkO%1 zNnE`v`5ZA0PWjY}rN1cFe^P(0TJRgEX;ROX)2OeFwJR#ZK=X!tn%tw+(C{wb?3{7N zL9Ee(4*@jkqiU6pM)%Ba}hmMBm zYqKdaQrrT0!cL|=-pN=MUs9ICjaP0g+4;$Q&j2LY#z|^4K2X1QpB}XR+b|+Pbv!QGJ%}4+ne*fD#>8?x_tIR5-;~Y~lwfWv!&N<*y0-(#Xxi;w$;e~h)dUh_X#771g(Pr^_RgmK2+bD$qZiSL+C@5Hg% zi^LyAeAbsA!74(VtI?@;~rL$-vkX-~r4#jY6`=ndw> zXIv;F0I`V_X%7kTZ$Nt?{M$zaluU_hPjSUJ>3aP1JB0U}u<1eiG`F;^jZ})2)11OjNsA8^l7@$UY>5uk#n0UMzGB6T*CV z)PMc<|E*LL`=Egckb%*A09wG=h46llV~ELTqCYS=D_5&mA>KpL4rnVMk*gbdNk17GIPkdkX(a* zzJ8Ic_RVW?w%*pc`82zy)a|WZeZ@xts>;-(UZi2(gNti#gR;KlROsQu_<)Q-15ASR zdWu@$2;q?B?a_$y_QCk=XF%U0AO>{1c5A--QJp({MDjNOMcBq2bQL)IWy=UeYuAFLQL*qPp?z>*IyL2}u>sJh z^7rNCMw${2u8$ej&n>QvG9Q%$&ZmbHCEd>B+nN}fonyUm9SVB^h2h;k6tTXB=`g^I znUEpwp7c^{X+Bn}yNOSuNEfu4PDO6KQwhV{;~!wovQBLUipe}mjrkOWb-k?GK7MLZ z)&*A@^K~HxCE}gLl-uXVY$H%t@n==>pZ&5m1L^Mw9b55IFT}#J9#Vqryh>&T@d$c! zR&t7u*y4=K$d%Pio?zN#ZJv|8K;W&#}|((-+UQij#^8z5T8@Ow0;o z`X5CFzkK~a84bg0ZcvJZT-Uukx_1zu4Ot+3eg<;k!Nvbr*nQVv51JBdG{42(U>=go zI6mXU&JtpsBI}1df{US=%HDhb+cq|0?kLp1d6_EkGUcxD25LE7a1{EhF&E8ZF%{e) z*E}g@8$KUfg;5zUG-L;*9m7w>X*VOze`t^UvP7TYbYQofchoq{4rO`Jp3RKfyD{`0tH;-9Dm0Lc$;!~N z?S+h@kCI$AkumiHcrAF)6m|J9UROLF5Z?X6F*FpcLvAqHS-=lhk=wm%Fg4p3>z*bm zE=;SGj4Xa!w8`7aZp6Rc`)RN=+j)jSL>nA>8~S0VSOA+je;=BI?Z;3>UDo|~DpKGU zp9%xI@Z(0w3m)I!KB)ZI4I+ud>(tt*Y#S6ni9BE~Kn3CPSz|6GYFAO~qP0bjIie)* zN8-WO2E&oyK?o#}rIKzoEBR- zX>x(Ho`U|Y){(9(YPo0qsM5tC`z*ih%BNOu_ID=q|Fmjy7kVyME>f-XU3@MGF5SL0o=NA8g}q6sD`J z1vU^S6<&(*>-i^SK;_MH*pj z_G4L-ln*=CO0@)KY&iPT$%oPpH#`Gb%5}pIaBdfvT^%1>PBg%~xw!GWh<)?AHK3t9 z&eDhidaSA1Eg8Lcg_~YP(@`dqAU`O0&%)p!jXjZE}UcMxm}~kYkBbz8cGiMDz1B| z+81jf>IVBd+t1-1+N+%@_r9m!zh#inXdwui14SWi=EX+IJ}UAulK3Y&{HO4^J|_o4 z_0P+N6t59_MC8x(gjDjV4M^vtgxT^b=c#7-Rmr@wMkd0A_kv1SR!f?(7w#0c+S|e; zT;~pMxxRoq?oK6t?zJ4}%g@b)qwd6(dYymN=MteU$CKe7Cy8bzO=hXOZTpO?$MS># ziwwRU{5`t&UGD=mFd+`My9h$@g^#pg54)t_Ha0{En&4Tqvk!r&Ow^yI1xvB$Fw;`a z8wY-BNCz*B!zt~x?1^XZ^q!e^&SPED8+ERBu7QK>St<9DB%?onMRVBD10z60W`fGU zjLRiKsa}`5{X4?or~m;xcRb1Kwp5w$u0%+6ckbEy!t#;NRcQF!S#B=<;&~AtV(Oyd z6|WG{XMsmIrokjXITkO;U*1flfMGpg+Vr4kb&qS1OW)%Hooxwk>3c+z!1es{(a6OJ zM-8nIS+US)NMJQH4Qk(jdEfb}B>qRsSh)=&rsMn&vN6Q$c~MDT>XX*)K02qGuDE%- zKPiJSN(tMY_}XWS_nz#vvt!|tY>qsxK8P&Y#Wm=BAPniHwAu=+95;!|%_l#FQ+7j1 z=%(fLfT!h=ox`NuPBp;uq?b5s=J@XcyH#%TXajIMPX5XQ2KywaM8F19=%)9sbfzNkVLqQ{NQ_t=CvK2Yd!`o6c-AE7gKQ6{;;UZE;c_DuY zv9|h(ZfDz0e$ZC^(b_@GbFaYe+v0oyf^(O3O)?)lrxuQM#%*H;EdQGlM+vRlS7B5) zXA_Owdm{z%jUWT&WNZH3Ojtez-01~ZG)3wt%)avqNzksN46V|6Q#JVY!52Tl>Jo+& z>PMtL6xbG3lQJB{_Bz0Fe9Ymd#>W-^9#WstPwd;#ufB50_uW5=R@#nX4VU zo%Y!YCfU=VPxLxmraQy!GzQ~J2CaMs>ny?tMa{v^C4!sQMcGavq`fO8LX0NttNfYE zDh-4nTyLhBQXag^mHj9wik9kR z*JJ+Ql={Gha$n~HByaR*-@XGD4Qx zhiVRs?0hlAN7@5O4>OHQLWqQSdNtZXY_t5D_t4xAN@hE;_ln7G4n%bd=xyr#YtIhl1RNy` zzp#dAjqs9-X9pEF;j-&P@`VhlD8klClB)Cy5o8S<;>AXra}ceoJ3=fJ4#kA;&$V-S z+Gjs^=A*giMGnz7E_zRbSaI3B*&E_c$F`+GQW)0duxl0GZh1`#a3A!;8&$6MM zyC{vv=eeoU`Iy%s_y=99sf2?#VLCaz~*GjVaHvQ!k0YI zFQbsKax7lMP?$&3L4`#&8YNGf%p0A(BHfU zRY>^=jf{ymttRI44lQ`TAET~sg+Tlnx7)Jw zNOJOD3$31a37HVv+>Z$21BtC`Rppnd@1&jai|0;UNiawtoZ2{bSEHpsX)ZVqYx~nuNl> zJNq;_SLdJxy^L26%?uL1JJ%>6grcwZ)zsOU9h`{#cpvB9)Bfv2=_1z70&$VTm*D;~ z{k4`ud*ab_q$-I;AGVJ$Rrj~$$sS5YAS$!44486jbGxg;&WkTEs_Ff{*Y2u>J|izw zTGpbd2Do2#x5d|HIo=`)p`>bM=b#JR4-7{gBfbY;QafF3Cb5J!Tqi z-}+>A7dtIOEpRA*T;HZ_f5a!SS?M+AJp#~HeKOIxSi0Pc%g^K#4TR^UHx@DR9I8H@ zz4oMhbQuCuCceY-P;**WxzODt#YrITE#A6ki@9MON}s<^-yW${HK|p!(sWXVv{rVp z(<_URNgh@-Iq8f1#d{FS`UPjEp?Ie+-Q-nkyMOM`k0V3m(wjXq78a*-02+Nm%d$8Q z8FE{O*~DU-+-y*sw$P=zK8bU}+~awK%J3Ks=v0<(RMp^Ilp}vhE)JQ!$8OEjXkV10 z`aK2@VG0oFVJL=IBC7ls?5HgSaHF=N)yHSRv+8D`SVJ5XWWlm+9&eV>%xoXNZ?zrY zR$5G&3Ta&ow)_(Ru-7jp1b(o!#NbuNK%8XA3nieFQyWfRo~NJF7k8-sQ~rsD`o; z7*~8r^Io*0<73@=q$2x4<+mA?3UY|yD+X>RSKQ(Cl=&uQwBGtl3xQZ=^iRUqzWRDt zam7v1i7D@a&&#Hir%YVt&HkicfuYCnZ>A)LRL8EJ{cNE+bgQNYRF8`}d5-4(DeKlp zV?$vjwI-H}!C^fPS(SV=LFwcO=+c9XA^D6E|wcO-+1Y z6qy596UF#xLdweYO00ziK7^zyZzHaben8C|r^=grY-O#87v4@{R6kXrZ{}c7G|9fO zJfAdmX^mn$B|l-)gKP$Se32(AdUHgCV@z@~=HDMz#ol`dgZv0C*l}dtKSS~324T&I z!={It2)6n{3R)RUx#^;CtAQ7314DsG@#C4R=p?>;AAQG7iE{0Qq54|C93lU)fnkMIPJo850}uc>R3l!8A&5YI;e-A!^z(X=zWBKci`n;XcHF$oLBd zqw~kgzCK|*;FQ_nnL429!!c$eFm~+E5W6=#&Zzh((Em~I8x)+a{dQPy*q#fk`cIFj z2t=}^k3N)=qwY3Xvn<#BZTF%y zRJc)?%$ndg$BUG;WJBt^M-DWan{sKjyH&DtFn@$Z|4^-eYfi5}m3Zyy*rXN|lg50b z&keEKu0|MMyH4K;;}hPRU97V!BKkCO=W{h%IdRIM5tW@6%sUQT)8W%R1C!C9-t_!j zDM36IHXKln_r+RSQFjL1_T`-kH}!cewnN`<-XVc25nTOcyP1Ox-xGy~$F#Gi!ifWM zIS(OC1fzk`$;1r&ufm&1l9}^F4gJ%t2A;6g(}qT znL~3e;JRpW&CRN{b6DSvROZkXN6JYP6jB`IUK+@1M02prNTmUw)|&N8QT~V!i!r_7 z46C#0N=#qU5}o}3l{iZQ!I)>-{popX8G}a#{p@Zxx)H_L541yshD52$zi%!)g<717 z-z&jrJIPifDO9$8e6db%W++aEH2!|5)#6Z_F`Heto!yufAAz6V>YQev2I|3!vI|5g zf^(WIxj|jwUqd)Q$ibQT7>c;|?xKqqg;cwGM+XNM7#K{fLq5-{3?4);j&C#nI5e4j)CMm;T1x z*#bnboeq<1R?Qr@?LYsU@SiUw3bn3w$?wnwXdv@Gv~+<0*P}RUMp4t{)#!wSn%BSZ z$lyYS0jXFF)@A<t@>8_@0k-U$5tSGWdU1O6WyihP>{_di)y!W=3|LSXid0%~U?YKM%< z(F)4%+t7hRK;{f>7mHW^arK|G-^AJ^&Et3BBO8}T&E!P$AjMuMzN1;9u zAu<+ZPoDL@TRG0#672Xm1+~zOidOQ!tFCoB0q42tuF#eWexSMUO;)p%Y3Ub1&G-Kwt*~zm1rQb^?LTC~F({bGP zpjpAiMIzn94!h~Z4-z+(TN-Up1uu7HK&LC(?n5qXKKvM^>nkLbi;=AYr_J!)l+%NOxMe|{5i^TW9j>)aA zE^;t@c!2T@MY(U>CPY?uS^Bz!9+*oAw?S41v1S5F#QzXmnJhpT$3pp!-%Xclzo$b0pbKM zP#fxJA)43aI1(xV7c?KHGW>jQ2Y^`R*U5#ozjm#E(&M+TID4T99Q8}tAw@1jpra}I z5=Mh>eOFwSur;of))Q`p1Do4HS_dgp>HBiysomI)KLNl0#7O_l3W@Kb)Q|fj4!g{p zl~*k*-&-UZd|Qm6Fu-aid1m2Q0LnxB-R9=XD4pKVb@m~=7v?}%`eVa?Yk}WMvWRv( zf9Ue4g1@3L>Lwn>*#%eVk{2c0889`;zapyIg*9OeB&Jg>p2_F))#JR!3$&CITIO(XO(x+#IuFIrrAK##^R+ z1U#bgJ?hWrHlois>e{Le{Pdo1DmZNyLSFnE`|zjTNy8o~Gf_^U6C4;mVvY~``KUi# zai-nld78(4@RPg#&OQ0|H&-pV_qnKq-(Xd(Mt?BJ50Yd)bj?#f6Wi$>8Bk!3OC|r) zgMNxf-8D89=ayIbZ?JB*I8j0!Nop?XaT{WF#3QDfZD^wE^}Qbh?0$X&pZZLcUczQE zIy$-7h6NkRPiFdg4{&u|Oe?SCwesh%?4RiN^R1gv+Q?5=Cgoh_9ft9`*NqujW7h`$ z0+(^sglF3qnZC!KUM7aCD=hP^O!lu-f+yyAU&;TnSASt{SJZp&UlDsW zYVsPdNwnNOjL6pJ>7x7VS;*gwlO=9Q)n>A|H8z4u?Q`qPn|jlqRQq`q_5|>ngjDGc zfQFm@v55ct70CI-ze@=vHQpqjvogHTiy@qb`GBaFzhz29r>{dH5aq96&nNOT=`m)K z%F5#e_$oznGoY5|Q2*sQnKIXarM2iJ^`9^IU#}X`Zldki2=)P`=}ev{DH1K7?7@z& zse1%oJnREpNC3Ltwhb-vm+*vZ2gY%`c>YSUb}ONcal$6@PFqg9*QT2t`SQg>WRBa(7a ztL^`fE#uVSfzkv3%ZU6K-JYj$Q`16xBx6CMIVn}bQE22xM0NtTIc`kqQ%L1%x^jvAVWa)|F8M*jSgE1WcoGC+Jvi7rI53c^i>KQYmB;IcWw=}8 zXL-=eZ?NQ#D|_eb)KrqgR<-=mghfKU;~pjGY{NpS$~w-v){;WmbR~eifw` zKUiimE~_q|5gv{OxG1t`+qDVdwA1S3v2S9@#G~nRAzsp>9OKCB$AMerR&TDdW$jN5 zwmTV3CM$tX0rU@KrL|MYL}C+9_LmLR&zuR1_TJ>F*hM-Wt$Wht_~yijkR7&>xb$bk z5|nJ8_d}f2bFkGE@Eq^5va$@08gR?&eKk{;BYc2{XlN3boF%Qy;B-n)t@gP9snDbj z&B4D}3$xsxdsD*3rhMyoUA6LT%i-V{U3|r-HDRDdG{$Yyk)OXRxmRHabkLJwcC^$L zlf#X~uE0-F@nQ#O%`sDc63S68+u;In;7n>M6rI1`Ye zXwQ^%TV%nUdCs&sdWgN!-?bz~IA`1CyJ%`T?ULU(*}tpPfVy)LRatvRV-R@Q;rj48IY;%Y2=!eH(9uNnWj5kVXCIiPSnIVM%UO?trv|}pw^^Up#l$e0ls!hM z?Jvl`v_ct)|F}6L;0N0gXMUQ+vi_ytJ@G22jBY5yp)QzGDeb6lLtcvJ0rM zX{7dEpRni|%IRU3-nC`B8uBIt&Flk4qqoP|IV`_w%ihY$3bBeBc>uJUD!8Dw;>fLRcCl5h`Td84AJ}`ZwXW-0 z*Iv75${frl>-FKsCSk9A{?HSNhb_spw?0OpP@*f};@2(sMNl!(qcbx`U*2Nv%^y#n za&QyXe>z}nTOw}}!Oh{A!APEAuBw^*h=>S6{*9-8M10(OlIzyt=?1@ILo#tGN3w7i zLcDzOxE+Z@fWWHlApfm2)B%;GS7-1x*2$7!sV%|%vqel8cM6B1YNi_a8zc;?cL^+q zkL|q1dJ^E3IRqU$BAfabgBp;`up8w=AbVxF`*Jq@0zqqP9HaNGD&TR)#pb}bsId@g~jb^ zujWsDsEP3Gg@JCN`n$REMAkXqE9OZGi+GTSJ9l)s&YX zBD;ta1I>I_Mr=M%5YO-XgHK_YeN}Ev_E`4Na#oh7tD{!yAfD69%E?m(F+%9eS(?oW^GNP{#%28y=`B25dBK-geCJi!x)jJ=e8$C) z>G7#fMu^Twp@PUL=99e_w^K3k{=*c0CmFXq&AVdQFu$a5^>gpYTxB)D5=Q%FrfW#z zq7-T&j3+!ZL^5ZegaL$I3qC5%h?rT#E4E*#Oa#b`y1I#KcgU(+Fu&RUXyfq_iAlb7x$t;mD_Uv@Z&BRjg2GO zb2&ZKxTAXPJZOl#cW*aF^ol6x!6v0yZeFt(AtoC?B{BsRO;Ew6 zr7wcIZ!~|qRx1QMUJAL7c9Hc&x)wL=_c~rCBfSi76N65cmwy>IL>_qhOuuyG_Wsb= zfDs;x;OL41XWTnnvo%Hu7g1MMLAZQb{Qr5d_9_{~$`=Nhf@9RMo2K~EKGDsi2AwyZ z?WaCZy;`FJAC(-E99I#R`wx~PTiGix^_+2_?SIl@X?OCEuv+ZVnQrrcA?^S3bFVqc zP9jOd^y?}DKAH7}RRWi!F(hZYnNmqh+bXzWZg6|S#k?s%U^YVyMtQI)CQaCUfH>q9hx7-(Yc*@O?KFfu4z zVvP9hS?+&mI9J5qFR2RCkx)~Y%+oU1_36i=*=vG%1~9123XfU%Ew1TaAXVum`{+33 zjF>$_@AY}fKT6s8dltY@+<3!|vQHJ);#i@f+z3KLX=(Z=kBTl+1y*$g`M(uew< zxNJ=fGTN<1i2Y^;lC!CXoSYmRwc`tW$>WJd(bbvp4ahFyMXS=z@8tb+--xG3&_*?- z%DvHT+~?Qd9^af7C-=P92?mRk{$_m>LV z8U3Mt&dWSmk@<8LQn8hp^b03>`##;6u&7H3^$Dn}}cqTpxHb?%y1ge=y*S@)1y95e7&fc*k8X_?TYZYfmvkq(PFBRP9+Ib)vTYKQx1xV+Ys&&>c90n z=Ji7gaqzU*9K*FpseKEwl>yIa^lX)aRg!y(Bq2og64_-;k@j2(=wv?Y;9Ra*%weC1 zuU@a0S^;ZO(>bQQ~M02!ST!!7m&j+t^*T>z!)B0Ua&M(>AyN5l19rjg>K`{^z>frczccaEIpfmR1{204)1rm*7BqFoO{FAc*&1w+1I^U2+BtlK`sthM zexf3gKvloq>o~m9YuNQtJpZufG|tfdfX(U-S6-_VP227ld?1MM$|@N`$i4iLfc9+2 zF(qt_HyeMH+yucFZuX{175Aetbn7x79jo`aWS}8-LesjjKOzmQc3##WyE_i;`uQ6V zPBYpBIuNGD5%Q`APwZG?KcA#|xV7i9mz8zB?@fXyU;{pKlX|f+et( zd0VJ5;d*oby4pFtNoqe_253;`GS|32o&Yz ztXm|m$omFC-9E={rh*fE|>~k`J6PB4a!&KE7{+duchtD z0gGnkxRm05_#*Pim-m+!Q|&$DP`_$i;(d4PO?Q2XEbNvG0msKT>i3mt)_svgnHn0$ z$kSP`#ayhl;n_|0g!&LkX zO_)&eVV^JR>-537-b2$vQvCAR^e1uU5_Zh2GmqaYGO~+dUg|hRb;DE&U!zWzOW)zR zIaU~Fx7^D@UP=FMj&Wc|{*!D*0l-q{4(HJEZemh&-GXr|ot|$w3wvkdgMescG`BW< z`sP`fA0B49b~LOq33(8Pv%zCf@OeR+jhfZ5J2lI{g@N1Uj=0*eqhA*(Zul>-r!1`J zqz>A0IvdpNnh-~eA)4`iYZ)x@uvk~Y21OR)W9^n}I|%>SJ_(|`rF?W_FdAE6gF4)iep5q-YP+6X$0dNUo&=;(Mybl5eWKG8$B z(eZH#okAqq7Uet1bH(wGNDU0gqL3v0Z)(Axul#SHGdFoXeJo^rl3ld{&~AnbOMEkk zkX!7(XvmV_ZnWri)6CWjjX^B={zgL^FQ`Rpj4DICb`|b!noW`7Cd8J31 zybx5&)nZ>fvXinPrp(R5VsL@x}6VE$!(DhDJ~9pd5%^1FxZV z(Ekf7<&@`8mj~W4{aM>f+zXcCZe6&OrA*ORzcdtiC#x`B=wP_J=q}I=Kb0ixs|v3X z3A?PP@#56__vymIt6w(oGiAe0$R($Cbi+dPI|BlQ|n-2*G>x@jXUy@L>a1 zXFV?`g?n=WztClR!`hF==t(&<3q(){29!+ByMNkwcdlOZ`>3av_@58;(@OspEOo$( z-*$|Q@aab!qliOrcOL2rP@a|oP5rj9OMJf zq1(DHK12HL`z{5XUuHKwS@7@Jg_{((9CWhg*VK3qtcaJiO(O{or^z;QwI}GkS|k=( z>ed{63DUbGWV+r;+3VF5<`Q$6(Mt_PY{}{3g~_b$>yzJ{E4MyOL*+gZ4|pe#uL?O> zY);J05ke;1D$#Cz0l*nkWjk_D^xX{(;ypa7*z;_ zcaLljEzxO&bkdp|?fG$iqbJ=Z@}Lno@uHb64Dl{Dp7i{C_@m&0P7{#idUgEB&cC(W*XKH^iN4;<)t-0C_Aw zFR$~0-qeTp+ZCH*>&Mf?e8h}T;!fS6gJs!dx#P?hUDa_QcFcA;lp4?pNTj$_bgD4 zn{TXmFLdS{MT8+?AmW-oRk*C|P;cn>wrFbHv=!p%f+UwwwKVHJ|AHG5Zb2*k({hC8 z{^(;1D#phs968V0etYTwgC|b#Rm4w^ZTOr1ZYe8Azs+fP6pw$Ai0fMlU(9cnz2Hrv zz&!J15IC(*3Y|1xCS=~`*1G6i`y5LuaWr6qU+)***nkzQ15qtDUHxmvK{V#$&Dofa+w^0=ZJ>~@us9AfJggLBL8@4OKBBjxY! zrt8Tzwl=OpZ%G`lEy7plux{JCMZ;%EZ$QXb*9mb(&oFzz-0OH9StVgOKynkb*NXa2ltb{tv23l zlb=#^uI{B=Gs6}?c~iW0r4X3##{BK$22ekvOGjfRk4RU<^2(%;eT0FbVeBjBk%6yQ zb_Ph;aS>^#k*~5+lZP^QQC|ygi7}rQ2W> z4D!gOp4d-Jt!gpbpPyK+5wtzp+mLfx?5*mc6gze9b?R=HBFHnGA)#b30Kpy`B6QBe z428XQm*3HCvgDw37hIgoV+R`K19-a4>Lu2-pUpb&qLL~5lLR%DJs9tWy!fzrgI>sP z1QJ(0SX_4ETn*Bs$ib~tA%>}RanERsoOYZZP3y-mh9=+9#%AhNRt0gq1C&4V_=>4i z#PNqiw_)X~Yw)|e_yZf`gGMxkLGfKGzx^=Sr;(mZW9XFEu9e>dSMl1h2(h%_Q>a}C54Z_^6Cs+nq*B5c@Q3!~Gc=u+yLdP@Z;`M==4440R zK)M)rL{&|WUsyT9v!}QeP zCYWz2q`)lW(g@NQy)AQd-YI(%R&@ENk;}Rq#s`LUW{mObVk;h+=JDzN8F)#;YyLaZ z2zCbQns0y+MRb??fv(myve_Qrku;+(1!UaS6)foA>v#beMx zdCJiukAID_DHh|uUOByc_rUEm^fE)akuH8Av|gdW<#*y7JB0_Ge1fHnVJf!D|2kCR zngAZ)vs@3Fh--)jRPueb!-#2zs~74?zpuQ(5|TaIM4E5JNF02Q#mrxxqha>En~QHk z@6>HNKqebDg^{@Ura>m3olRoZ_%*K2KZ0MGX^Fntajmhpm6&j-UdC+tnUj4yX4tV1+oUlU+wFYB{gimG{&1m+f@8gx!E+{$ z2fR)!^8~$D*V??$E(KM)?Xz1iy#C?+=OcFI56htxcf_tz7kIra@hnlJ`k6N5OLa5r z%Bv{$de6+@b8L=%J{KQs?Ig`^*(DO|syscd8bn*$%00Gy)}7IislZ~a1nT2>VA9rv zm4+XA;k4Tw_re7SjX)dpmJ4Xn0bkyYL)BI+o=z0CHh#TwDkDt}^g$k?LV)k~Rvy+YS)Roox1K}x1A58FWjC2s)#22A z5G?mWBzZftWnv%x!@Jv~)PDN-&V~5hT2BWQW|xdGA*;~3?N+C3UFJiv>X$b~^LepQ z8_gfjV;VQ*PA>`#&9AIU%vPc0sjwY&qk-;NZXhydhkoWUb^fev8UcV16@s>N>!6n0 z72Z>XA7Ufr|HBShFZg(NsOVVyN0z3)@M}7E8tHU@vV`&{Kf#uaCC2<&ZFXJg@8*WD zk&<5HUM_C3_Mzl73l@Ddr&Iey7e+OqroMP(-LT|7$B{j1{Zg+0CiyU7)eE|TOSSIX zTTgt^$&EiqOEy(f!I+wLY4h3A3sk~R^YRU01Iw z4m7NM_ozFb8(K97o;_P0Mz%n(DP;NOFw%0v2iDkfNW-Zl5!O$noT#v^!kO+$PIXGa zJ=T}pT_`Y0Cxor1Am0W@5$d^j*`x*$^ytHI@}wU1)sZ0bw~9nRNWH>NdZ^a;Lkg** zG-rnt9`@hgb_A?;#MRsR#Z*oYR$`0gZp46O2_MT_sfLW^#`+b&V);6hR1S6QIH z959C#i>TeeWr^Vr``MUE`k&11!JLhBjk2zEQh12YWzWg{p5xK161*n~TY@EvxsTX% zWy)(-EDTYWFGR*>cS>lTDm&JW!j>34w&T|)SquK2Q+Nz`m4oreB3%FFMEQT$m?tER z-X}5%LY5!2gl@ZMT5ZhL!@y!5F>-+j8ZL~WM50>(whI>cLL%9~N+U#sv|ea{w>sNv z-%J!%9j+eex>Hjd%rqFR@H(B}xW?tB34V7|G+l<4-=Dx!Iv@X%akOxph;qZV3LO8_}Y(vO86dUr&yk@UZv{lMkB+Iz0MUF@Ca2VTH{Be=SN>t z&*mA`l=8V^nAe9+Z_H{ZwN==RHbs@UBrZKwAwP0j*^4p_qm$0#&$E!EWW_yh4W%9> z8;?-G2(_XQG29p_%Bt@h%IdB#htTFm_y!_Riop^#dhpw279C$@Fh}cLsr2$bBg3}t zIW>~6mNIJ#)k4Ia7e3;0tQ^$?dE6hxepwAfBge?x>7J)LcileV7XkTJl8aEok1^Y5 zOBVvpd;C1n@JA6-TO?8DDh;;<9gH$9<$ci(18|!2DY?nWLS$Wjc^j1czLQy$Iw~mF z9oJj$pwj?8y*GYd^%&BV!8ZrqlxUV{1SV3GPOqDUH^g}C1|!i`NIz9A=*wAomb@f$ zW{pvaz8ZF27TtT+%=&fMB0b#)!aX4DHky%Tc?rHi+VnN?P0phIMlxj|p3hyZdEJ#F z9O0RFtdhz*?2NavoMu%@D$oYoZ6zBxTL*4U@eGUZG5oD$I{{NIC_F_u|B#1&4NI=5 z2Uv8z#x);aSwyDDJ)Ij}E$4A=`MgHF=AI~n8j>1_+l)#r%*q{yu=MF?GEr&$XB~25-+c7p_jD^^wepI z!|0)E_2-3Vqm(87c$9GA#OtdH3JOr+IdFRhd%fr$i#KFIIu+kG(l#26Y`?22volsv z=3zWC-{Lukmll((4^UTE=d)W|y-i7(Joh81sJO2Rc#&S%BXp`HJjvOO{y%A)wx zaBE*&@G&~a8L7~0Nat1O;G?4!=*wy=*;?@`s_9@fK2^Vx;zve?WzX6zsIVhr;iE#L zZ6NcbM>ee(_E*>6f$+UfHHcMzuy&@~JEV>{?fp8t)2pfWV)w4XogS$8%n{SJkp9vz zk>#$qsHkQ{%okdIo7SA`Kl(GN$Rc->A|<9H?~%{?av5fOCJ~l0(9DKo1v})2PI*(p z?9MUpxUqy49=%*jY9U_sgsG2YiB(6JO1Rnmry%}ol>1CW`udx3?;4vVSUHIKz=@>|O!7=lngzZ# zoKw}$fooZe{Or?I)mLuSZzAHpQLd6Z{0%L2(y@*!=O6vGRxEct7U*|m884OX>X)^3 zeEx0vneIy=KjdUTNA!&h6~E`)g~@u~NRQ+c(B79L@1OA=b*uqVQBenj!Mmc+>uW{A zu4DHmtF6l%yxoxzw-M>4mNBjaedT`F%2(@}kDBs(e`ES5a$v zO}%Q~@A0tx8QR3K(!m|{np1lfLnr9K7k>Cin&r2z>z=IvtVpj!qsXY{2NnH>jAeIB z+-{rS_F*P_qy*+G4v9xwA6{b+(hw7}Pn+EtT~oC7KmQvAS#!cSq(SfBINdT&eN?0M zon5_O%(4CfK+uPq@cTLY20$Tt$9u{B>uf|7n;26P)>47K86{EaD_oeZ{9Rm|qc1V7 z#+9Z6aH1&dZUb4l__o7Th3WSXxBnRNGSBS1bT=FRX~O+4^WeYu=wq>TY*(zsJO_`# zkTl9dKXyjibE?EO$6?LgnY|`phtkc|Z{52lO9sC=R<#gDm-f`GMc071XDHF>JiNlC zqt#r>C->#Vpwc-Jl^Wqt1`V;J$VYHqt!3W;BXPPC3vm8l6DB^e}C5TRH1l&;fUxNh0L* zz;ZG_VSa1MARW#2sI9tzZwHu8+u-9qAEr@npw0Eoe!8+df7IWmHTy>CVm%#%4aIZ6 zP7(&eVt2dC>0)^tP1^DCaFa^217=Cfpv*)#jvwLIst@`u~s(erz@1(RR6GQJw_$bA38y8jB^ka8o z;qErJtv>Wx04(!fZ(Xf3p3Ok{ndS@s+{w#fMCZ;Nk*s#bYO?DTq-nS04d;|EfNve) zTvf3@?#5e|ThA(pm4sk=ryxG)dJ0S2M|p#58={0^Rmq#~1clufsq)3I_X_(@C(0mA zB?B{xtDVa<3kyBu3RW-LZ)}$3iNx}Frlae=(~3yk5rWtU65o_LbP8g~ zX$q7N2Ga<=0Iv05Gi^wk0m#+51M{^lY)b~;NjR~qX#eo1zg!hIbaY&vza z)=XhzFUNqGRW{PC9wsALdPvOWOPt}q6Myf9yf+lRwxY8LqK^iwQ#0Gvc)F!+Ze6}4 zLQf2>7+IqCcL)3C6Otcl|G~~n-!~%BI=CtL^S)z|5tR5sN5pyki&0k+=LYdbkt!$D zLj3k(DDUQ0>(dZ^6JB24wlp^azfU!e`rYLVeI%lu8}oLR{i~z7>c%?6nzr(pjP{7c zw#@5#H8vGVl8{H+Tl5XOLZq|8@0<>=9~P#hlQszFnwt8V!h5!}Rgy)yHpd;m8dTU+ zhm(H_Die%0%dJFz-d;hoOYKiE@j1-&yDWE64cqsd{u~%;Ob@3Q7ASn~bKz}oN=5~o z?wl=ZI75^DN0G$H{h0<;$z;Cn)m?xn#G-60{ zpl6vVP~OsFbH?`}ZOJNY+>77>#O_n-4gKYwyuLTsS<+N7Pn9{y?(;M7TIIa1pIbC3 z>?nZneAO@X?9HqkXgwqL@c@mzceSdc`YK!5I-rV_dTbly=@u91=bG0T?*diaKq~Sp zxdxg>%wuaLAKlz0m^4b3tjRu9=gr>DFlv0hw#QlOTqw9=GYb#0{yY0+Bg||V49XG#K0xRMKkIr%dDyiZ|-jtsv zYNNq!%H1+q%HKrDZeW_G#80JssKJ=t+j9QCh-;hpX`g%e!F-s4-VYP)FDM!k)PllTzhnx_%eMtpw~cHaXCJhs z=?XqXJzwL;=%nB51S&y&ONm-POesO5t)f+Sc-~PqGPXe9d}-1OBSLul8h+tCBl%-w zkufehQ&C%mW$Q735x1sSB^$#+jd7cfQ)H4`K7g*Efr!~l)WMZ#z|`3%WwFQh6=slI zcPJ^js)X$yvIoeeKMVSzE5oKW;6=M@bF=Qdu3j&{NztPs4ghcTfomBZn`=xz^%e8r z1+|ISH~Jimm%58T)p9A#s;VZ4Dl_`6$y7PR$*b(Ax#@-Ng|0hJ{zzErOYRonwtsJo zwKTZg@Mi)N9Ju>25c}}&hZX-dQIx)KLGtj6KQpam-9xCcu0G32^=+yLG~6s*t=;j= zY?I1AH_gi9ndz6K#a|?bNt1xNzXIf{{XTap%~s|wqwmBkqJ$w9R@>Ut3X9I&or{F3 zqp`N|zfOXe;>AKUqEVR+2lrQ*%`tpd{b`Z0m~A^o^rtV{!GKJ1VS&3VTiK9PzCa@# zETWhSC$x8sLce``r=Xj!C7-9G&C?lS{pS1mjrDBy&UT7Lzab+gaFbiHJrr6{->30o zgRDABF`iFSUuwBKUe%U~Jw0MJn?cM|zIcuzK~h_yH47-C2g@|6Q*Q^rPf!UlkegQl zB8NiXZhr=W(FxBoYv%L~82mou`P5b1=6>?t3fpbpraA78E!Fu=B0nuu$Ra8_ReXD6 zY8#f$q*PS{4l;pW)c*QcbZ1Lte7&aEfTJk%5w0%`VIqU-{EYnYi30o3n8n#Rin}3A zZ?dR;+G!_VX21EsoW(4hx$!NTq?Y?5)nnrikz|ytwiRlly@{QGEJ@EX_Q#taBTaJc zf9sb2&(BS0+z%YIVd|8FGK-~f7u|Mx0z8s%ay3^*j_}=Or6au}@5HPBHrR;o_6NJ^ zhS_pE6mv(uHb-KcK9yRwk~Py{;Hhh{khQqrP`>C!T&-Wd$p;D)$zG{!?L|RB2e&Yb zz9fCyLs8ZIarVc`YRg@*Lp}T3y0ZbCRYDGH)7Nd|#xmv2dpq{7)fcew92R&AdIL8-hl~4d?W~8?AKu{frNjc*_rTCtq76 z?8Aq=e!Y4n3x!5&1@B6a#v;*dmHAmL$^{_ z#L-Bo*WZBp{!73_#&)~zPmul3E%`6E14+eu80%8LID=?(s3LwyKlm=m%`2b)jVy(e zLS8_d9G)5jj%>w(#)nD=yy5NJw>2B!U|m&*{%^b)-OD;WYMl}a@!Xd1glJY`RC93T zx3)`s(!#W|8K0_1O1}fIe2HGXsKr9$Bh4}3jb+y!3N=`7G$_#>ZecrJu(MYR`b3LR10e(l9U6P0+?G zg~SQjfR8vt)}v9-{L#%3*Eyt}SZ%M^UVEV4Yr4w6hZ~ zj8plG+8Pg(E_0Lej>=o@Y{@&uLsa<3vTib|3g3#gUF>r8}(t{Z(QEqRN+1S}NfWE{FcHN>(N=6Od^bxCPWN`rS zom9VxTXjM$S?Bt_{4Yaaj&s9~<58 zO%2IX;>e`^+7dUlo$ODp6`{vL!1uGy#4rKDy{K0jpyLDN48P|cRz)ftP#sE z&)NebwMx>{m#j)=wVNyq@`m63M;%K6MlUo|a+&=nB9)3X%brtgOYh+C0`qy2ay2U-mlA9B)YePBkz$FUCqkt{oNcq+~Z8B&}jX|s+9+%t;4RY1Q=iO98zz>ua&vXIwgaw*!Ml zPFti#Ewv3>yvSCgzO;l|oS)Cl{bEpfg-Wcjt@?_FJs6X)x?F{fulZc{K!EigM(K((y=vl^n5VNO^>4kkNuFB!L>-M<9cBG8k7O$j_~!<<^`X&^AV3v; zQEJuC9Zo;{5V)BX1$5}BM?$uZ1Ue5UH!y5!nx`(y<-ox-3ZB9PL3JnBv_x=vwqA~> z#w_GE3$wXw{id0%&4zKFk*?P&HK*-Aw@A;=lu#fCbUU()r-k?cK3kP2;kEV^x8Kfg zJuv^a%DITzq|QqnxV*J#vp;&e@x2-l`gIY$2aQ1GqGbBBH38)S=6J=?!^BQe=*`7*_=?aBt#Yn07XN~9He3#=ek>ilvUhg6D3>M zE~D!ms{PSY$@Y(Ri<0`@Vl7e>_@ZKBEDUr2l@rL_O4-gjm+r6`P2~R=HZJnHfMzsL zOHr6F>3>C?sf6AYHfht`q`3Dq4R5IG`I3|KzHX5bb)3pz5y_knR^m*Tg|!Oz_#Z_{ zy6j4L&OVWvcsr5aPm)*z!#GKLuq=Wl+n=p+@2vP8>|=*2A*_mhK>)YlC)Mh6tvQW>jSM~N^nntnCYgdFwk`wHvyyp zlfaUPf8U0G4FpPH>iNI_`A6aVubTAVKa!px2kN`X()8V)&L?%P67Cx@RWO5wxsYhq zippEJZan~M$zW&&U+(M14+gEK4Pbqs_5xtYfWt(%qf7F*U&;p&jXOQu)ES3XU$}Ik z79S(a!pz(?Rbgv6x*=Hlrdf8f&JwmYTdNAl`kK$04kW&I8aLh6%1}psVdQuo96s_H zC!F~lTltqT-j3xMD1x+^HmuLy0K`-h5o({6tRIT;+{>?&_SVc*Lq!?&MDkoqeXi3U zWZmJ^lb))y<^rw_L<92CgwUj1z=cZ%%$|moJA!q66_3x5K$TSGO~YL1>ouj*gXLi| z^8C9+93N~vy|iinfwFy2&#y$sZj2YRvM9zm;dB`#!O_;5sckQK| z`lTHV4AY5iHCKNE;@WZ}`0ukVeRj)ORH5a+FY3S0O#k|pkLB-UJ1^rdWnd#u@!B9$ zJ61^olqa+#^I9y)bE~dcj+_^=vKt=_=%RtP`1V0;EwJ0UjpuZfX%@KOQ6&pLlI7^j zPfHSU)zdE5sqx2^DEQGy$*lAfUpDP~z4`Q&&0MzZW*k=S_1IPokZOf16bm$K9-0!&+mRYnxAU)Tm|psc%}U^%{M{}kPQ|J3?YCfx?%DIiaK69Xe>9f)U~8R$Ho zNi|i@)-;S_9!7uynBsAK9XVZT%>uwA#G8hlYqiIU#-N2i494oht-iN>6#q{>^S@=D z0H@oZKVL~X-#Q03f#0bEPP={d&ODtX@JO-`+`WviB#0G-W)2D8`E^sZ5=3P=Tb2!E zpW%Fk?4AUG#a!)*=_F38s;I;P7f$8FbhelKCVVY$C-Rj_JL$*PPw%Z3V(c;S8Nk@l z9jz+CBrh*-@Dj#)JCaUBOI$?7nriFiO-LJg`MKgIfMqiLDjDD}9vM74x}B8%F=nly z*(uxc{lPaF;vS^>vxFxK#IvSs@rZfs9CUjGzF`Uw7hZEPTz%S74+!^^koE5`bNxbx zmG;u7gFkf2q<-WfbD6|SG$)%w`R|aZ>+BZeXy81??V!!a7oHN<9Y2%fH$DEO~K^qNUn2q zPpYscqyN_L_slFBA<=;B^nlBtG8+AbwT6|M*=rIypx=ueSIx8Jvwak-IP&UUmT!SV zJ^nQ@DFr|-8b5c9(Wu(HISH?M&yn4j9iIW`IhZa>B4_y2Zo#on)KG_SYeoMeZgby) zhDBe7-id1qr~fWw{ky5vWu=F9xO(I~C2IofB{rS%Tmc&|#f?#Mf*qj6@ZRD0lMeDo zE?`XsG_^ne^PSf}U-;LY;r_hAJdg;AXg;moPG;32&A0CC445kQoYubhELxC*fr@H* zR`x=<&ZjH&Ei3^$OoXY1ss2>Ix-RxVJNS>2G}byG~2+`Sp6;44zXbtCN~s z4&5Ut`TCqcrUv66-0mJ^nDSNc-9MuL>&b6@qfW-z+5`s~{IOL}l09AgZD0(Ljyg|S zo-ev+iY(AL))H95uE5j3SLl5N(7$kt`TU0&|49LVC*l7V=4a{P{;vxGV0eYLGR4XZ zN_~OUHOTr93(QNLeLUL7qUSCkIFwp+{;<3Snunwxju;nyswUG2e8lLtM0Psgtjer* z(d=-!n-vJ2ht0u|2lc)vH%&w8py*$KaJbZf1$})Ue8X0d<8^6LE3gaFkFTsf61gjMHb)NEq60_lK}(*-P!&e zR-0=axwdw9d*bz#ky)A?)yv0ZXsiB|vKS6tUUf#l^5ULUVp!I+ys?*sMJ52DWo<@s zxdD?F6eC;u>0r6r__}_T1Aeq0e8u#m%Xy)j~p{KKz>x`fR z2i>R0RbShjT8idvFWB^s+E)A=fx5|B!aYI@-EZFJw_2$W)4{V{zQGt{_A(3}u8D2} z1Or$44C=X|w1zu8COrauoYH7Ctpk3jCusT4K^6nrHr2y#X#YJplkT{ho5@JatpD(f zu7*n~#szQDqtUIUH1%@)^1<1Q$yaWipY`X2ES!YQ=Q@2Je<&);L)NxFY{mTG0q^`p z?nsbS5la#2r?pI=CK^hq&oK}t0Q*MN9Xh_!9gA`&CF}%kqwd7?S@pZQh`Bz zh-DYa<6>b$SDvq2wmRz*CH0G0MZ~cJeU30{B0GZn)L6)ViW8`YJLG#V>mdIEX#Xeh z168#DQ1|T*o`U|&^3J0eO4g&D!~+|nuniL)=i=ktxip>{j5cXIcoun>rx~F9b1j^D zoN6xj(zqxb(eETSw^1o}2W#nsZ@QqH$k+xc*Z)A_?l0+mAg~T}Z5%k@ZtE^09zD3- zj~jEdG|@Y|{4&*Szo&! zc{BqQl7mKk^Nd0QjVv!e^$fY|+Bio`AY zo*lAe7&JGw!Lz~9LS%Qp8;|FClLPQ}#&b2UGvNd+yf z7-nYXZw~J55yl!Yo$JF-IW81z_yT+|ipJDubdepFX~83ILxwQ-T*= zW(u8n=j!~c4O1-}xt_R;yrbnkpx;)|$yGA}l;4>_(+b_v`@klgw>cNK1imK?7TS)I zKC|FJYK0==4fa1qopTt?m$GaPm9jVujV*QGME)Se&2QfUfp{_+7@01bdN)o#{NPd# zXnrIGCcej8!&4qI;hR=Fqvek)J^(!>r8v^{N5$f9kB^{XF2--xRoi)jrvKW7As(ru zo0G5X&<=cSDB;;LkKlbzB+z=1N7-m6w;5O8WOZHb^@MYxdLkI6v0IKWfHILMae6jr z?yp7nVxRZ!f~ZzWpN(MqH~jJ31wU%Fiw2{bYfM@$5zJB7<|5PgU3(|*0Y5r#AdLno z!WsjW8g2oH=&O#5Ke1k!qpvFsa6{Nq6=TJQ*n3oEHj+#lz#@f(_R~eHY1-c|%h(KT z6Y!VC^`1*-#2aGO6Quodc(K=|^j4 z`1r&OEJ0)LrnD(Mpyhxz=;}I4&`hnT=_|(Q>Y|@GR*QAgEuXsUXg}-q$VVTq?oMSz z>ooOP55KLp8{HCdt$VMDIXWu0 zWKRbZ_!N~sy$Vr!#Ww`5?%PX`W4?RQ0@`Hv^-F9+4DghAz^%u|`7vXk(A(2IfT3wP zl%=Cyda~4V<}%K49dn%?AUM|ys&;q^7XYrEMMYyLqYcv<%O5%u99vIC*ZKu*S=c%| z*xxn^fKMK8Yq+lTup#}04J5%t1~%>d%wwN9Qbxc-Wjksy9&cu_Nx5Vi0BF7n2tt2| zgIV0#+B;f2>d6^S_UG6zvpu4vdGbI!@zD9ou6VwmwloWyTA&aDhojW5eH(P`bc-oo z)dT_8_)gIt!4ThTSeczbKV|B<1 zRV`$r#X`{u$oWg1v5E(losaatkbs~ToCs={ia~kVt=P{NBEGyT3tx1rCCJb=&rgAf zd-Fi8!hmYq1J~e|;K3PCbjdBcRQz%6-?IP|I1S-V?hpL*T#mQ&C(FIV#^So-d2-IS zP0&VbJb&nb%Ksg(ZSPK9Y%5`;`}qOIqS18kG27qNU2GRWiT;)hnXK|h=!h7ZTk~Fe zIe_$cjGm*AdXApbl8@VLN5`#~VB z)G{;7a{SAwR!P*-;#q0hJ~<-IZ{f313+V$IRJoM0O7Ab-07*VX&l2A!0VajoM??t9 z(t5JA#-nZ&u)Dy5E^0RH)Px;6zdoigM43qAx6OW);aNo3bLFyD*5x8h!gA0-wqGD{}XOjC;0x zbk4>{{oDN3(7DQtjEt;OUNw$8IWCsnuLd8!EYFvm?8lllw<*U-c>86jSN6pS+I~6O z7}aDQvEsFPVhZKGuWzNGG!iz<7IxQ`WJJ1?c(ldE-Wj}eB)yBxs!H|ueCwXk|BWnY z#ig%3y$1QAVk{&i#AT^NT^|rX5`-NCzA#|x>(Gtu2R*Jl9cOJb>cS~S6rH;z;X{z0 zZ4blLxzwK40JShUSKsBd2YM{kD%twK5d*(K#gak8I4>$4@~&$NvD71CN8JpWlQP31x-J2sft zK};UXa8b*h;9Q*ye#dd)3Q6j(ZDq%3q@SUUK?#R$QO8~LpFik0*LLM4WU4GTZ$JU% zlT9z}{bFA9CGvbU5kf%LVp7CBxjVl$8nZw0A17e8^Mq#HGzImmc8WJ?o}M26f2_TA zT$S7QJ*JEgnpx3+b-=iGDk`+nYk z&OsFR^Q<-3j4{U?Yv)j~w^m)fHJYg~C}p5?q|zpi;%Ln#2Qj@K7ZiX>`Sp+bON`0U zm<)>aA&(C6y_>(cM^>5brh930aM z(!Dn)e25s~O(}3gYoK)#T(H=%b5=s+Gw+0d6fm3|c!yGHH!Izc+eP%2&)Sj**xpy@ zO;&HEL;WN5OJ|MSx^65wNA&VdT%f%3sg|xeH7HZQ6B2E)7iXBPHqPu@c6Nfqcz>u| zwN>E}zyK*eXU#*~2O9bJf2MkW+&I!_NeOIeSkh}_meSKVsNLJ$+H1jLw z+|d=C-(#5QRdNY_X1jH?=AK-ZO5H@j(d2% z;{D#68skquaZ@jsznJg)N4jCnG?7}wu;yT7R9aMMa##Zz z9P*1GGgw^KlgDAUso-8=9)cXNFN_weSJ3n=Al)s3s)qI(YyKgXTCiTMgJfV}V3D^$|5sGf%p+~v92nRKyI;7GUK%SB zR@oi~5c4o8(p4V~R2Jy2F=u((1V<4HqvmXb>LL$i@jY$`D=*^Ajy7JsC$ZA`ds+7k zekV3Q2X`j(Ie##gCv<*#R6Mw4gX^>qY0INMS4{PUJ;OpEo#1H7aaEI;dQ|JGRrHtY z5)Afj48QgR>uVtrJbEBUOcqmUX^dubkb|$PT7 zXut7Q0aQDJF9p0bIftOUZTZCezF4ylxyWMBL~*NGC+J37M&0+0`z7avv}El=#$ z=2e?P>Ei&S_rr1@vT9qK$a$~-=*N)6x8R3x0D_;`o(XiY&N|I;K$N~@D(o$_>BQ?` zhzg)9`z}JVWUb1_9^L{ZAtw!fv>*5eV9efk95RjT`-1`hZ?4ttE~>;u-ARupPQQtp zecPH7n}c%X-0bK^lb6tD0*lq!h#*+?Gz{#`G9cL^1mILqyVQHewS1h$FP>$)A=Anh z>$J`r!DQ&WZvaZ|(1p%OgXZfNFJxqrSx<~O*2tcyRq>o#sGCBksP>uHVL~o|8{UxA zgtM8CpakP^$%^l^?4CbQbUZ(j2mTa}keIk#kGm%LYIp5w=kk4Fdtdo6bHKAhU#OE%vQ(2Q7QGs?{>^yt@%mhc4A?% zyc1$fqhZ@weQ$oac`rsHPZNM4^W|5m^s$0=7Q1nFy+W&=3l#Up4^BP^dD)~G>l@0C-BUX$q?!)(s6%0&woW6D8KU=hg2dTltwY;4mN#IyDJqB~o|+X{ z!^C@((%z->A2*t*_#{G77B;$O8)a__ooE{utHd&uE@hjy@Ec;0X0LEm-)zT=sC|*p zK2Lf}v(iTR=;d%_%t?w)`h%bNl?DKJ{XUKt)BY+f{=qo;&+v$=at+%yK8*hcH8h54 zYHfrl7A_t~YahOo+-|EIENYj%fgZb@#UUV2`QjEVWNQCv_x{1lTkeyN^6)js6#ziA zEsCw2D%9&10({eUFq}8`R<)D@+8-2GyVFUE#< zx^!u%uE0c<8iiaxVI=}kWzWQM7OZ;U{@TdP=Ihk{>=vRNM1~#3R3_|e}z^h@u(Io>y2`3R`bXfTT>&Uub>n(|cGQNG3!n%c{0eSV6Uq z$*@Q$6-a@Jd!cu1ya&ksY;SdQb-{5r-AE&t%W`fFW5|QfFS3kh4d+w6ojsykGiBU6 zur^i}M)A=`BVDOFIOpG9P~PxfZm)?1{$yRiJVSW_*^eW!SRXju@Ynz0bVtCRSFDl7 z$p&_UDbHj_tK%Aws7X@U1PK`prtDy?KX)vFPVVcuK6vxPvzCCNHBVz}I>diy#Q*08 z|7s5+Jahx3V!Jg+7@I-Y(XEGb)BLgiAlz}ueDd2B1CwMSP#6RN>di^@EJ(ZSgdmX= z-&i=San6#XOa=pAY$NMmpG~%5#ZHJM#Ktbv%4nMnHnwE3 zHKiwEo-?n5lL-x6U$0wEO>vb2g%>O@vVpt{VH-OBvFQf>IglO)E>MoP0{$Ya5<_*p z4+ca36Fep+#begpa1lUmbilH4g9c=c27aNwQRUXtquR1tbgI_S-t!}v?1zkiowZwa zah1tMjbplv+X?$F-4`YqqT`NYwh(f?F6g&>+wiJTrSKlWr*D=Vy? z3}G8I4(GtZ*4$o`RgiJ}?(F+<_=s1wEZNZAJzKBTjCpdaG=hvv?IP`ZOV))GcmLkI z(mtwN4~3(wW3(GQd`5WFxr)F*vfL*C%^>SamugoFmZ zHdUL0;9a77(k>0Q=WpecH-g093%yIGq0{eGFsUvM5KG7RSh;DO8GrlRAS>}32QbK* z1|4Nw6FbY@w*okJhTixlpzpM&-e6UxH6;R^q;d^aa^0a~btMhKh#yY;?gXHnA#9up zulxyu0QCuDH>DQP6vG89jD1V1=Dzmp)#s_SCcR^`To=&k zlk>A<`>M_tiqmHY6M@l@u&L6^+3*iaHoov;Z;VbR)xR}x3i&vFkkXFnMY>J{Kc7{f z5-ald*26$BHq{j*`<$pm2|~8r(I;-FGHJJiD&Wad0cDb4IdLMMjuq*{7YBQ?fgu-TgUY_xY^kC^-UtiYn;!H< z^56v4QXS9YYPecD31%N_)s>&Yv752qaf^8XBb%*O9hvSnUeN~tl=&sSxUeUx4lTQA zg>$_r!WCI+4{cV5%#KdqC-lyhgi^cIe_9DGKS>O92nIxCRe>SO6Wv_}{ei@s4<}oC zl7oyDK^5ogkdwzpwQ5d0gi<{*{kS7GlV}t@Jv|j!WgiOrsw2mbr5NMXRnOvA03qvO z+{Q1fQEV~mgfs+Kl5lw#2od%@lBem8VOcD@FfiG@z-c<0BU|-2ilEjB5 z0qjT|>jLlzfL)zT5J7M3F)N#^qF8>mKbjmntyUtUPYPOdpFeBU1=^$N>!{2ShBC~~#>d-u^6mNJ*T z=j%GJJ{qKnBNKO$KJ}DFKg>OqNVI#T$hHP%WVxU@z}B5;L=hbEikQ z%)f4G43`*J8Vzrko>t5;@ZZM~FG)ep3QT=^O)ma|rn}u2i=~)c5>&BE7yZZuKnTNp zbLuhwbCTxDEe9rth3wC&RWDCHX68WJS^lyrYhzhq(Qk?4V1m%JcF&_vv_~&D{{gAg z;mAXVIB4>5u7=bG^v(%me3G9)2R?jJ%6Zvt9Jd7ECkCFU^9VR$ug<4FxOg(-xT?PK zYGTCYdJRGC`Ee9~EAu%RKj|k2sdn#fiE@fkai$c6u^Sb1>chi2&0=NUJ0Q=bV)Vr0 z*eOR#H~e#&%B?{xEHIxp@be2>9y?ntJo{uWpMIVl7+V&32a}NmHr7RP!g+o%KE%^3)NDAm_WsBEN^wJxP9}iFTnK`Ko4`hL4%kO&j@J0ls=24C<2Ba^Ucu5 z&uY}|HC(e5@_WrDr=C37qYgn3BwR*gTTqr93>A%}4OOD^sbzW8>bjxg07e*j#5eWUiU!K%>;^YKyRx0;3o5Jn8r z0GTx7z|5O_wh6IXtSy{MoxL0kr|Q2iKCJ|0%=|~ApZ5HT4-}vph2;7OKOv!iEkM8j zh3hz+`C=EL7N1|R7>J7ir(`v8Ha9QOy5fUw^m1=E^O>s@;_{6>LAIr{kT7-s!AaZp ztkm?Y`^$7dnMf@suQ1TQU~!R~Jpg3FU*C8deK1)3n81x$djSB4w>&I(c&x&J`I-`l z_TkY{F@F8)G@r4GJyXd%PI1t0@YSZ{egEKwrL0lqnS0X}eeqO?ODL-lB$;x7nzi;JxZeM1(+Aj*S%Vyc|z9ZjSmh5wBIW(%QRQeoc zL7<{h{UmnfX5P|kO*{sJ->Yp+YMv=;Y3A=f$qVOwg7z34_GupFcFM+ z0N*@0xQnuML{kokRd}Y=M-_QXj#3j&7v;+Rt_m^wJE@e+8lQ3fO|Bky)V_ga%8Hii zEW-d_6>)4R;ke+4`JnRg9QhU$GgloQ;2OOnTMJs#*NxA5mGfI#1deuR4?x?UxtiCm z`aGwUKJamz69F2vZS1>Fqa^5J3#%7296=x(6+q=?yEA>7>!8#V+&Y5pbl6~dpaG`p z){ee}n?kEXw)c>N7^US9>0?Yh3*QOY7nwfZ{%h}k;SlavftaBi8;IYHyDNF;VQD41M+U}IF? z3y$yhg_KgMK|=AG099P%NR^_r-Q4u`;*x1U+&$%!?f2qXUi{FRkK$I2!EA^(KEhRX zgfDtL0CvTvGZ>q@CB~07YO}hjs8~bsZYQ6LZcTYWFV2~SVN!+R>lIC(s}X>6@a*gI z{L0M`Ds?fBM<=*=pDwsDB5C!F2q+#AfpfiYRhS#9mj;UTUBb#}^}KV}KF4&~ERR!K zlWZ`4NZuJ(=O}x&GMb;nGah!N35vOg-frX#P%qRrJwmnSTqRoVM+4x57-lO0HsKYE z&PWEYimsb;0LVlWuqXkI>TkJR!sDrn+ShMAZWTjq^nY43AVT501?zpxJm{x_d=A#B zF)#tHC(k<(6WaX2cOz@UJz44__CEGJtADD~5iU7lcY-JhtNqDJ>c&GbB||_G`yxBm zo5^m*FEN(Wq&WjRlGZ4&-BV#DTuWE0(39x<84rx-KdDG_p(AM+U4r_Et{7(f%8Kp* zX;_WL?wH4b#A^|(m}7vFQxHaRq)P6K3+G748Fw#^2!5o7jS=v-CvPoksqdkmgeC>D zq!Z{bV?Lyij=S#AdVQms5~!QhOA!jMB;-z?pDeeBn&2BxA52(;eK^v6lhVp2RiYo} zKv%g-s7^F-YhlN*Z3AG6fMD~%AuldPv+<_>!=pWicGS)}&y8jQ5at~#RCCE4+m;SK zt(sSf-ogMC(>m1Lk8(l%>;gH)939`WD!RG|yLCdV3Fv^Ha@9Of*Rw`D z4LJ96$tHoPOis&iQ1m$KZu>%3tyMZtViMn-5Kv|cmo!+tCCqjv?|;%fS`&(*d>T$|h#$M7y!m`ZC=bE3(CYzR&0fVg;I-{eau_h()eCjnQ;R!y z;dKB^dwj4$UVBj@vA#Mm?=ch1jpYN!usovQ*1yD%-F+J_`$2s7J3#;7mUOT0WicMD z!LR03s9|5!YWHezhFxY>A{;oJKIxH4cy3o)B9Ew0o5gRV_|!BN2z|8WjowN1A$8AB z;ums*Iik#v10a|7MD&vRq|Yw|YSzG)^du8)i7XuyRQbbUfC|RNNto zpwsf+b()cE55hH5o%Ulh7^jCGAM!`OXMwY8#nqUj)uY8!L~$cU`lJBHe(JTop$S68 z1zF+Gt0@ss0EGCaEjBUsx0}Wpena7mXAA+qobf66->c$Z@l(R!syIYm5SlzMpW*=xX*p6TC zzY_5zX5-aul_=IFVtrd-cMQiTsuh+Eta>yBi!JL$CCxkiQ-h7S7MbB;vaa5j6Iou$ zgwE`Rp4YSll<^ZDzoN5R>hFO7!O9DOB0m-!u_AEX3UZA(`GDR}cb>M9qSd_1R(%9` z8lb_sk;b-grVAMSrp=Lqa590cA#l$TRq|%)gL?Bq;x7bM_0kPncxyYX2sYc|CL=$i z9@E`))D8me&#pkts$!3hB*Nr$q>h7KZ0-qpb7ZNY~EUIv8(Q13?|j z3;6z|lbAud+Xdp_G;)<-q~H0G{=zBLMP3v@NB z79c9tc!7UIur*M-gW>$5f^<0Cj1W(;ORWmm?BOh!#o#-^5mb}Es}6#k8!k%O(LzO{ z8gvK*Adt*zh6CAF1oT4UA+j*n=niuMb+9+_Xe|)PckVuTNWanq_$JVj?cm_lk3zZT zxo$@|B@|*uCKk6J=iBo;FfC81DWZ#A$c?RQtrK@@0~rQW?3diwUT1(HX~msB22BS- zAT<;^?y%p+adC=o_Hbq{@!3mNCcRE{d;quvfjVE17hh1!9eyuGZF~d(bEu^l)ps8* zTHCdK0=(BWN09>u!05q*bvprPo5L@T1Wexr#qd@3(x8yZNGO#UJuy})4(9VQxfdeA zr?)(i^SOF+I3bZ&Y&h87p=j~JVu)&qeUOZB?<5-xJzK7R z5o0bgT`W6CpsL<21sG}c_7?v}>#QFeg}NhCN10~?J}6nhVu@A~>m%|lYDp)H1( z6d zdrIXQ2kph-{T4n-V;=(y+s7#@Q5&HbSk@_BrDI&rtEF!lv0Bkdd!}$ORnKh>lYrJ;rfj`I;guwV~vVCzTUu}(`P${LEeI%ws`dQvZzu>A;D?Y%##or6FD6wr9jV9o0frqh$_M!v=3;L4dsi1{zpQ4} z%NT|fiK_OURU230YlJiVMl(7;2Mn6EGGDnx<-NR}B%iq#J$x`dV8BhVCy_TU) zLWVR0%yp=+?t0ryH?^SpmtSScO6? zzKV$GV@3q}S+fcY)2T4nCSeZ6uU3Vv*=sA787plz0w7s~EdY_$27+bjzPALFht^#2Bj zj-`gRx8v9Zgnwk-obsRws*w6?j^y7X+@VUHY{8x4$MWqeT{eMJv(HtcjlhU}HcO3s zm#$U7RKp`XWHTl~^F~79oaZUYn#0@owN4wh4ZRp0qo%lv*OZ2rF_3>a-ZvF>oA;9D zeCjvC>9r0&QYC|7=u|Vfe-vJSYqN45-r#wo9`y1LO13L7$1r40o{ui}SV_4H2#5&F zMJN*0@qS+2`+~s@pWoMju3Vu9Pst4K*BH&eygv9~|0(>_eNswj&G(zCMF9Ur@}`77 zD`p<;``j-1_K08q-V_enlPOjLfJE^O!tZ{S+a~h6o3%tec)$BVDBhiWgYmut0Y)p^ z2YH@?Ee`#(3rgvB!R-K4PoU@nP59yb>TD)2NN`-XBfxO2#7&grNnt0ky=tII5J;mZ zRx8I$N-8*zbJA`Z<(3|$pMvn4&f1l$wO~yeO#6-gc~k1I7ykL=bwX~p`RwlyFR@@o zSrQyQWiJ<+3Md$BJC>yXVG+sVK&x zudieTTKytgzkM{fO_X;Clc_z_KR^H9H_c4~wZ*?)midlNj(NyT!}u@ZRX2&d@4S3_ zG8e!=SMiJ^$}Uu<&^r?h+{$5c{fn9WGS|DY(0)btnNZe0mg4V^;`R|`^T`(fkolLV z-=Ee9U!v^QNH4}$#oX>M`rT?=snu5%PYP8^*sNp;`{zC zZ$dbMURBXZp~<*96aa&A2SBnYsAe}$CnzRHPYI2*Ql%zXeT*=_dna5+z@olaa}xU3 z`04K!3_PF)zQo5DSAoKYznp^__F?2VRablDd^Zj?fTij_< z(5v1Y%PYG1*9ZLeD+CrdAh7nbbg6Q~1zd*W$a4pqIl0Sa5!~8+A}y{J7|x2Kj?c`1wybTW-*n@5w<~{nph$;7}lPVnPY3l?Dd3 z^Y-mCV1oh!wSflD{8r6bigBe_fyQiV|NC)gUoWpvx4f#a^d4`2{;Gdlrx!?WBo07I z;SXdLb8?LhRR1b zdERJ5z5GS~{9i7B0f8~l@sWjU_2fqwnjiLrOL7elA0Jd(?7<+#xvq02ld?RJL5Wj! zVf|&m&-;Hy%!}>`rz?DtCbuv-qSU@tbK@qYU6JBR5kTh6*BCUcv~PSZznl2AP?cQn zqecFUdNsGZcX{=>{2wsisCF;nMAtvDPOJ~6l(uGIsl2c=HMOo~TI@JD({(lKh=!)l zRaBhrNE=ZWJilG>%gz=S=Gil>xoR|1VO!fWH?mkkEY?HJqpv{YnS!E&=6XEYk4W^d zD;q9_`#HE!xA$KyBK0l_>64JXm6Fzx4$1Ivt@JhC~X0c25?i9>yg_mMAs@t0{ZMgJcRH@?NL%^G4#v6B1)r(2R7=!)}kJi11frZ}oPJ2WW>$Y|sgPsgelKN7;hNroWa6pFMR%t` zd%ErU;1_lEb6-gYwR`gN=o1G&gaA?H@w!A|E%m&;l+MY6 z>lQ^W{aeMqQ(oN9>ZqTXzn|r&?_IasKMT#m%_q>(GTPxyF7-DwG#L5dGq-;XaMD># zaZgd)@erFG_HwO+i#^#8DWyI5dbdwX&Edp3t&?qxPH*|rp_sbi_MwQvkN&Vlpnd;y zkzvY08w$ppJ6fR*SDUGnl*)1m6Uj!*!=cExB!0v=5s$T=uq)?QniUZcHI}bEDpq`F zC{pQcX6AKTC2=9DY~iB7=Z>_tstx@FBILu$R?k+VF3X=w;mHXInse>l907hD=Q3)} zR*~RV6&ZdMC(_4{>&+X>+I6cH_3y z_U`OPd)-87%LUt^FD6@{)(pxsD;flnxFWXMwGBjZI%ekQsol{Od&~S7xxAM@#6TEV z%PN!~DJEil_!v9pRK2lJ-ZV7piJe03qZ5CEZngD+1#HNoy2LA?n$ulkG?r!O^0g*C z9mDksX#xoFmq=inR&F6{ci^t`6k@8YpsY3D&)dprxZwRhpt5? zkhY|(bm=tT)15emFr1B}#n{AhPyS-k-%LEax*&X+YT)R?&-#|yBiwZh43s+hdZvxm zf1$p8#_%3i8$!`H3d3T9>3>2jV%4sp9!<~1Y@@#zs`TKJKtp^9T#5EZ=vdGZHj5)) zq#cIi9A-DTAnSy#eD}5Bd?zWJ$6Wm8Yddo7xv9$e{MJ5R{ZowrA48Fo2;m6BY_8^- zY8L@r`;vXwAh-Wa4yJZstP+!SPO(Z`3sN}Rn{jH0<<+g;(i#r*Q|_4brzfbGZ}D!A z6ou(P+ETW^;v?Br1-w%x61(@eMf=AeH*dJzjSIgP^s}ZBg=zEAL0-2N9MClhX!RQ> z;Nn6RJ%YoZ5}4}Gz$>2(UA*md)0}sWh4@2Ij~2XK)f|{@`E>?TQAo%O+t5c(^4#=@ z%bXB}+&^HA9#mH6GTh7+QKdp|s--wNz}jz`#>7r4DiYDHVUb6@2z-)PV-`oS?``pD zC5xr3sVD}mai#f)yqIvC2i9PK!VHGfz)3U{c z!?LqMx?)@jy~qFziBW)@B^bzefH8b89v&Wnq8u&(OUvN?9BF=lXag)PCK;Ft0Q>Zo zCUUaYYP)wLl&s6peph^6MS-C`lyKwu!4#+=dkW41AxEu5ter+xo>+iggveCBXKK5L z)hJ@OealC7>aBW1#KDBi8(M?spU>QmkH}8lSLmeU6}hn0+VQ~7XxnnshbkpFWL`7>^(dEL}9g`_KiM^g{u8JvR`%4l7^HBR!=q9+< z-XvKoz$)&rrd@CQWKitgC`B{cQA}}& z%LA##ZDTT}n9hS;umvWaa;5!=-|qg|hv-Dhj}*dEGD z-i?x%{nC=L6Z%glz~e+L#p+|ukXEJPxTsG=?iZ)ERpzYkGSr4RG}lt-==hp`u^O!( zlL2m>ZSK^rHUr7}UIeRN`FjNGZyK71QLeqAL&FV|1MT*|D^>n_u{s;Lmz&cpi?1WT zFY^n5DL02S!LBV8ZaRTGB5qYORAuz6XKBi&ycr>}O4fkN-1W|J!R# zpvKpGmNqhBf42qf2Zzf$aXO2>u-L3HDQBWn?r0X;T?fL5REGa@>?!;M7AByEGh9lHA=cR(6mOy(w?p6lt3cy8Ra4YA(O0dS*AjY8{)SU%ei(_ z!3dF&wfx%fGQY=L;bcOY*Fr{k?lcfUH8 z#q38=1F7@71}?9ehdgB8n?)(0wYf>cfXse&c;X>!{Z5~>ZzA&L37kmx`Td${FR8yQ z^2$|rn01pHe~@I>quH@nCzr{rR~dWH&7<0$zrbewbRlZ5Tw*-DQjcGjW{In&r8HNf zyK!%RdY#g4pV}rY`P*u`BHbYA;9-8lQ`;} zpfeY{D7)iVR%(=gJKUGe{M(s2AAZLIk@-L4OubPA$SuiY?Th!+Bm9FQ7`IlUEo@N3I_o?>80YX*X1? z)v$|(?X>>C;rEx_do;e;e%&E<5@ey5XV` zhMd{FpgK9wZ=DKq9$C)YS%kJj27PwZbn;OdZj#jcpKq|JhgLh>LCZa!DZ0^HF3i$k zMRMuLx}w3|y!D>IuS1GzVP+n@FBlyZu%fNwQQe_%?d4a2GSN zB(se%@!CkKKeH7+Aq9oZw)w5OuRX{GjRP+=j2Gk-cl$Pg7pE^Bm#H*6v$iuF88qL3 zm9MlCU8g{OIT$WidDI%KznRd1Z?|tA@pMZlNBX;pZ5~-^KCeuzFym2`8$g_cjt-7 z@JFLJLI;H0{Xja@ro16WO>Jdfjl0>@pQqjP3F6^rx%kE`F?wfYqHNvZtgSF512~u2lIN<6Mwm9@jceBw!DvX?DSYa?cTY3qIh4@pa;b8Z7(#sWa|oZ2ioqkU z5ehZ<3;K=Wa!@%)t2v*kfs+3VV#xjp5h*2QUyOL-#m(d=%H(+>5jwVR+#Y)YUPx+& zWp}P!+TN#8oc1HSr6No;Y@?J`tg_lmGoUhqmzfx4Ki}ohnMll-_o4gr={cP!Q>rw% z-Au&jTr*)_`;>))VO#OTY}!%B$DD0NMs_Zb5PEbZ?A;Mw*yo+ z2^TDfMaQ6wgk-j#9#Q|NyijORe4zq-P7`9r_fzFMdhH1ogADu0KH9h_d$73MM1_vu z=prwd#Vt%`TaopeGv0`1S!TP466sBrgCiiqN=>gTn!3b&b6)QI_QxqqN%~JP5 z279PE(yl{3_F?&4onQJt&Yg=0H z9cuB2WH8*LJ)gC|50O3CouFGW)k^8}w>%_Vqz+F`Sqq#s!%dB1A&Fv7>V3UgKaN?E z64un87xw*k3%KMruE4NAd9hlW>Evi8@`GAVbL%s)b>N~}jqt&J`s~qMmN1?RWD-is zph^=Lt~(cIV_#;1#V2FGJ6z-RJFS;Kx4Su96ZTu*o(yOk{>k9k5eK9uNAzL_yr z$X3fC06GfnAw=B84tq;@>fLr~%#Fp*WYWwF8Z`r0)@wvkN6_zhyn&OSNI5#Xm^c-) z@C|^=eTHw3$A4$BWrLgnojegwS^xagw}I0s9GK8;dMw|;Z9Qba$P+A|!4KMS|A-`) z?C+Py{r2V>xLZnvn|`H#W9T`RWNkuV`4{DCevt2R6Mp%6<#BPl~) zHKXxs%k|za39P_NiR&hv+h;Yi%rxy{r%VT3zLI5I2RA#rw*`a_rPZp|%`-uoYrYN! zufS#D7-TuL*{6r?#B2kPUUdu=&O;bBkmuRFCyXbe=L_@>9#=0#?vypr2FVI4_-{uA zhbU%!u$yT-E*{keNp&oVsOYy4;SS?_K5j#6viU0c4@BL6W%bziKo-QWK;(HxCUH-4J=!KEdu8-#;81xjT)NwypS zm_YLPVFqza;aHbs`cB)Et=`pCo2pYA<~kxY9O{^~;3 zzApt-j}uYs$~H9vJh{(uG-YR{7gFW0B(})Ffk%;~;||oL%42HzacXCd_@r_F{1sg< zZUH#pqWc;7H>mMxc^&W^D@ygPL9~Zot}W^bwh>E-SDna8vg?es&}%E;L>x{P^9lK1 zIxDW?w+IL_OcMk0M-<%sb|F!+$~?Th0!EAUnX^9d&ikz};~O{>sPC&zkE?qVb+nTD zBtkeeW`bcn z)TO!q?^bzf#V_Y-aQ!#FOmW8`OMX9W{gIpv9#&T4m?>ilji^H~yu6L(&6M5W>WRBp zno4a&r9?s!cL#$X3tX~QM@e#8zlAh_0V_gIx@*vzb~)OAq7ni zp!+lLv=J=75xTkgG$&(z3wi*zQ${fOzcPnYdVzNv6X(bcoSC^V+S=1|FOfEr)xm#% zq$xw?*8Ms=jjFO=vb? zyz&n7(2Api-Nvg6_d#Z)?YfC@-<-Kf48G-DqvQmy0e5pBeUuX#b{&(A zzPrtl7q_57_!uNQs&)kbm%8JJUz`#FuhnHa0Igyu`$wls~}|LS9&x^s&A_|W#s>n$k_B)B)v)t%#KS|A7ZGhj=q_H#{rIwHqf zHDf#VQ*IVWYT8r1ZjTGJbi;&sc*1krc7mi+vO6-#=B?e$o=Mgw0B7894)9p(h{g?mvm+!ek>+*C?yzidZZ3LCk3{QOL!AP-bE|VwR z#oeL6YJTjh4aA1`B)EeJ68q%?VSDl$poDSbVs;3eZ8J>2n0dLuA z_G(1-UOHO%3~2@-P3B@LHe!4?V)40l&}XR-x{Wo!A4zALPClqAD6dANVv{i`HMrCT ziG0)oHjtcW^L!SDf#pbDcL05n4) zi9kOpC$JCHbjry6sIpD{RSMMxNzg1)l;qjBwIxFSs9@~FmaTI?nf0^Mo(6{WKIyZ| z%l_L_T6qn?PbQMf-~HDVpWEx#(uuUjUn+bs**FbvZV^3Hh$@$bde{V6lDOOFpvX)d zJNg1CVOVZ{j9-43oD zx9_KN^ED^L1gp))0cCPamNoA%QzUiUf=a}f`*&~oE0VZ!UIF0}-&KrW_#0~VG_Gz( z+=&E$*aNg%Uzr1_9kn6iSr8f57kxLPTUvv*O4+GE2=!QSUVPK8vm5T_VelUt9&v3M z;umVY7L}#;yrKypLFlu{4viVH?|bT2J1Z?izV_us<+6$s>@D~4+6=E!IIRrY*KyV~ z6)7m@$Q?bKXP_jV&u`!#zOZ|jWF~)I0Knt22q%}4$UpT)5|rSWLS*2+*KSj)ZiDJ) zQtaW+_;riO5__;b#2T%)>nl-#mJA!cesThWu5S+z4 z+9FpF(Y2SOkO@gzH2+qcxN|-a1g;6{*@B3-WvN(z8Cw~NBl_^kJhZx7X3=~Vl(H5d z2ajGkY7#GARht;auSv?q*VVJjtLKLzVB$gm;?5zR?-V2dF{Cj|1ScL2V7fQwv?QlZl!*vjF7i*O3VI@R)Av!hRNf) zU@{XK(v-FYs?+E!<)LX7D_LQ!bU8E&LLKOQpf_adnI-Oh0<(&Uo?W)#8LYLVP>4X6?R z3VFW^J%8DL`je@{Qq?T}?VnGaiQss4)SbxcsPo>(5Px=~JX5HRLP8S!G z?fXz49o{B~Vs<2Pu_<6_CU3m;AV%y@3t-mwROtr`TY8NE^5m|G_jrKf%k#uu=dP8`K8kO+2t=LZaP#x z`o933KP-YX=n5h`t=YZr5!H3$21y*n*>coH=sKI0qp5jE3qA}$kqFzva?K3afH?!n z^sI5EAk9lzMz}2Q<7aYv{e6A?bnBpPFQ-~4*ar{gb3nHEVEwR}60&$^i4XyfN}ZvR zWM9!n4lkD_E1w&i%vsq~^k_+cvj@;0dOvEYhIyi~XU4TZa=6-B7&eNb`=>fAmuoXo z&{_QQnfF^W?+Q!};obY_6jZg&c^@#@>Pm^4j97_20`82R*0MZy1_x+*RM~S!@uPA{ zUb2cYTz~%uX%%e&4NOoVAZ!bb48x7&Ei}z|WM=mQV536!t&dQgNtcrG}!iN}tz8_XP(QR1F%$n-MXF4|Xl|#QCDr^6Hxl2qO{?y1fdI zK7K=afH$Qm;bw8@pXgwu{&SCYSO~W6^k}t!{@b(HqDq)3*r$l z&&oFNMr-zXZM&gVI1}4n{?fj|#e?jy1Rf(d(gu^oTFQEjHC~CDYj#Waq4izc?Kd^> zIQCKcjNu;29vJkQS(RcM^`ztFO31Kl=+Y}e)C{u5FGI?vlfa(8>??_|TpOqn%8h(T z*qE}d)n;n&-0NFU|EZfRZ+6K7ww&ZSw_W3ocV0-d$j1k%zoj?+^_WD+04Zq|d(`*6 zzgPo|`& z4@n}-B76k`i?gnd0NGG2zg*l-QNe(^9?Io{w6}_MTcbxMl-KiT_t~#cD5|I=W@#%Z zq=Sry=WacR!a!1+8c7rMec!$v z(YkRM;O;VobCnBa!D+#2(kpv;MT#k3C#$J9S>L^l8`~wfw4NW-`OS-|dsp}tEHl4U ziqZlIUfKd1fK%QO16lAkx`*jCvTBv~0sarjK?Y=rBBxc`dqsEpwJco|+Ap8|=OK?X z2OOk+p?g0t0i>ItF^Yd69!@l|BnGXvAWeE&(c2^lpcr z6(Gf+pPls*wyff4rwxcC)aO1ArZsv$y|%$@=${ zE}pNB!39>iv*Y_=1e0QYFv8A)Vek3IsoK)`3Lj71aVwoX~fLEW7Ev9w0XYFJJkX$-_2uuWXQgUO!iN5eurSdR}?*Bb~hLzc2aWOV4v@ zSL;H+ZmHbJ|Dm)JgaF+wdDY{0*$OpX1T?ylH59M#h{Y$I&oI> zS09gY;!;^&ZU#iers&6dp<}#%Nw$v5$jaJZkGd;)_Ea zX9aktGsX=DXif^u?P62%%-}-?8l!_mA`hQO){UI|7RxdLr>C=-WLj019`;Y)j5Eje zcv3=MIe_uU`=`^nfr8(>eNVi#gwny#_zvcf4V=mG;v-3uM}3vm7q!FYn{|@aOLLbn zuonhb=NB%9%1SZ^{ioyW`I`)k5!q*gW@K>sRNbIHP6!3%NX^|(6V{@4CH_`-=mUYl zaPp+!IT`uKbo=CpwjQ@vq_tE!IRR2dBFesN1fuYYApZ4Bh{go20N2dz40)BV?qZ5dat@h5xyBxc+beua~8g z*DtcgBhKS<T-dBzWGJW3Y^kGRY@vX;NK`A$N$SUfhnTut@uSh(fPn{y_+%6iX& zRnU}*j?tW7d?f7BX^E<7qaNoxe*4T0%~_43(n=Nae7nzpl!S4f?8Dh#q2`aj>c5MS z@$A#j0ykU@m&LG@^{C~x-THiGRYmBJCg%3?GbRO5x6qu(7G;`qd_XBoj1B@VJl^tQ ziSGTz?6>Blhr-F+BEoD-YOT{1J2z>r|Kh>HNI`Qds6&-CELF-0j0m7yye-Czr7opH z23pB1XCsgxI7CEV6-aLZ2Oge^@Zr*s7QcA3^E$b>FM7B%k9PQA*gcZLaGD|B5)U*` zPIo#dQfj58yIT|l3F+?c z?ov^ZmROcvi6xd^y5D>C=<%G-bAHeB`j6-$?tbrkX0EyBni+}Rh}4^JK}`b+KqfL3QANTzCVQKJI~AH#9i*5Vcvx>SrO?&+nL%o{y&y`;eTZUsd1C-kK1{^ z@x7s1G83y@Lh6`kovv8jbU|&cn6!E5@v&01MB3ckTxWkPy}{XOYq|co=d&84ffPWqqik!U}{api?YZCF^eO4f(=He7zrrW%l06c803?qoPn?k!$} zn{C(}FG^{fVPoS)gWI*($rXMb{roZ_>+*UX`6qHX7w?yI{h%+SK{0|W2BcR(7pdc+ z-+2;uiT8SDtDw?eIHh;5fQ{kp!1SY0HGRBu|MR5+RF)0kG)cHK|Kxr1&qqGK(z$?f z5o=uN8qaN6xdq=NVo9Qp>Wc+$StpWcnx+&A-%1+Xd{u z%I`T;rYFZSpLz)K4b|q~B^M|^8gyFYNiBFWXoTy4AeFluY1SP!I0Ip)VXb-JZ|OhV z`gqr&R=z7_dSPdfz?1I*Yl(vA3GeE*S@J&v^xtZ~|M@YbKE5d?hyR0l1X@6T%zGLS zqUBpWrHgBw%L^e5axj|aqeHd0?G0R@AZHI1Y2k4Daz}n*#rmU~7Kc`L({UXjFS~Vv zDkwXHWm@h*=ufbA?QgXGE(Jb*g7!9O=H66WB6RM{nl=__yO~F9k`{0mbSk~%vo~jO zKt9e3aWZ~hF@G4ZNipK34D6F2OX_urIlsdLN)uHXT%qeVBDHN4+SH)NLAh$nce=(e z{05cV10+heq`a;o7EgTif5T1x!)Hf-ORVh8F9utN^*^4@`9u->BrqVN2@8z%c?cV8 zIdd&jFH!|?Iqp#^fRU)-ASp1f34C~6-857mKMS~kJs}Gzr;>6cKc6A`r|w6IJbYT1 zyA<%#vTSN}|NnZvZ{#3f-3Z=#&-?@JSOfQVSTQw^ zbF_PU>sf*mFCZ+oF5gXwJlQ?MlbWU5(Po($^Gc)#+@J&L!rhyzdvd5*_pK>+#0%}9 z-=6L_*_%R&;Kp_bnKLjc!Oa!TBCw3@hU-g|w8+cUnKcGG0Zy%OK)Bq_D=qypY%K;q zf&-PKi=K48>BWy+MtCzdL*JVz+!|!rvN<=O9m~`+%y-iK@9EA@=;Qtf&GFk%#a{Sf zwJaCF+{KYi(Nm`t#dv(Ima?Xo&!#RS(&gOf9!I+pnd?9cvJVDszye8z|X#9BSHv;Xl| z%2zl|mE|wutmN@E+kQLb(qw9xt4q0b+&a9CKo$6zX!Hcv1cd<(h!dfFb*j-M$NdP( z8s+c&o2=d&a^-H1V7oGt)i2Fuyrr+e}A>Ls3)T3&-MdR(Fib-*$q zUVH@SbH7%FUMNetdFtwX1Ds zoCyy)wk3YW3+Au_%uwp&qJur6o{0g!xU5q(uKFSm)-H9PfI;U1Z@Zo0C;{BWR$p3- z4z1R7mV1w^x?^+m*9rZ1*!n&Uq!JVz_kWy$Hy+SxdMu5Gj9c>PZczi^M%In*1(^b4N~vQNGCPZ0&p94R}t**6eP%qT_*~z6fJh z?w#W}^cF4rT+AEeACm;PYt@T(6W;KU!R&Bse?O}KbbNpP-TMh}koWfv^mo7ePYgPR z5}n9mq7;F!9HvkjIN_x$SE6d*UXt;g7O8Hw!3je#1trvZ{17Sk)hJdP){?IZxW7pj z@bAABc?c*Y>J*Pkk->q=VVJU(;f6Pdo3wRlGc~c-!P3LLRdThK6BIE>l$7em@^#Pk zJ;~)$xKdK7bIz!-jj^IvUPtjDD=&pLw&o$A0OXo3(xHV~=fk}}5BtvcNyM$1#ho9G zw^jDMtM4Ur8dPDdPjnP4u=1Jxx9S)82mf~@Rlf0ck9<6e=@({gq)X*=`dX3nCGCSh z6*r_C=rSJ@M9kpGuU2oyNCU!QjSnrVXjpeI`Ny%+e9ba*>qmL606}Jabj-WVA|Fr) zXY^ejBa_|@TF+#!Hyufy^f%~WB~`&{I!12Edk%Sg;^+&6B`p_!LLY141B(d;AfYaB zHaRRo^Uy?HusBajX7njt;>37s@u4&0aQ;Q3D@Dowk9QSB6P;jU)Q@LTMPrRJXfD1J z6TNhFU0)mPPRDgjpbm=eDkd>~Q9KAgeiBI@_d7+l;pFq~>+_}ICSQqYQUbk#$ySc4 zswd9hQqLDL8f9R*nw93vY)KkRoesuNQ?>L1M~~--)-tyO zG(eJg@JS%{Jom7p{uj@BSS^r> zF(1%sJ2i)WQqzge;`bOjIA_05&(DTm%=VcX1b~Jm)28Hs%FM!UjpeQ9Ki+Te1t2Lx zi`!8uoo8d&S^joxyZ<}&;@xmz8{WUP7N_uYG_2NT^NG?{!=NT_(2}`|%W>lyCMe*) zW7F?@khwAK)=};SzXrMveh+atnAib8&pVx0?<#w=y~+p@URZ=i^43V}Sp@K+_6y5W z$Heh6$LfF%BpTu3Ga%rh&d6ChKKL1~3Y&Ucl1(ihpkAwVXgHZrA zJ>s0h(QY>xEA%NA2tS5=1Ld%jgMcW$nEZ*xJ>-=XWH;SUaW&eDZC#?FamoH5(ddsv zMsW>n=#57=u_Km3x2ZJjrqJ4&4hc9kQ7tT$lVi!_F)T2q>XZwF0+5qct*zu?gC@B7 zyn&(b*T&k{dtl-YC#N#mgBLe$VD0clKJTkgQ$sPfezL$IUN!X z)H=UmrT>CNy)8jnwwQhA+K+&;ewkT2kpYCEeuu9hw_il-^)7%uHu^ig%T_PaARea? zceznzzunGneVAoQG(ILWYP3#jVxmhA?ajR**Z>*GC>ul$1q4Q? zvTOR{@q1Y@1&f$dhGj92Fw$Rs@}+FHJ*&2MWEksJ%j^kK@A>Z#_&4+W4-a4{TtxC7 zWlZazSKs?01|r5E;^`Uf7nGl7Q%!f({1OjA?edgO`Saw{ghF)r>$S0Pbj?)K+#>$( zK=iq*4@fGPhVMKcP_uFz*8q~@EVG)cmIVtT4*|Unx@2c)9Y!^@5s`Z(gzKIv=(08t zEaVsX70@6^@TT_akDF~X3Kaa09zFC$)RRl_Pe91|k59<>?KfhJg@u7!>cHc~e;`*; zup}{hFZ=AKJlOgaADUGtUq^QY1f;w%=f6V&D{OUf6Vx?=>*oBaMPdHFa!Z@KRj(5n z8KRYPE(=agJUW=lZY+D_dvO0Zb;*B=G^cm)=Y(Z+X@A6{{gU_mR_+DMbg`GiiYNJ% z1rSV_T;~bG^$>}-noNVtNMpH;>k2zY(rI7++KUCEfGmBg`}#_6k!I-Cbk6vVDhr?% zBaMFwNHHxh#H=5gHBbEelpevUcO@H?C=aN??~W%<@-Fixi!zsf09W5{k_c-l?JZDnX)?kSK&qF}g{hkGt$#cwr+ed~j%gQBSRtJcTKX-x7~PZO$pGBraCnc`Q+drWfSo0AED=KS2gNv~>|YEiFwpkIdS$~YnH4OL`h_%V z)t-;M*`j4)8aM1;)#R3ve6#K$@}?_*5EzW#g@FMMgO$&-%21ctDz&-vw{1WBqrSth z_f8jHx;vS6|EOX5yuZQYy8TMv@GF0HLX1w8hji+$d~MO!<+!hkNbX#v&G3>-BSec% zMtDo(Ee(?%rdTm7y^)48>`*_#A7zQ}H_>|uP@)qP-&%WcA5CDp#uQ`Y(i%rFUUw%nQ!NfF^%JJYLt@H2&>T z++1$|i9?UWS@WH6(0Wv?75*JsHZtMUpeuHDs8aPJ6)K`xX`G+kjyyMbJSRrnJldm1 zegB(rT-q-|S19);%72W?p(ZLF0}JMW>=2~52GeL z2PR6J+70e7ZUtJ_BU@{(3>3t%XnXn53j8m zUA0VzE&A$0oX7xRr?pH6uos#~SRpZL+1&#Zi;rAN+?N5LzN02U2fC2&3xHXZ)-L;- z2{^D#Wel&Twi~?=1~4lieiZG4CS5 zI$PNClRG5SKYS6|z1Lre+*1c7542hzS*#eA%NhS>d7^qIOWxB1RX2`kOxu%`D%)+t zff7gB38-Z^Z`U=ltupa$6`m*ME3D}$&-i3-P76BI(b8Mci}nRw5eL1UQb!&YcjazX zTaBwVMW(@=rud%#ZHj5GAAQ8h4w;OUPTE_G#SIR7K#o0)53IC+X*sU*<<@K}Jt6s(#11gobV-mviQZVxMA#0=*`_}`=Ke!j?) zCm{mD2-*XK;RT>DnlBhM(E01^{^cw0zAM(StiF`nbU$k7-kunUh=5W2ueZ@R)lXR5 z9W;9!9B!plQAGbO#@xYAxem50Fv?MMmrimok)b=&aFiVQ+Dl6nTuzd(#14?l2`$?C zEyt0djw~KO3Whk$71|MSEz~jT4lSP6%k(5PX*sb3{Oyf-vtk4K?O6=PFyw{oKXV?(>jB6PZV z=F~Ab3y4a^IW`iVzbEj^A(_3G4tubDRw$$?EV%>f?)11$l zY&7^W2SXFFzxIv){TP2kQO{KYPK@2@X0!U?6cR6ic7-pq@^B>0OYKWMH)wxpNZ4l`t5nf%G}IWG7BgsG;tnSGo5+ve4FvIJ?I+kYjKYWl!W8d zZOqus0SI(7Key7|nNnN^B>x94)9a3^ORg_Q-ZU5c3Xoi*prC1eyp+(|ONnK0GG;-Q zNSXEbw6gEAtji`5soiu|cy#jg=sCro5W zi0mDd1YsL#+Fs zu`h*@H+rbIw)1ZlI=d`YVf!sRS9l9+>v!ESmR{>{*LGhB;d!K%%pdVMgetW`3$=o% zx0Cu#OKosr6qLxlIry1=_@vx8WzNdVq7uG}_1{adm~tTH^l@x^#`-;RgK{tk;(2KXNqU3j_rExN-$KMWSy$>#-?kJP2Q}Jz}q$ zP3|(C{aPOq2eb(sLhkAS?m8e~M&T`5`V4kT4s4FA!#Zdk9}_G|%GZxZ3WToW@D96Z zz^i>PSH0iDoIhge-lC)U?jnEqwqNn?uRp%uyYTX*uZLH7H~z0z-)8}Ea_Q#)wfCHu z0ZlV=uk-d|jhbbLoGyUIV6?;Pia6UVPLt|&K5{!~zekL2QTS)y27v;Z1j#5t_)JhH z%ycc&-|F4B)SE6RP*yVss^Y6tRw7PuLR-4vo-Q#C(eE9#ashoLTJSl9;J5!o3P|## z>r_;93nTIF-9Xq-x4m0*WH8v4l+MYd`@I~$L5s1x)S=9In7gl0_9!5iqyYRLTJonQ zeBVY$n&my(&3Z*6$R(l!KjKuT%MfHSk&#Tz z^QodDjqkq@CSF&-?yZ@1;;3Zt$70H^pOyanRN&KApq*)Wn=;+;5U^PV#sw>Ux_q4Y*{LRcgQJ1yNtVT+Uw}IeVt(ZO>bg?jj zYInUfJ%CMsX8is>`>@qd8H?Oiwl*eYbN@?*7-+nf|JQ}!17njbQ=1S)Imw$m( z?^_seKi?LdXtK|(FkAulZDUE+KVlB+(EzR7(Wxun4bB)PcZccI{kE@%T2vlL z)wqTYB2pV2FII{&CHO>Pj4v0Q_UIiGYv&eT26f~hx3mutZ z89R`1!1uY>n*PWGL36-rHQUxlOy2MG-(S!8A76RPW3Nn@ob9b8aGN2OwhY++dPawU zL`v%mX8v)RO~q4fA`WdobG0ItdHcFzgaw#7>8}H!l8H}(Rf}YL`fVJ_?80tFR)MXu ze3fmY#+yF{Q9T`yM@f380Kl{%jKw4;uU9p&NkLvM|hmAfLLf^vbvl>C>>V%M&G(0?#44k$B%)DQZaVdedt1#qwj zvT(FY_IU5WOR6&w!R|F>1zK&BD1?)jIs2eb5FYk@`%l0**V-@~-$mp;3YrRo-7C|$Rw5vw6IlNvT z-K)^xNc9e+oKN3D?t+Hk^kdtHVg|JUpp6Jw&^$%A$O2WpG?u2)tK!Kq8DMaMi z@HhSAeUM=uWOoPs>&@-HojOrER^{f8@_#npE%2}3_gg^x{+>3f-xofEj0n!X`{270 z!ts}w7u@ihf8FrEAIiVG!hiX`(_8PNlW#F>5f7=$f@POr|A)}uKVFW=8{V9r!vE!M zf2Dt3mc@YgLn!?>i~S2xfv-$8zs+?-u-nYgGz>aFpJ}1J`=-yO=56cEpON|bLo~$S z$YeA8_aE}>Y3*Qoac`AIbMJz7F-VbgL}<(2U1(wT8KEJ>#bn%WT$L-`{mq%^`)$mF zg9`wPvzVBWmRpQFj#in~pvP~aP+MZ7YEPfu28&(@h=>@1$+!p*gBasAX7t#bf?dM2 zr78u{+1!_2UC7Ka?nDg{m3y{62G=B7zdc0C(z5tEag5}N>rSSp2SO1tGowe##wHq0 zFq@@WV+kY@=8VZAP02;oUL~qU8u#oX7NlGsXRl1u$vjEqVYoIuJ&-DT&ZIxRZhK|) z5dnc~=hrmfEUDSHY`&5z!BIdUKC7|x0ORI@6AX`bDQ&@?O0eA`_xg<+W2Y2EGPON? zVBYfWJGa5;zwCi*IdYTn>?-@36JF$CPRy#q@|cca_AC}Mg;q}T{2l!FpkrDbHQos~ z+(B}VmOB)fPnOGpS$U>MQ@7+$-XS%~l2nTgs_mH&4$)gZ3D4y)S zEi$GT-ij>hzD}CE5Vfw-V;@D9VwS4207cl^+RnisF<;`VmJLr1am$U@b;jK)2@&gq z=pi{^&EC@zOKlV5Bd2nSP)h=3QBh{`zTEKG)S&s)@j~CZ z$i26nEeqGKO;^_qMMdA!l=R2i}mM65Z;=}H^5=%ClOmXQ`O0dg7#MsX$D&l}KzJ71? z)Qt5fP4|CVTh-lP?#yIZv266T!r2q;(59HjuUZnBi3O-Xj2xzfjysKLxJQnVxnK8_ zY@bicl+lVoLA0t*F4~FHYPP}q41_r2h{UiTf+v(UX!xeG;OqrP8`ehy&WlOO-B$8b0KwOV2!Pc%*UYITho|;ZK zCD~1G)qJN<@GKoku-^k zsJhE-{9=-f%k(aBXp;g<%kk5c=DK!sSVhy8^&~EL4E)&83i5d*96}t3S|~c)-Aad; z!@kLs+stMo8&;aHUcDOa>CyMDi={!GrEdSWCM#3HC&x7t?%_#a3yqrSAw)|p^U*$` zd`H82k3H#Lf47t2mv^Epv)kwIayOi8Z?Uo)uL(S>TvSuEEWBUNz+H()?N}UCcH+VN z)zP=Xmdf2?%U@Wg*{IVUU*XQT;Fhf+cgW`F1?sN2bBFwi<*4<~=@8GTP~ijFXclct zvpL0Y_lHMAk_GF#vSP+gaqH)9lRZ)KWgmpMrg-kYJ~kd`g{mrSW!Yx99wLblxaZ8) zc6#5i%NsTqR|o9Ly>p|VKrqT~d2%GFNxUuSSyzejd*yMM%;4g35R&cWdW~UCC1BQPhq}GLu>yM8VUEqz@AGECG`!d?M{Fk+QTA~=XJl|1GHt<$yl|I)!fb+Jn z=x9t``f6fbl_YHkovuzHp4bjuHpf-z%dJ)$mq8>p9!+7PCig13$m>kIqERV1T5N0V zb*J@aV7{x7#-PgQ0=B*O6gM1l8&8*66*bB68cUSvAQY_P=%}e)Hk|&L(v_f5AD>cj zQdX8U_j0Q*;w?*~2%l4k-Hm*+6qFU_*@N7SE&rAjXi=qU)bq>6Co-fW z=-7{rN*V_wAK-gEP~DN9A^PdGzX<}T?c5iC=eLsFzr>tNJowf_xf!=7`aS(;Irno@ zXU)8?n8TYNO}QR-3A?&Nz8tS?EkRxzT!y{d%84D)$8DKSpjeF{k&&Kta_ zP5<@05Expa;pLnt|{H!nj_ zi~7i78R%=u^YK8`U`zM~m;-%<(1}*$Lh?wY5p~Ukd&a6!T|t-dy>Y_!uTKY4PMJ-f zahF}%5A-G_b9X2KZyioU&cG3`z2-8VkL_aiqdbtiS*9&zB!&=X(wwx#n?#C`x3WMPU1%aA%-@dWP%TQxu}o|;1rSI50>rr=V2 zyS;R{qRYWSB>vH{;5L39QgLg4a&eWdUBWz&Vv?XfA zu2oiME$#6p7+@r0ew;LL@*x@3PJPr!9UH)Pd)j$7<_;U16xG_nD@cdn5DC~FcN@L< z*oo(wB1A4VmS@u=Pwngh=~(y4R_Tm=483`NY$Pv^#NMu+r&NF6d8dUH0Si zxQ(xaiQby}q|6BrrQ2Lwb^cEk0*Hs^W;LzM7^yuurG^|vEM<5g{#$ds_lh*DhQhexCTv4fKYGMqa_clU_gL{XeqnKQN=QyOTNk!bURU+z&^IF%o#{ zj8^#;A}&JQPH+bgyQA+VG@g!;lq8ezn~%ILKYNcMW3GZ3tli)VGv(*w(`e&uqaq{( z>)g%0CJVX&>94wChg_k?JgfTM9?j|q@sN)259+rO&bzQ;rui^=EZK%^bu}#1z^kPOjV#3I>$(schb2SsE0a{tl1yc@{Dkm`{a&Z8DL`~MeG{Ln zwiwUS&pB_5l4s42qcZhBqRP%Ne6g`HEsXHNqP96b&u;Qonqs`#BN2xR)yUDW>8I;* z65Z$5ZQ$4>Br)SyFv~C0JeZZFmE(91y*W?0z;!Fj!KkHlwu!9**W{-v2!BHYtj0NA zR3!dIZvBgu|FHK<*L*&G5_4HQm_}{f3B@ven1uA&jx8HNG-J)}ZxUwbPFT0@uy1sD zj#=ad9G)BrXHk}r^H`le1WTn$99pyF*V_@<35;g#M+CLNEi93^`LB%Ez+;d)P(}H> zYr`kHq&q04@bK^%6>W&`olWOm=tH@Qcp?4h66hebK(Q%4l0k`YlCPmjF@kNpa+#(8 zR){;6eOB)BcFUv<+0mxTnkb`sWl#t+>{e=cr6B4>i8tNEzEKCeRPC%6#wrJi8&i03 zYn3T9(yX{{>>V3$AV$6M79_@w-qMWa<_DKsjke}G?trz?96?*x@uiNSgO13^xD)Lv z^HKI*OTrlzdD|V%kSHd#fcQqLMMLBesfHJMR#GAPBHQ6Ob-Rn}2bBUC&)LNq#=IA% zR!FNg<77%YQ_O|}@hD+o`&T&RLIaiO3yKdFm!Bl=Yp$&xdaq11wQIl0TE27A?4RX) z#?_1(hlXSpS;)zdAXj4}A|jHo&BmPGaMO0srr@5fVKnHZ-0^6@!XpElPu(LLd8Zu? zkX`a~olHeC_(nBu`cpa$Z+;F5K96||?B@`w8;f`=W{pw4Y=l`i4IiQcF>PYiQLmhSo6(jvg2 zOO3rOJ3ii`TRK=I)QFh#WfSr`3aVVTj%I#Fb4y6|ts%0KRKv#TVoxlurn2p|j4ZHz z4!r~@<9-bytQs z$6wkL?5?zhMb}I0x46|`q{X&wW3!k-6;xVK(<8M|#?uv|pNe6|{N_$z+oR!d5W_&W zgN)Y`!q$4n25uv;HtFYq;nQHuHt2|wkxc%NPWTeZl|(*=&I7#N7VWAr9v+cv{FD(2 ze4nRox$T`!JB6Cwo@Ua2EOmN(P&rPv`B8GT#;V1!vEluY6qo7k=HB$=i>HSp{ThV* z_G5NLcGDj(EDf*TQk2)gX=67TFlI+(^+!8UF11&1TZ2FpA>@(5X>nTx1m8@s`FINF zR=OVa72Kc7MSv#UE%PJra2C!KU|XpJ>1xypA`G4^H+bF(!-nu!)Df2?(~sBWuarGuPDA#!V}Gk?Z-i($`}S z;{&K#k_DhIYO|xyN;f590*Kii2a2`LI>3L~2EBEbJS%X*@^p`TZFx+4h>iLS;`m?- zwf${jz~Nwce7*CC+C9-y`{q>c9L~C|J1f36@6ns>BPt(ncm0p!>ER!8k2KQCk5BgC zwhP*OfzmiD%X)i#7lG}q)=xGk(uXO&zK|ar~faO^uZr-Q=3lRkPE9whi zRc6-sIlQ;7wzj$V7%{|S+!_K7v5rq!J_iy-5kp9_C%Zvt%7mQd9D9;A#tT%K?vSl| z`s2B&N=Gr~QLf8;lgZGz#+Ak#`!@cr-e)nb28zD=Bg>(#k}qJjf_*FQY&^FWBUtgr z74&wdmZ!R7)XJlV5!MLNwkYYZ z7jaixr6`FBLX$BXNAMR3_FeSyaoI%i_&MC3MQxbZKiMQx9nQRdJ5aGen4YlB;bCzG z&l4+tdxO>HL8bkut;_Uh#Zzvi{uRP0cZngIBN65>cYtg>kya2U!ivKRT*eN=Jt{$Q zTFfnnvSnjmfMpyfEg^k)8Lp1?+(rhXyua+m+-LDQ-v4$Smoi0eNEjpiFZf8}lWcES zpy6xEgDm?cv$<$ZQ^}%uE`%R>aRj(QGQe7Coz^C7hc^wz2Xi;IO*%01V|fptHd$n#wkiqoW>-*+C!v9*dhGc=Vk zejUGkh+0&0c$mayF=VvOjF@m-A7#?4Hn*YCJQM}qmyVcK zXFH$OL3`P@Z!j}#a_X<(&~$lR46caxyu9ck@#M%BiC8^OXmRc7$pw1LDi4_{yA zYv#W3&TgTPKcdb8Ufmk!4cM;N*gS;8)v56`4_Z$MYPmo12H=7Ic&pW7T*}P5^a2c?6hR7$=aZf6b z-tUF~u_I_TT*&L!6UpRMG~vD_=Vz04X~ie5zV&~&)) zRun}_99Wj*NT;TpV%2_nDC5Yx7K~n6dV^V=hVerRcNZ45``j^qnmpXcPf8Ymh}mQQ zrv4(_q*;X&82FdAr>p7Vk>=yo+QWs~^n=FTwo~hyMih)L%W4q$sG|nQ&FpwsS(qoh z)eN7Ni5b#75O!(akkw54sv4|#oQx|H)K>UUud>`@EQ_g^k4;QmX{4_O5PB57FhR3M zr`hIs)otTjT2~YoB#(xAZxXI;^dyB@4dM}HRMe&Gcw%0n{Bf7pu8_E^`Wt1#0K%b*x8*|J{-oq(gnk-;Fe&zV)aSO}d+j!^MF0j8>iuB;){komui&O;#*y@Dv6Ok$V70swd zj;jiY>*x7FncSP|8@>XG{%OCK8*`4@%#iLF)yfLEQR54OgosGI16DI$U!wUr;n z2N1$x`Pp=z1xHRi7d~-8dQA|;y@)dX_8gf~y`eg3z3ur|u;7n4U8{~Pq%ZNkMJ`=d z_vledFmKxn1S?}CAG*`KsUnv6^+ojdBfgD3og zgjgEGk-xWRzmOROi>1LMn+1;>NtxLT6dlTNn`3R%VJ&w0TG=9tO8x|m@X2}&Z z4XXCeF+SF5S)V|v*-m&I?a6ehu2N#VojpJ@YS&564`<6Uxh#ILd|GFlWp9`~m77Uj zpjs3L5UD*~$maFseA!8*_y-84lQMC`NatIu9Y3{Ct zW7I7x$u&ad*3t-ef;@H%Tf*IH$^E&Pp$6%m9&Q~U4=S?6w~WRfo3gL!hs-f`#jtGi z*?a^WhM1}44~G(T>w~J6H&+N+pBn}vp$4ZDq<(;0;9%r^%||!i6UTDyaHn5-P4}yZ z`i%mqFDYK9QNtt9!Ri?1WcnsI!TBxTq6s&FeQ#z+wIp(=I15^GIlkq2TCUxPl-G70 z)Pl&{-Xa=4*>YrHYq|{L^|eP$(ramBxhe(OvZqK67o!a&9gp4IOnngfrTBwN-7I33 zt#?;OvmPB)YaB_)-K=qa)?nGBYm3L__c&zkMKjG_X_qILzVNe6?J7I>!r`yV;V-SC znW2F@^e@1=KtHGoJLyi19nU?Ag{jq|#l;t-+e3dmYkW~$X)&(BFDeSixugzxxYypj zA~A}*3{>PqhTX%&LXA4*dj9b1NSZwbi+p{~sjyEnlft!S4-bYcalEV!4J#;wj&pPq zRWPRA;jN4jm8(}tQH?P0pdR8yRU(3IN?fBR9_R+T-hFd4qL9!t{I1JTDI}=Pm*)AzJ2fuJc?Wb;1R!b`yZ=<{=<#> zo~Om$y}uu~fB=WmZ>lJ_;e1+m3w2;j-JzluHIP5cAbE1`v|(*!MRq&w$e`~nF>5DB z-_@O=LdR!4@mzD${`F?!Lr?u!mH z*ea~IM6L8P8=6~PPCBjBy7H8AaJbX7>dX+!CBhLipWl+UA(8GWKk^oxW zi`p@mog?R4t$nK~B?f@XeDVH(E7x?>i+(27COm&)iiJZr2CJz?_R&oz-+I>R9%HRB zDlmwam9_aBT#z7Y<>BNq=?Us>IKNYIWsg}4i)vx6`{n@+gGzp;W0I9x(QpVC$NhH%jz*24$(6Qd zc4^dHZ<1payaX!d!d0j3^l>8nvyA&%gk6U*ALDKB_gC2^)F4@r^fM83B&i$ z5x?KkYKN@JtlH5R1E+G_L7n!nKq0L$Z3E&(8DrR`M$&dbX1zAS8^`bDqwBDfRA&;= z=4V5CZlxN+VcD|gR?t#_q>EJS;0~cYl5rZ*X$KTRS6C@sf2oeUxkPKM!sTx~FgQ7K zy-c32i4|I)0xg-NWAC*|1T0!b!qdJ2D11%-am!pIY?gPRR#}@GLb?@h^Udp8%W?TL z{^7#+)-CJ0(Y_6i;SmvrBX7di0=0|1jK16?Jv_!wiTG4+$_Z(9NA+V7(pD>BseNXF z<(cKz8!vR}`Txo=6S3+YR&1?gVZJ{n@rc~KejY5Gz3k@0_Tzm2@6!jb#$&REdWti# z185$SQ_?~USg)bLJz7aYTE`iFE6F;uE>jiiEu@)-&0|qh%+ckN;IKqqJF?e&U46-d zOdf}V4B(gLEcDeIou(tuZQ|7sETx%B_aVgXgh;XXgNB}-ULv+uXH3h7p<4p;v2Wbq zO?xkD4(2u2C7(VGcXS^U30bHlTZ6#I5tgf4-5yN4%m)t3s;#&s%A1cB<^QPGQw)fH zjN|pBUxMhU1;0PGuob^@5i4wn97RxgD3}h;P2kI|mZLgpH(O!2tMZ)gP@ER0=vjrIsvVFm3TNTpFwTuEe@6L`;vSeJktd(dmj0fc9yy+{^MEe@X0E$8Cf`Dff%n ziAHamX4fG5+1>P$i2oN-wwq{wwZ@_x5QZATLV}A@iV>WJIzw|i07nkR-_J>YU+dCU zl8uvxcE3xa=PJiFT2A>7K=NrhOW$}9QmE0?X5rHI@`JoE158_uWftk(>}4`%ttc;F zqvx_4Cpw0Wca@TlnMth}jz!@`S6|IVs7nGQ;o6iu;d1HjH|{cNF}QC2D=NVsBvC z+C>+(TpF)l5V2y}pKdibv@{sK(#xO}OvsWOLP#*vxCSpif@(H>V#4;S_8`e;+!0@jY@-8*YIx_WY%EDVTMt~m@LnH&H;At!s{asyb20|Dh`{OqvXKBsJu_)p}X#PC;2MiK$XQC z;jWykpwqB~Cv>dN|A6cMfbgFlI)@4d+J#!1*a}u&{36dwr2(6++A+ZLzj^5A^<8i8KyIi-D0+_U zX9T$z7Fu%j3aP+9Xlc}#-a<;vsov}F?`KDS)=f`t&ac=<890U*Q$Dj1kgBWKa!_Gt zdY;aX4R}Gu!TKPkCn>xUHc?CKpKggg@j) zPeOn+X6kng^h90q^T-Y#sKH~H*dAH<9nDPC*|A%#VYc#EoeQB7L`r(@7wJ|7a;xI+ z%&zLeKmx9m+Falm0+V^T4N9HpM1IQBPxm;IVGu`OSwu^VzNl6Kdj>dK!X{V2eCk5d zI6*f@G`DQD-J&Z^S&ZhkvI93&Nnh>R!jAuhUi_0j79Ys@C&(+jgXw;; zbTSttH*^_Tga1{=_jhkQe+jJY4zwhAopW&)mMmt{yC_EGwSBGs!2hUXp*v!3&Y+?` zij6h&I#->7F|WsA5DwY4dHq+f(*p@tp)nZ(!ia$)r@hBJJDrOlE}SdZk(+l}8~qTy zr;;}lJdoz0S>$W^k^LMt466kU`*_ISv|qWp5jlX9)_pf2mkDTw>XVQl-E27DTA zhbF_MW_u?{Ty=1$Q)`lR8FV?z%njvpLUHfyJA`nm-?1JuXb6vm7oq z6qp7RRS8jp@(IT)tF}8#CCs;3gDE^+UVkW{m5B+EW)H6i>)RtiDNA^#XnH*c;wl<= zok5Sp*w9=04~0T?MW%+@q1{$IhnDTJ!7EV{`cNcS^232B8-bkJjs5KA7S}0?G9~deIKn!7OahIm&0pQe<=~FyVX`sY)?r<;6t>IW6&Fq4tc}}3D!U*d2 z(FO@>BTs4f!2pux%l62jDOkP^R0*!=5bbr!n?-nooz1c3DtCRAPwx8c<1QqZ83J&? z!5IKoRv1qGV$hMb@vhgup^TzBQNIFyx=9sr^$lkulr zLvY?BJPJvV*a6#E0q=TFFaLJ!!)#{GV z`lHw2Z0FBmtNhBAQqffVVuLrvuhag&bMZDznHU!CtF~g57K74Ey@9D9=)d48y7N^%dQdJ}t{V5w@Ls!we}Km@I7q*;$|s zdt7er-GJ4h56jiQ&wOUJAT2htkI{#Nq>+-gDEIlCGFlJoxittDN zeDER)XmEPnk?*%LtN&(jm!bjN-j1Z7ow4+2+)6`BmS8P*d#HLjvt;KkIN4X&r4K3|wL$|c* za65*f-Al8gHyh6nWzMi0N@E#(1%&M|vOOjsNds5&`X>i<8c~k^sY;W1+WlAo!}wt; zD5Yjw+eitixK<0^wi6VEuzJ@S#Z*Tn>sJ*)zf5_DG~dwGZNB?i9JiNf_717qP=T7n zkl3!Z25^eI(b9p|`ma&0AB4mZ9Z8xEs!@!?bY9<SQ6lg{PVfPuJXD=pJ6%2D%qB z*xL0^=fyVTJK~0R9Jl5nRW%G;_X++&AziAcA=_^Hs$=~Reu9qYQGZ|y6^3VN{}=QK z?8ztfvVrW(rWI!BbqU*0g8Tu04wW1qRs&Lei%J|{qvVOZ{u@)PjoF>^|zRDGu zaVMy=M^7f3X-l$@N1F=a((s?I@8xU44gp6B(bqNHmUGZybQx;G&Wbw^zTrs9cPy?* zDBGLFzo0_yb9r9tawO-;P5e4c(}xDB(GM?NF?EQFjm;$@zAUQce>Qmq26R*k5oN*A zJ(`|T7UMx@*=(ltyu8n{{~vc>9Z>bww0l56LL~%5kdkge0R;(>?(R@RO1c{)6qRo2 zF6nL*>5}dSk?yX$wnxzO9be#k?_c-&*LJh_{;f4@&8(Sco|)Ais)bHU=^_%Zgo6bX z=vmbZbdwWytT7o@vM;6h;vf7WE_`DES=Yz4r9^;IZ7_?z`UYb;1^`hSZ!-s}pa$L@ zc2~#r5F&u;o9%hMmIJAexH|P2e;Oqws&LQ-6IeAJqmTG597hQu<4;LyWTJaMDbBN^ z5zI9BJx#aU+9{?JB6eWv-`aoKx3oQDy^|g6PNacC79-7iUGR>4}JgufcraQAa z8cvNMiGOXX{CbLNMjD83UKjOTn*wY$Wy8un(wbsUm=v<7S8Y?rLl48nrUWX%nVzfP zM_V&t@)!qA1(953|ASDL%|y7f;A82;w>z^dfUDB*#dD>3WXC+{ZBV+9{cPDxPtKI) z(%{x$ql-OGjw4MQi31h5iM+a?+F~ZtLgm?~!H7p&q?V*4BuZb)7)4BRD;Gb+gqc4F zsrnGAyUwcr9dAiE{M6=8q&!O&fRxi>N~;u=*UUjdCFOHSa4Q{^FI1 z1@_gXC_{$#wiZ{M@F|uofp}OvtED5O9fVg=1px9}!9HYB))hr1Tj@Ow)T;D1wMuke zBa-^K7+HAcJ6@n**I*qjh@4 z|5S-7Mt$ibV%;|qRhdmaU+9Bg5`7f{5EZwG-eC3wYfYxr$|wbg(mCXjRDC`C(D&J zM{rnycUa8jWiwRuQAj!qMW`CX?kArum3l4Llu_GD1e#_kJ`F*?XWybd=7$|4!q=MTnoAIt2tdmX0SuI%kkPhV5t4g!cMAV7fZ{FK#!jp=M~-yH+3 z>n;McdK!kt!~?6^3<(?rkfSBNRB-iqzVTxm1Ql93#m%N>BkP$w&SuQk1jU|khs@}l z$-Rx)$VPTmTvkyQ%Xw<%5#_Eh&gs1ND)b;73UQ=a$(V$>t1b-~2WX{^ge@L**#JWNVzUR}=sv0D-zrG&RL~yUtA!f^pzcfGFBA+ug6kY$i8* z>#HscIBa0JHpf4?DvktJ5$<|AH|vw8vl(+5g)19aC)ocqT9y{nN(Ho%D_#mSJY}ci zbnb!fkhS7GIQ{>h#QXbu#|U>=?E4{-F0{qYl@S`q)zWMjPuBd})xO(Pn+>8C0AR1D zU%yXvz#`D`o<5LTLODIG0=rS?VN?Nmz3f<4PK6eHo%&`}S?ySYSZ1Xb9AiZ5dX@S{j9SDX-96XCHqnc8}L*uFQxj zWv38y3N&m4_FV1uOpfpOyx)U;F1zyTCfRZC{$uH>;%^`HKjL{mKakTORg&tn2wH3n znJ)BOKg}4dPSZakKwI$&6XrHM6*XryOdNFYP25Xx$$vP>yTmc~O&XIuT;=C>*QSEE z9otWq14)aV+n_D9(`-aaf`kgIo0&lP zTTU1hW1?pvjh_vf##VZiUw7T?O_S-O_uQR_WZ1*+TU9C-8-L>QT%(MHxeFrxKuZH9 z9OiGNua4&7>T2hoImmgvecO4gWH28%=MR$J>YGt=2Ea>rIrJieoRU2=VAg{xKM=^c ze)_Fv2Xf&(N!%SvKoYHd-E%PiJx4=PC1ol}Joc4hjw~DojiPkA&DPO4@L3ddZoA%{ z`RUSH)lK!!?`Sn~j{-cWRp)0*cNmmx!D>9G8Kp$Nvn~Joc0OH;U+;1L1=Ahyh{)iP z)pGev_~v>uo!|tQd-*b-2>I>3$$MD~3Mvy#cX>W8e>OOPMVz)$3N9RP8OYNeE46Q3 zTlBA;5oCN4RQuQk058C&s<;ajcjyzj}u?!f%$S*ayyn~?b=Iza|!|$ zmi4}MIM4a zkS?oBN(%L;3;|2zUwsqiciNaitVTlFv>Tl)7Rf|prsc}mmDKK=kU;E8?&k(GMNAZh z{k+PyQ$P)~WhD(bV8I_4(~|Lh!>t0y#LT27Z7qO{))M+?$m02j0*i(2V1E+91cjUI zbdLaLBC~yGV9V8WQ;7iB=H2VhzdA3o6+9zV7c4_4-3gSA%)|E7=qildwg_XGI61|( zmez0@`uxm|sC^AL-A{L9D8)1Tt355FI=^=;InQaS)`uE#DqgXudGgVC;o8I z7zB_(L}=(QUm^|_yV1L(G%MfZxzjYGt z9JAIawi_i2Gm0WgrfCYKf*kp*$2Gx$EI3X=>5t(pTBC=saRG1cS#$-{D$&+^9|i6A zF0JhDiW)X^RXBsqiM(ej_N2<a$KrEq{o1yCz& zqBYMEZ>)FA6+1C~Use)>iYj4RM!fkQDpxCK*-y*~3_H21qVIZoI>m^N$CH#=K+$a`!iJzFXXf6i-nfzew%d`j{C*$_=A0 zVEVXLQM)mW==F0fU~N&MpimkDSc|UN=SF{2u9UY0Np^H{Uyi>jl2*7xl(Cr&Uzxux zd+LqP;a4d>g*)pHhX(20;_MEs{90wo(%Z*E#rH)IcRsH2}=ZN^tPL6D*El zb?H}8T9mV5h%KrV377S!l6yZ<4f5htX0vU~?}e_1GH5Bz3l&v?|B_@VGFd6%&BNZA zjjepW$XW@}_ANYNft=E-W~xn^ zUxPxk4~^x=IhE(>-u#0gpA-7>ND{xB!sq0Bbo?Y|KXQAvlZEdZ5_BvnN(;gbS%uvL z2394mKz?-YHhE~(xo1r8q{NBVAwZ`KWdbJ52v2`gU`WxT@c;L)ybGF?X zC{TO|Dvo3$dY+8FCsHcZsq(nFd>V)xvGj|4ywkQ*C#)GmaVk=R80h1pnVVl@J8z$c z{O9EYkC8M@9>h8-)9A6yu_-rZ)AmDybG94%^N^7j@q$@2?VZDr=5%z_a};G6XmM@*Qq?LmKPN=UYN}?h^~HG`^Y08?hFh38Cp;bV|*OJ z;-DBzt3Zl`NfVqXB=_dScik;W+0WeCYW#Ywb^tL8OS-I8gplIjcZmig0NIM9vrDn& z8&Tmq9C|J|pY$5`uU6=UtyY}&0$3cXQWFfKo};Iwpb!8t{2V8HB&z#oV7OD*>H?IytCjxD!ZWGno! zi&eYXUbjcGYHx|>4|16Cu0;9kQIpo~R{BdMPaF=!lo=5%;bjA&#&`J}<~9#Uagg#+ zkIs&)7YN*8Jt#|m*u4MR-zb#O%V^eaO}kiEp+^zF9}~^S^Lg72nRC~z3~!ZP!#R!{ z8%jcRgK(i8i!@}fke%$+e@rSjoP~HT7N)a}1|ZgZ$L#T3|$T+&WO z!;&@G8R9p_A0p3GXsqP$G&vxr@2J1$HI-rX0N!ixmoLQWuQmFQ{fO$Kr_xY^78$Gz zPj1?~ZJa^#0c3BWCQOZ}-*B5lnq&X%i;|!$$r(^yGT{O*Pb;3y36;TqTMpbR5ak(B@FLm+tR5hBxK9pqAcm4=lk3n$CK6lwutikn-^S@_IvAL3~t89KdGDs zO;GsGyj**u@_#W!)1JcPP(m;t?IN5>Y7&3HOz!Njr&i>br&a9;UHcA)OhlY=$Z9bk zU(|!f(PlF}SGSAqcJKc2_U7FEb?z)j|3LIZvM4-2hg!jz)NUOq+tDcy2OxIMLvQV; zccHoDV>n$7J4p_i9s?pJYtK-%?I`W{YNft=CM*bT} z@|P~Ws2Fz&lx&OyCt{|o*shsGN{)Ff8F_DHk655`tp3n<@6yS)RK#+$742*z2ajaE zZDuo76kBgmDN0j`l94N)La7UPnq#qU3hvZ#b-boO({~o=I;&iBMMAjq@VK9loeTUe z_!aFu-rcsGPv7Q%zGo;o<+*qqh$xp?R+MjTmn~nhr`|h$?X_O#R^;%}K(zpu)itD= zN2Ib_0VEAHuutu()4p$=&~2QxQjTQmuWeHE`N4y$4}_tfJOvx9sRz#i@v zr2GQ>DY`R%$}VZKJEwx1kI(LgX&^Y&hYVWE_mHMr!tx-3L_>QY;F(Z|ee|LV8^K-G zrV))KL3LzM@k%(mL-0_sZkKi`jSx+W$^s^Ei8bekvX5F#@CwUlC$HjB?65)S#J|Pp z5`sG%G8%%WzvJToXv`xTHhQtk1r&^8|MAJNO6 zZZ?7p07-Z1{o~fr)4%=Nh-a_hpSUn23h8_4t&DO2r<1h@JAvm3nOIaHAfgBX*HRYw z`g~tdT@hm^$dP7wbIb!@UzFsYl@^6O67WxI5gErR z5a@@dVzdd5y>jyV^v~;T^)~X$OiI^&z%A<9Ds~168HSF7`2tjJO)>aRhYIO?An~AI zI|t`Jb!t3EG#(94LfaTo55L6v(H#zAKz%6z2}~Nn@sie)kWxIS(`%6Ez_Legsh=Ow zetu3{3qpMe52RCLx6x`Lw=r1Mkr3PLRsew4%o^GLTR}_IkC1d{uEuPpvsD&pnM%2J zNA?&b2ZM;-VD$4)a)Sq3ONs>?;CU}Z{p#`TzNBY#3Xp?6i9zuR;*ZV&f7DBW)ykXs z+90SfdO5r+mGm%Z6+pZeVZGYQekl0iCP8W|16hu1uoVyEnn;>FwQ{mhP{A4S&sF_M^-b zec7vy>UHZgR0T}Q5Xzm&op#^|@%VD~epG-k=GJxEU*{JIl7#`JiiykxB0Lg^@Dy-H z201g!bKg84|2SaAPzwSoA#jp2WQdhoz&ig`$b3lMM;S!-3ZSoz;dKDeoLmyG8 zM6k5IXb%f9j7(<+{#hV^J4z@K%Pj89J}+v1$v6&xaMU-rvZ-;Z4C8<*-3*FNuh0{R zI8ZB;Dg(e!C0I*2wM^vpLWv!|_o*j3f?MWSJep>w;ipr}^wT^#vw@`FbDyIY*)syu zf0pJUneR;J3VP$+5U1|>?%WfuU3bm~NNY-gt@)n(y1bVIh>vVHQ)FKf!=#cqB7>^d z?Id%ZG1o^Y2?stjz8EUFICbLV-7`7nbYjqd4|o8mP>{7qgsg>44|gR1$vj*rOCa|}losF4V<0=? zzcp^%mHos)d$RKV!jVn8;}#!XvBk~FseHJ5PM;>t>xcCC(0C%NVy7nG`)FlAxpAV@ z+rTfvc#O*ufSghB$itgRkm8bgXauQ_@q?&m}?JG*|_19#xwo_bg3xTemmP(dv;MgTJ7Oz-a#0c1x}@qy9Z0QSA%?2@iz zQJlJzzuHJ>NMAu5pJsqJ;B=9Kk|8(du{Ere$}z{CBV@@uUJLDr{o_=B^V>8C2sylj z>_K^I4(dp@Khh^9rhQG`xU6j6Ac;q`zR@0@AQ%)LVRHXP+cs7fqEHYeg@s-p!+WIU zzq~h$2bH$5^?!J8S*dnLpvp+V@vvieV?8}9S#U+wM*!4Ee#Db4-QEG&A1YbLaKp$l zW=1{f06>Uae(Rw6OV1qGiy0{gXGTNeU?E}sk5%Pt0JK1&X-kAFTxUn*PoLx8ZVA9F z!^Uemipv6_Q)gti#LD1ziHz;0cp1$5krYMBXG8lP0Z)@ZmJ7CEvK7&GX^T0ckxBZq zSB2flYX9PTB2pr#bjH(ok62%|77XE7#oyB|5#(HJ3JsYCgh}Ju9D38nx{Eps;E14Y z?#eveXeVM=P5rbE7`|PZHyZSVx~E5W*aHb&ufio%F^_#n=@F|awMa0I)-lC`mitDT!|W_Vs}Z#^hi-F-Ab7M#5s zdJpfktD3+*kSD33R|J=!zvp_Ts#;=70;*)oCLS^w?DIJ8H|~1YdtBFr`%a81DT2|U z#+lCZ56xNV0Gfjhm*$3)3Zb$}sg)j$#5%BMwA&V4wB0@<#JP9f9C6WA z&z|oGVOB_qO)Yj=Ij1D!FHlFa6!syQ;g|u^hRvcUyZ7;C6tr#4^}gn6vV#%>x`LQU z4R!7XSsKYP>|?z37V!blUr>&L9!i!ZtgtmeBZY-}6M=6Xx)%xW>@_(3Xg z7^6(3eQhzAGzC^d!HbNWl#EO$g>0_@q(Zwh9o_}cu&y}9U}*FD3X;d=1yY!KiH6?y z_?b81tVV;Vz(+_zHZ&B=#|2;jAsom3WPpH$!bs7t(;)0 z3t}h!avCV-fusz+wa|-_nl;lfPJhtPWO{l3&XtF4H6~dD8ZCaNpjBB#BHG>W$0O28>ggd}uOajaZ46o9ByGoF)tvGNBgES=h<>yy@^>8LDOd8}=>BWD%|%vhd3@zNbk z$dNq(wSvKb&8J(te>2~#Zzb1q#5RLeuQw$VBl*?C-%*v9Eg#6N3^TZG-&HBx1w<;Q zm)TtI>yjmsJT(uUKsxsLJ8Y357gxl@U)^Jm=jE01cX4X^K}V6AP~+m`Zz6CR7$~vvI3QKdUMhlyt|G^7Rgxi0Ll$j^(G1jcute$*WzdSvsX&Wz`pm z0;(BKR}R5hL<&y=Tv@ly(Pz}B=fyv4&A;@+`6?76{qh7Jvh^E&HS_(=+a<}(N5Q0f zp_`4facK76X(L$*LO#1AEqG|Zl?p(uJk&Ey+FF*~TTSeN`=N9j^p6ekN z3rr0|E9J?!MlZ(S+*0+weg8%HKthVAq1cT()OvTl+CZTU?cBfyAq1(t(;mjprS5K9 zn%ri3_{AhLJEksQe~4FFk}xvSs497S{L@pGSoT^#$`Sy@CK0ot5?AKuJ_BdIjeJq^ z(QGFPqAFx6PXMg-v3^v6Xzuf*Q7{V{Ej9hidngFE`m;RB2sN*CbN52>Pu?p9CPocv zPBzD)I5%$+fojMHFR7Fv+ux#nKzn$?5n_l0RpJecCv2B8d3|m#6#Hyt{$Qn+@Kqg) zH3B~eW{_IR;bs9AmFCtt4q0_vQFV)_&~^Th!59-!VVn-*)K1u|55!p;U^qH=}IUd3U z^xu!8NPt?!Q0+I|8?LqcCIr%!2egaq2-m~Y5Q}1hpO_sgulyqT>y zK43P$Db1=mV<#@>EE+^aE_>PNKZRUe_rrri5e6y(BNbhFN#+h6tm({KzuBk`G1J%& z92Wceo7O-YLryU%ZurNvkOKJwd~1G>>-ZG&a}lLNMF(X~J#uP^I8n5;BqThxJGcb( zKGFO|qa)#v(kuNeZ%oFHPa#@gvS$Lpd{jj*6Pt+&~ z*o{0QNvjs1{#*{E)J0#kMx~Fq2`;Y1AR|b3T0R5iMj201D%v1KLEmrfY6%#(7GwDE z9L4S654KlAwsEIGkzMlJ(r=+Tz}j(_a_qjbUz_PuY)#egjkF^EVg8f_(?98hO$u5& zfv~2vhjyltTA64=ZY^&$J5J^}w@G765ukj%jD7&!+p{uI)m|>KIa87qLE)s;4EiySn z7FJ_D3Tblb^M%8UuZ#~)@yqSCs6vQ26Dn&hKP!3RQmHO-`h+sCRJ(f)X!)Cdxxi!U zj8%F8Z5Upl0euB2I>&oSA$>|r$E%-YEu4TIYy$y_0jCheA2?a_A(RS|wmY^h=`6ag zV-7o46iQ5G24(QnkR2S~1X9X$YP?VW40=5@3enxzK=;pF1YMgRsdQyr8xN>!jWcF|?5sJwS)-X((U@U$)%2HPkT+W1pHqMrarBGNZQW}S z-etaFmxL{QuTr@ zat7*FLimjFOx;YOI|!NY!u{hqi~n#qabiPl`M9glEcceZ6)B^AH|4c&Qc$e4LK$oU z52N&K`dy9IMz9(WiO%Yaz1e7|X;6LI41zd6&<#Nd1pEnlJ(QCBAM_I%f_R8YwMA#8 z@?;`De9uIaOL6S(de~#62KuOe1RNDA$XTS8)|^#k7~~gUr_(3XC^k8D=+tT2l~g43 zGWkXvso!^M%96UJqA}8~`Q8o$X0~r26VnDm9baEg+4_OW4_wFH=*2AS3wm-qT-U@9fWIM=|w_*5{GE7AKdwdD{YW3w_3} zjGe%0#NxHNP9HCXO0&0d%NKYM4Y5yOYtCklY3(etbso87va~8-Z!G#g$+h1TTq9Me z2knQ@w*r`rRCUeuucS(+#z~t;h{>tQH099xfJ7r7BoPPbdVW&LqM%NXOcQip$+Z9- zd;xP%0aSbmh>C9Ol}U#m?QgB8iPk-9OMHK~9MU2R8j{t>E835esB%<^4sgSuRhBo@ zH&34xr6$*^Qgp_4CY50bRf`&#G!x4qB~Dl?*&vXfeuxYWI)%`nwC%8!kIK_gDbE!r z?|^ca&I&%UtYb_Wry>aaye9?tX{JL4MS>#rN1yeZgZ%(mN-$Ho1b4^FKeADC2e8^$ z3s(XyaF`nWjeGG4&AyeQy?@Eqju}tiqMqOD~hM#JIs_BhtPSmq)#sKL~ z7pC%w^AdKV1=TnbWqRxj9)F^zPGxJwa z=$uZDiuzPi6K?3CmG0-*+SJjPRPt`=v0KEGPIGK?t7&)s6$#hv2DgP=-Kyxp&H*?z zs#E;(#{Jm>T7zZnpzz$-TPpxPE-vmGnO+3D18J z>0K;o!%x4;3c_#-5bG)|G@$av6WtC43X?sSU@R7r@nrojXOn$LEs;X}E}hsd)P zw;pNuOoM(>{X!%7-zk0A_KgWj*FhKm1pUpFTZy0`S+$QbovZlNjxsG+xA%JkJE}Eo zIeLce$C3iDUwnw@{ndZtJbwa!R=Hwq3(^?&7TO_ji)uHbaLNbqd*4%`*sTh|fxNdG zFEF@OW?rHMx)i5?&V1HN@k%q^ZEK|y3>tMWXMHST>7YR$OPcm;x2JioZ*Um_V)))+ z7cz|q`OU`qJR7^ugTau3J4(NI3eDTNtSx#?E0(HG2Jay~5UYmh%UodUHD?H2qZtYm z+DN1x=|XOuTU7*}pbm| z%ZEI0I};rg2B`nyESdZJ-n(b+Mik>*KJ??Ha>I+jNwOd)fcTF?#iUg zmgLih(&cJ3lM<=jA=Tq@$!dnwnX@0k0U-FT?9F*kZ&#X#^3}!rt_hj|$I_5^0=6R6 zRb;$Q?;Xs>580Jb2i|TEciasH(IzXh zrjjVCWb@RHCXuXXlyI1QLDqOPZ48uizuiBm2l?fa$Q?(Wmv#wbK-F{i%Z@Xc>tUza zy*sv3>@2&I>F2>-IO&!>R6$R54b&|CjA`yY1=X!D$UEUq4e;OS z9VPmF>v_mq_SsvMwrg|X}*L6jT~ z*Wj4jL?zG^lnETiAC;|?J}r9NTnRy}0 z0pYuX)FDG+iMZfuEEBoavNEI=pNFIu{+qVRr&q9=pgJA|9&idQH^3&op@9wQz+jIF zME(I%H!1naFG1W&;wCESfS-Y5XmjVF$5g$GMO>g;oz-GBtpXI6xqWf7&Vn;|a<9Gw zHs!%w5GRoPDq>CjcyESc!(qw&1jt!ZIIhNM(LSG3kLTwO`V#G}Gw=3=dKT0iod)}! zua6Ux7A=hyJ3Tg_PZt565EAWwZF@%k9~`RRm*X2!$`?HC_9(ym+G3uX0{+yQ$6_T! zC9efn{UXpw00kVhG(+MCrhz)rSpPV<^Dgux{~{`lYZNvC(zjOs{jGVpK<=ZOarO#) zt4%hkH0hGYtV)i(O(cjPy~Je8wiY^UE~N#UJzl}-&Z62mToGNGhd}o$l{>MPr1HXCCK8rldTW?<4q5Yc>K-ayw=yglo$8~6a?UhWbD4~9{R%l_j6 z^eug*d>u+izpr=x;~DBzaP6Z*QIOizG=G$2r5w9mi-(?f>?L+XFOh{5Q zNpz1(_4biJI<4|U6cv_(;*5?A2BS9f+h4PaG(jhXXGimv=x2;93m<)>$-qQNfT{76 z`cxB8;GyJBUQ(bObzZ8`W-wC1!(2cP}18mEd2zG^02klp&%j)0|VHh5MTYcUf7Su~|k+>sB#}I>|UdOFcqcKOoaE?$j zke5*);R5uvV6(U`FN`Lo94{9OmD>y(K-t8AM1nFSJf8iT;I zOUY`!e>5!S-jQP83QN6RNX#b-s@{*l=bX}ek=_?fLj>u%p_dD?s*+=V{eC1B0JgV9 z9)*>yz5!qY%+|?b!pvhll>$@GYanO?-JE2p=N)aq`4Zuz?R3~tCRurSUDjMfF#H#d zKG2opDG_L{8T@?1=EvQP5c35iLcpTK)h zfYhO|&KzDeXQWIuuBi5qXWCP-sQpQJ=zxq72%*aj(rgX1t;0wiq@2Ll0lN1(=I zE72kyvvYcTh!~i9$AS>WNA&a(cn;-05o~r$7D4r6C8o6v`Wk#!HzQgjSwr66W|L$Y zGx6!&T7dMg+`Z|b)!RY=DLnHTn$nNO?#hmBZ*U!134zo#LJ&-;WHGy*hz|`(qzZNQ z7XHAVZlxK1#P~~a22Y}WdjU5{G_RB0F%{Skj zNe^8{Sb8YXvN3-=xirE(e|9Q}<5 zowv^LZi&tvCxw=B%Z3$1S`8#uF+ft__CTdW`XXz-NUl~DfkIFGy*r+dJ88Tr1w8NG zqSFkKtj!Er$fTkxF`Y3bzS4*lx^`5Rq3OL*t{PODzF{PC{La||8iZ&;(N3epI8KKG zmMo+rkQ?J5#XTU3G$-wS2#N+YxTG*4sTmChTRaNMVqbJnu>hz+H1l(Bn8%cPn#PRT z0-}URc>wLzpRV$7FuNx;fWwOP+A6v=sEt!7eChsCrRoXONiGkh%*^Se$O+QT7+?zl zpn8}!6Z8pC>`9Z%7;JrBCiW7~Mj*Ug z|JF*im)St&ey7h1#BUaO8hxzZdGkH#?!u->Dj1BP=3cKbHyI5THq+Kyt^m5GH{K)4 zda(#&RjMAFq~jSC;kF30sogeTW+OZb;8_LII(P+As3@k%X53|#D*+%#vN0%I|NNYe zo)-q?jV9PZekE1|S+RL)w`f%F?pQ56e;A`Bbc?03KA8SJ(Rl7(^?O=Ksy3M8m9Mp< zp9QB?ZS{;*g%`c_1G!6Ij_pUs$85I3ZI94aqs)lgyx%ra$~`P9`x2sfI&b>7e$5F!P@wnThaU;nCeMAP% z78lwWnm`FA26R{`38-6f1n_TPDRHA862&>k!8ZgG#>A1wy%L7P&}s~>;Da6b%8>r` ziEgJ~*}>S&Ye{n?HvYpTRv|kZ>&0bbD+fv|vz_ItNqd2RK|#a7qY!X9|9tV4%7wU} zsALeDebl<7+i6HYcwR^Udu7okR>nU*_u~t$B*p-2s0ZneG8IPvdFM>sz+WolC4>BC zw~zD6^(`~HNVl^`aMB^sGbbJFv2|l2 zn|J@51}iVx`ovu@8kbnYal4wY#qFX3r*q+qQG|uDmtKg>q5J0~&t`kxix@HJcD5b+ zVVl_zHt(Yryy*EHUZ@{4dkvYH34Y|Ov+;A%Ai?jZ%#>-b{98yDzEdp;eM{Bc1Rc>m zagdd%d@BQ|z7EHzJEu_}GU^l!D9}u6_ZtiLnH*fOQBhVUh?tw05Pn;^^X76U5g?~1 zSVbi>lTYqWi#e+s(IfHo*szcxisM69GI!TIm?Qp@RRJx*czo|l%+bwX8|0FYfr2(d zRCe48qBr|iHD<3iUt4UVp^+SEN#2v_1rgi4c=e74OL?j{_u1HD;J|8ikBj7K{TjiA z!aH4pXSy!Ey~+{Wd0ipP-@Dht*w}GvUL&t`&(r)^vUtdnS<6Lwo_^k$(*O}HS?j@z zzDqyjY^2VdWG?u)Ho}jOcW*{2hS(2vccZHvex40}x#|65aaBa=9Mkz}N+B?4_J1@go%~vt1 zFbh%D)2aO7g$oG`1MaAWedL3pdRueDm3v=={v|ILDD+S^JJH3w6=scqqAHrv%Elq} z0vS0^gqx&aLg|}p<9Oapa-7(Z;MpB|?$&b{dJ54VnGshtM1I?!CbRTRm68*aO7nm` zJxv&hi?WQc@aeBnab|&SxcQ;wuYW!GoN@pBQ;a_}xVO!-Y^8+Ruw<(+mnLu6w7K@v zRb27HJ>efa{R$PZQq{IfKa}8a!|Sq+q}j}oc>CXy{@pPD2e~=iYS3iJS#P4+a(IPC zRAsknrM@>4Le~-hW2fsq0rOKV7TV_jZGKkJ(CnpM9elsu`olu}d9K&qg4O|4AqFEE zMUGJ%<9RPq6fbX@(6SFM06|sE6lGwcM0kJ{b9}$*d$?vk})Y- zj^|v56N`E3!^JsAQO!So>)tM~i`S0XvakF$jeUq=T(RUu`!DPw2AVv;IvrcfI|T!# zJ|nex7WAp6t8HKjL)N2*Xx#4U)-Vz);cs%>0dll8dI9s>bT2zg@#iiS=^uw@F$zas zEELbbZB~!8=w05p0|yy7=N7{5sJO$<7w_Lh#ZUJ&v{IVnqGzQTxComZEcvvN2@In*@J26+!`7h`Gti^qut;@L%(XTUuAuAP$e{(`y5@8wK zCkxE;F5EQt?m@F1^_QlD28Be&I~uDANhKwZqO2!7Lial3>DBu`Ptv&uvdgX8-9^H@ z?&BHI_YTOJ4qViW5hh&U@8g|y!d&mq$W~M&XU>>eF?Ro76XyH^AQZf!VOynPi=B$K zRStYl?PM_@Ikm_h%>A4ehUPDz791i@?;=cBw!)Lu*XkJsyWR*l zQM?|k;y=&ve?1bShk8$97Gk`aEyrY>q#<^U>IAF8WP$kFw+HCN_04ft)x{I7MuW$-5OU+i{Ty#Fhc_lFti@q^>#`Uths zZ|3K}FmJ!SsT4LSDEtDe_QlZ4i2M?t`F}!)F*zRFdg*_V@9(R9?+Xl~J6GdPs~*+d zVs4EjDWy3X77QDq%gNh?9k^ZJi6Wj~@Dy<`=)`yye@>s5>*R1A#6FTx_yb%?8A)bp zj8UCYzmMlk!~XRmg~nxig|6hH4_Eu5@PB3%gta#BVJ8;9<9~_O1a9)k-5YuW*|z(E_5GqT%Yp@ zP7K6p(-M9>cP_N4j#`<;rWpo511!9n*FNNM_R`z+bGH%hstm)oUa)?DKVx1hCfA85 zmAy6M?KzKx{7H|S0AX84SJylEn~h)V^xIFkiOf;$+Hs6yC?JL51-kh2NJ&ZM6uz76 zl+wARmUD(cJe`D&C+E!GE+ud-Qz{ip~57yn)tLsy= z-Vx%*$Hm6RYDV&2(g<$y0F$KF{rA9N-jIsg)Wwk?ze(O^E1U~R7kk%vg){B-(hQ^ly{jO32-+DwN z_3aG_orUK7EtxICmp|gJ3$?)I0~nP=UQt`!1t(7oGqk`*zJvIUViF<_mL1X+AvyCK zNED2cIDb5}|NY=x_E3{eFA&PN zUOE+2ZWnLVxb4lTdPzVih6C)e{djHa3$|FdTQ$xnJb~hR9G|xE<+KX3$ltJoKaKxv zKYxG6SxD!4k(IsR@u#zeJc%F@=$W}_+PP0Xd;BF{YzhF=7TtgC0_5ZJL-Z8|KgdER z^M_^s?*_e34Ae~QCENvC#+e8Uar{V1_2?8nUMds;drF1?{5GQDE1KVB{C{J?y0gIg z>{k?t{=(M&^m8tRq~ciWvlhx19ELv^P>&}jN0v!1`XZUeR6&PL!Zg5su&->4IC+wu^lsCP zsb6yerrexuh9-VSiFz?#%^NIsdv5x1q7k3Y`~IAi_B+CtI)-B$VLFd8680Zu1zaG9 zY7|ud&uof{A2gn9lsCLEtsVDHe9G?s-cOnE<~t-Wdi(BvtoxSA4;UFS^zY*avQ#dA zUAz#a`MJ3zLCD3Akof)&8|PF;kU#11#fS?p%a>c5v_#%##bj#tOcK_AfPRHNxM68` z#RRd|*~dPXkoUbJ6R-CjjO*90AF+J*pCCB;(DdwbN;c08);V&Rzy1QN=|Tbw%@21N zu>`iG-`CAfGniU_QY8s%v7>***oP!(^RhwD3GnwO;xE!73kq>^ZuIrN26Q#ep|A^2;rIHw3c>Iz)+@q01Q0+kSV^0UDJek4u>*+nDzd#iHZhU{F_F6j$)uDAnq z7&&?kaOY{*{ZA~)N8yjGpz2W~y-EZq+2weS&7i zU)jj>h6GLzf^m&EBgXaThnm&ms0}}i1utpo4I}|uMYCFI9}iS|``tI{)vxj6k%QV_ zn_Vif!s3T=y01%6ka5w;0^nnYFGL7CkXRYEb>nV$qs8rYzh*va$_I7X5*ivU{U#mV z-O|_g$l)A|FDX}Xkl4i6`tktdcXRNE?@9v;DbLUl->oOm3NEA4SdJLTNwE>V)|}7# z^cDv$9eO;+y1NG!z1{%UBU9-I4;}=5xOBj@Px7@^?fHCP1l&cwrOOixa`g2aa9zrq z;a55@`PCnnR{D>6c+^25>@;YW4hi(xbzd@KHh@Gd|IAeVVcH-$$`vF2hJ1RX(dn*I zOLv3j|H4A}0}GL%uW$(qQ5)eyLhFYwWNdxO(Zd4kAdS-3eSt2DE5?Xpy|MPv!7<{V&EVV@GdCM%H%yjoZha|S8OpQ|_@K*= zv@3jlZfIroytRZAi81*qTX^r>-~ROjrKr~5V|7g$kZ<^a+f~W2v9U{2yLmu-(=L5c z^bn{9PU=08)(hjqAKJpn0rRab5~u!TH{szCUBW-BD|@XhQY{ToRRv^1TSmrwT#j+~;tH0cb1`q4U8n>HJa8 zfBwj(%6K#~2in-l@pF^0Bjim7G$I6Zl-00#=ZW)uE?1Bj)c9)Ta{U5tkCOzJp%#=L zrKYEEOBM`5#u8%+&B4A#X0ku0%}pDUK798Do+LE8)YExO*7Krgmk%fa(YjgGNO}>X zRRk?y&Bui3M?vg!C`pL#uI`lN>kyw*6p#+_MqF|cm93VxKC?AZ>M=x9M4!LM(jXpt z!3gTX`5t>U$}MvAE03CNNS@$>RN)5c(q$bhX+;91IoOu-4|y=gCc>{4t9&+Bo;2mi znOz|CQ)3Ym54;_3?29mrYF|`xbOt|eSM<9#!(K8mgb|vVU%i|;dgcr)^R;`$Gv{qG z$eOQ1mnhTv^CkImTL)J9NAZ+Q`n2Nk)`3!^cTo62N2s>EulVP1VuMbHex8x8MnOqg z923QccJ{tVZF@+3VeInrAJo5sfbIFbOu;wy_!Uj}-nj48>0q+2(I zfm>&I!7P3bDg_DHKnoKb++Vb)KMxztg)0^Xba(YGF?&%zxtFJ5UC4<2mg6=Z#*iOf z;WrTMsCVbmn@*?b=;%0KG?kwU?eZ2LoaFH70vBlkptrfj`^&}p`tTqr-8H45|48V$ z_I;o7e+{;ufF<4>`+DsHiz3bgE#Syc7!6~GKKPl9A*>Sat@*9%k~~)nF)nBF(o}(} z^^D!cAwJi_{pC`LNQ{b`sLoQVCzdBlDy3HkZY0l74wJfmzAB$C?iS5g`LTL4mJ3FO zl?ENgMQ7=^E^IdFjS#R|ytbNVbU27onsjKlhq>6H| zv~3AzrcxGW z&oz8f?Yj2$Y1d@ftpJvp*xsI8^3`gHjHOVVgcSla-2{+5j;&3^BGn^Kuq_+4AH4LrCOMSC_=( z3X*Dddg+@7<~!9zAFuZnZx1d^ zg=t4E^)2dW6uhwCB*ORhS=#N8*N!YWV3ae0!?*rQ_=8(%*}!w_n6PkIeS=otpW}*A z@mkVuP06hLm?dIhjwtCd-aoN#W9bUec2er|* zlq*svOIE|TqeqW6{I>NS2ed-G7BWljl8QxjCvVx_|2~@>jarZEq&`omee~`9?)cTw zofi%j_DQ3UeQ%v>%epJTu~}MGd*ghQ>@tt`oh4<;%fYf|Os34)XTG$`Qc4ASXVl7x zxQaU(M$P<)vAc-P2bYz!@-UI^i`@31I&th@R~%+cOLooZyQi47kJ$a4eFT-mZ`BA_ z9v5WQ9TbkRP~vuWjXC>p9PQ;koST-?0G(H~;vxF9m~)VcIWbNpn z(n>n_#=gaR%VSLlWeNLupxvIuo9$ZziJtz^{emyg%g^<7p7FzLzWzdidkOZyJvSIU z`_JMw)Q0;r#Um)`>1knDc8e}c!3NGvFd1K zAZM!(|NYp}aRi7sbcxd@d<`s(gb^SY>SJ^S5uoyLFCN6K?FtYj~xu)jUO>TVj|$ zUxURhAeqMH;C_^gkD?f(|HIx_hDEu({caHzMa7^bRS*yfk*)znDG3P$X+h~matKE( zKp1HR1f;vWl^CSEOS*?1YT&GKZ{7dco8dh#AI^21{cSECo_W^2?)=?_BXPN+1wld0 zPvk1-W9|;i*RUV=&yDbOT1V>w^)*YRZaGo!f>0_gl0e(UBSQsvgl2quUh@0)PeSvikZ89!o?X8(9RU&NB&oRXW`KG1g zx!tDQl%q3AX$h69f{h=WCPz_vdsl3FH+`4t*_+mtHK$+F{-zynHYXGTBkxjo(V1JH zZaO;^>~F)w>0)GLMB!X1x3uQ8?g8rEz?z3hlW$w}Vb3KUcRe)Sda@?{o~2lKsneH& zdQea+#35;s%dQUw!lJCBC6bPdOR z>9#WKG&VJLYjSdK5{Fp1It@H;Ygk$u>1w;rTA~(^GPgO-S_vIQr?y*N`^-1F^L?j% zO^^t5)zaq0QAnzD*MPH zjhDM)sRNEZCQvUzF1;4VAw9muxA)THICFm#IvK+Op;nFd^JcRXn`>Q~X81k@B7B2b&2wKk0XM-M8{`MUYaZdtqs>U&L1jM_?#R*n3CHEWVkPPlJc@xetLXB-vm@hfOgczve}M&WAlb*8sL3r{(#^H{8G_<7A9 zqwpZ1vVugfDT#eFe^ho3CEyDFnZy9yY!8CHUXLE9MtHU%m9r~S`N{CdW(k=?^eZ^neWSzStkuK$u zCgDq0oq?eE=ny>C6o91ID49Z{keXo@y8RA)^9fyoGc^_3wSN8zT{rf3)rFbyI6A-5 z&0|fW0*6*JH2uRjQ94z}r?rAbn|O*ET41qA2CA#{{K`bbY@7B)`czV+%TR7ck1q0E zPL~S%BbMD;=vz@@Vj?0U;5z7m^6ao1{_=KYp$O@DBW(+cQK2O&lgl?!-91IyU zx->kESW7WVK1fGa-=%MMGRIx7*Cqs29UD?g(-~Cg09Hm{7bR&5Y?|!Dk;7;~yX9|j zBaQq>_u14kHVudAag8+ZSB{QLbA{MBBRs@YV1>-I*E(L*ZfOf>PaYIOnMZZ?8_0Z?Q}bEs0Z8h=v&sCi zR-rs@>+Mint5B!Lf~D6@F1dY$seHa#!53wAB$Nj3=OFV(#dNwxcCn;C;<;f`OPWfH z1Q$B`)UB!Z7i+}?VOgQbg~YO?b?v)|3-r=6KIyJ{VRp1C8e2+I`oYu{gFZLPs7haVp&9`|Ivq8vZrIl&5NzVU#u-;*Rr4&}va~lOT?mLZ4>1s#%Gk^H!E- zCGa^v8cHD|^GZxcH`=a|tlhBtUhaC<4WDI42Vnq3J8Ud|&*-DKbxWHsQA)!RTPR*x z*jRF-N2uElv?Xx{s+tz$gW)wg-RVzC$dnT8iU^RT@SWKfd3CegYv0+MAOlVfAyp-I z-ncD}+vP|=s_cQ;`Mf>zxtz&BZC~lZ-lPl(o~9v|vRlsf)gN2>%>9p+t)z4#_tvL7 z9AeykUjz`C=7|XG?cm201{#uc8a`FCS{pCfQoH8M!JStr1}RjaJ#x(;mG<3aaMB*J zPW9?S(|dUYRVwl8OP~-(0bF`(H(r2nT}?E zC6(*Ws6I`^<|G<2xDu8k4?IT?CU%GCKTOhEm&PUPo$_t@G=8Afc+ezz(Wz#v0;|n5 z4wMVtxDIL-o8hCALTHJ!uYx|DzFHqD$0G6f&XozyDrJCvguXnVnN8RUD)%<$trf)_ ze@4lLy7|1pa|jA_l*Zee#-*FO7EAXgs|Z{cOT_PRv}&Z4tgEP5&952MC1oy*2kKRS z7vd{ag7(d*t5yq{7IVq$^;|DGixMZ6e5q`Mk+H4Xx{-}hFLzpoYxCB%2|}}Mc-I=# zewvJgJqq!cd_L#=Qjg|Ira5e!7mERZTpAd*?0d2H!h)Owx61ym=FNQIY98+zn%UpS zy8M6+HWuhb!m2@GHCgZ82|%d37!c|R^~Vb!Ht57kSocw; zw%GV=a)mc3`PQ|Lpapjyt&f5>dy1;fViz9xPDqwEp%>GsW3JC<=wiH*G#8UJs@Y!r zh5XUfjd^6FflJ@$%0s2S5~bUBp)S@{gj$TsNzL}!{WbJrv&e{;7zLT9 z=b8I!YdKHA!1tx?pZnWLaJWVy%XXP)@}3< zBw?}$m=^i2N2=e^C?QgVqmluSbwBc;LWimY%T_nVzdoq7m;RV(xwG`uYIH!g`zCs%(&TqgInZ_@_Y{4wY_)Zr1R8FuLe)pK!-{hYEwpU=rL9LIg?ew*6)GNe-Iw6)`o<}QeA z`c(P8a2@sHz4hqvGs(mfHS_P(3LWZblo1BZkbW3KNa@&|r<0cE zc-`8`>=Zc}*~pl$7o2wS>b&(Pky4cqwKJ`Wec7xR@65aQ&*w@W8<*|%abv8!qKIze zj&1R3Yh8k2Qp(h;i?T8~F|o0+JhR+o?;E&>m>xQ5x{Jc8h{|?5nsib)yTmznqQgUj z6I0_NtE;Pd=G%R>E8m?L^v_+(%g(MmGQ`Vs>Kk`IODb)=-kupHDF}(@BelOx(8f>1 z#eJf1%feEku*NiuWbarRu;$vIc~8s1>dy)0>2#Hq*ReLe2iAwV<91;9%`46pTL#Yv zQ2LsVAD;!dMZx)s(I$CKU>N9K3+@S@)@nF)U}A3Cy*|?{Yvb&eHwkYvp#y6Jor0TV3Kl3q zl#OcJAn%i}8$1(J)H!7-Q!5}y22}EC_TcDx7N|}I7Ntom^ri*75(q?SjzY*de0e~8fLf=T3{vL@$kMJ2+P&B;>x0X8&RcDt(gd4#fz~Fy-4!yyTQUA%ha8p z&6I}|)>TW;Qv~u6>hJDM@Qi(^2nezPHX;Mby$P6o1+pIVr6Sj!fisaXv!#+<$HKp6t)Et%QU)-nnaKvI;6wEs!U1cP zAn4NQr^@pUbpc^SkB)i)zE9HzafsRAXf~L3;&y^Mv{OgD_a%{%+7&OMg?NBNpwDoN zUbxlj>@-=9tXgR*Yu?R8OkVU7qI;zLRbdum+N0Hry(7{p;9zr@By zC(Q(LZ<2U$yOoRA;0C0yP{V$$1LOA<&1giT6^fj$zBxmld$&)x_#+tFt3!Tx&KPnLn4vyvzi6Z%`Rc(U!#M`<0!C-F8Kq0{hEM)TZIXhgg!_6o zT4CukfzxHIcuu=3kR^wFkH)`5{(s3K?8^0YP#3}y=;4^kDS@0?t|i$f9?z81r?^z{ z2Gefzv?#gbc0@3)X&Jgvv9CvW7F9jf5+&aekTj*Fw^OXCAh=jAl7K)0xIV$CPJQE> z>_RZ(a8yCTH7e%~&1Yvc^o8oc)`fE*!7;d-8`)IHTIQIz>3h>xrEqr%t}1VW5=0I% zK2*QvKA1C3oDf@rD&1D-Sc{!{S3%&3fdh5x?NSj>*!6b#*Y`SM7)DxG?$fwwJu_p` zO;9$p+xg#Zqd!rdA3?yI_b31J^%7s z0v&lo@=#qP*T)ZwRPMpNqK#7_{v^j?CGUo=wR!-J_S_ zt$4LGs_-y3m)O1w7 z@Fj$s4dTKG-EIfZCMNAs8Hi|*k&};PE6BLZMTy3KRKb#VoRI`+P4xkpxBH^%FUp$< z#lMOG2;qmH-oMks7D>9;@&Occ)Xdb79EGR=U!6vjnH=0`Gj>J=ro8$+CdWq1r$HOfMalzjx8XSY**ET zBv=Aj5tc`9A)??AVvuH`>QVuzwqBeF2d3l)w=XQPfy)3c!@SN>>7iVn6|R-zp{1$urmMCZzTs zF98EIVIeU5Toh}VXJ$^Oy31GP7#WgaR?-@{~iHdjIvObkHU^!`JoZ;=m`KKdALVd4aT zyiySM7$>d~`4ijOPt>`n$aBtD9bv)Yy!_#nR&+st zrt>Dpvxg5?YLn7pdu6#oJWpP%j!R5=+EVFuQ9-&)za>&RUF&vY?;znBa;{|VaYO!- zinmTjwaFh{w*eEih}37T!UqH)i@PS<#nl+p4U#d7*cKQR-rpH*_rO?_0$;2a_&(lK zcci3X0K@VdVNbm5?Cj{?X)tyM@V$au@45FN-F;U?q$0x1X=lSC0hO-xG5PmnEBU?jGb2}KZQUYg#hRCEOgUAlNEl6 zjb6*Fn6C>Am)>pmZ!|i+x}B{lg6>heu&^{rP2Dzo-HUfij7_&bNCVD}g&)=!06$P6 z6S4FFN66qhkngm#W#D_eIbON=T8isd&j`&Z$vE^@nCF%Bcc|!4R`=o2h_dJNt)YycOkwZ%w!R&>hS+?VUEA~GWV1x!H z5i>{Ag+_rhKF- zB|+4WpOC&I63xW;9DS{?>EjJ{f$%0VmC0%d?o~zIE?pB6Ow+@5U7>v=@zEqkr;@Fi zWS(LEq4~92lVP^E)u01sN~rYy&9MKJus;ku>c(4I z`zi24Chv)4i{>i0WcJYBy{zQOF&Ps1WOYN?bZ$f6(2((s2gyCjmUz6n90b!NAD?kfv)=sI}bRBe>70Y18!V4P;Q)9tHe7=Fo^-xx+c|2!L%E1 z+*hI26Rg_;Z(;ktar_CGu^`3QOMi8me=$Z{`amLo2dS)>*_cA$#%#k;Nl6b*FM%R7 zCP?dEjer6pb9BD2(!6>~$|qsXW36v4)Q1VirQJW>n@?*@-@pl?5`B|~nCmI}4KBMY z;fgEVTwIk?Ez^wwhIj5g11HK^t##43UgwYl`KBN%bWx1T+PDmkIf%=bd+0C(mdQ2o z&tR)AstEzI2H0kdJ>bTP$>axxvOhXnTp^hsX}?B2;GVQThvQbhe335trJZ$WGDdi_ zM^4qn^~x+;19v7Z7Nf~soKh;7G^urI(e=8NMhB@yEQX z49^lhn@O)wolPY$wWA^1F7%cKYCRJi=YItsfBC@>)2qomzzoGw=Ls`C=5r2iOn6nn zk+zAFdfwzEr0`n%;}{%Yx)^+qATY9CdUtszT4MSR2^Dm1>2{e220=;`j6UNd&iOGi z^JH`hD<;R))0tf5_5+3;zoZx7G>fT{!Q^XeA!#S~2f82N1BB4$V;qkbO2Gia8Q3vND?nQX%V2 z?Q5s7ygDn6=bGiY$$t^QzqVTjxTgjPt-HD!ZmQ!lvfKp8sURMUDn-~)+aW8nWPpkh z4Jk|>%iXJyVbfda58|-UmI~&`Fe&q;lQx9PtcPXGGC=_L*kP}4ycn=H3O4Jgg4W^+ zlf1!}0t4Y8`Y$I4gqG73jZ#4pBVmT{Omg{g^3rQ-FEKNJ<_bYfIXfG(2ptk)!lXPE z5x|X;3UzFS>i{jb^nG3^z)%e!GIOW~Glaj=PK=3K;_Ll$-NF%PG4R_L>F2muJf2M9 z&ky(>f%%8Kp`3wC{@;lAlpLSlLVv7P#yQFY&eA1Iv}l2#w5?hQA2bn0bx}EbI}SiV zZc9D2q?S=(Bv;i`Inkx!OzUeF0Za}XEJmp0n@uihr#RYT4x-q5kZ{=?#a{N9U@5sC zTuh4hJsIb%;1E@Z&=rswCbK+39jLZ7nLn3Vwx-{vVO}ryHt*OLEmBqp-QK-+u}JK~ za7)|Tn1U<xp8_5dEH$1cL832VJr-lt+2}lY7onU&KkcdBQc4u zg2dzGSG#Tk2!vYh^hW#~y#Cm`>^dk*U^&Q83w9`CsuvP9(`7jy*G6tHKrgqxS~f0j zd2>y#-#AN}M)mZdgq1o5IJ=%C-VhTQs|p3P)H9sfK2$w)!ud8iM~M_i-u5EoxKDKHrdEzNV^X;ClxMd-?{Z1$hC0Dk@BzkMiw!}P$=a%s&$&;jb8E#2fE ze^=8lLB5#a^?R~W?2J|Ag8ZEuN>yO^?OSfyEwF}S=zQ1SqF6!a zx39{H9g?Xk_Y_#uC#jm6a`aOkdE95+c1-PcW2ybhm1|!ulQFxp?k=LSUdyxGAXar9 zDJTfp%8;#AO3i0XE!!1aniSf*7Qmt&a6?VcsVfkz?y%bDS{2p~gjjd7tC9$wk^Ul5%jv=Qa&+UUhZ3gwyC{r|a{!=Ykq> zz?v76r?*M%y*yY-X=Qs}wLg7AuH>{fTQJe<^y-68$$CFc7X@k%YUa3fKX5YfQw>PHBUZh2S%b+p9FPZc3D>Y>#%xp&~W`ti=3 z-rlDqo8o&zTk9Q5!>$CWw!sJi-O+B{*%Xyd%ICvJn77~9IxdQkNEu9hHFMc6+E^Y} zI$icYcCyE**R0Gbc-_F5`NQO1!bKv|oXv@lDw{q?)LPZ)na3I0_Z)}ot&ZnN?bd~u zZ5pXrj4N3&XO9QSdKe3h@;a}C3{}5vD!YjyG_ymOAyS(LVxFPF7NZfTxKFK_E_Mjp z)KV^#^IJIOv?Jq?0*(4x$o}*?jW@RE83Q{Xw!az$qZT_aYD#U^6_Lb#jHq233*vcSGRqMGp8M^XWO@x4N>Ka=s$wJnLmq11I6ile( z&cS$=qJ6D8yz=7n^0>XAlWr~IdqTdG-Jq6cT0!|89$eL+F;^S z@$E;dCYR`oXDLN`gFjIO9bcc@8$kzKbDHpP3dU6pA&avNhLp+UwgfplXF2vA;{zK3 zO&$!5CAkXwld@hPCQpmrz7ex$vL4hDl2`B*Z!UjyeQDJsw=ri(Xm69%q82LK^pL+Z z(yD3u0VtGLFL*<}l^K|tx3K8KZ(PE8^q7+7$^Z`#p|hbfwN}Io7WEkjG2M?SyMgm* z4XTNzAB6S95D{6OtWc;@&H}}~rY|STXeXM&!=aUZaC^O-1(ZvTx=`m=g77`>jkUBr z`mRO89I`aK%{t4DTt)|3GTi4t>hor(S_i!cqwpJrSo!$*!5(uy-chHxYS3G&VohM+ zGWGfGRrW^mX!fV(9=-|AvN`#uvCQ34z5xIT=GXgIa-X}L{u$rTm$ufRS?szK)lN-s zW-094hciy$sb$?bV$|q>{@=p4n^>z~Q*YWWY_$+-0OlXX=#H)rlMBnC-E?9%zwK_9 zG353d)~Q+P-kS_Vecu?1W2sda3jWHZKTL@fV8iGeO9E(hTb>)p0nyl&d=)#U+Z2YL z`i7SlAee6aYH&w=WunoOyKMSQ!HHzRs%gAC*1lxR-cA}>FW|+9D{mVA*n8N2|bG}3RxzrUYf{+@(EFtcLLev!tAY6==%c@cKz(Qp-OudSkHrqFgM zLG>l1(2dM;cQ?M$^)<8gx0++0R#MD(Z*{efTk?9zir|V`6(iqgjf|l}iJhFBqAqc1 z&o7VHjlSzDxn62n^^m6X%mS0Yq($|mIMMYv(h}jW{!}$rknZqOv$l8F*xRBJCHi2o zhrT~L&gx?AI5}gqA|;d(izm!GC(>Lfj{?Deahq94U1RYE*FwBZ1DP264(G>Mwk$Z~>IZvZUjswdyu6PQvnb!R{6bUG zrM6OnA!T9VL?v{{S7_{N2>bTw;^~^aY73;mmCSPZ5*0v729^rBDK5p8A?xK{FBb1w zi&grDWlskMH0^>8fc#M%;3rsmhJzJyp?jaLXz+(G)mca@cu7H7d&$a)-GwKHz-0%Y@BvoHe&!xCh)L8%8(btoq=w0Ox?2Avd>XFW3q?AsjVXt zy2qKgB)HD@B5s|Ty3P@T}uclZfz&yB5Y1z9h8$kIug&iCyFp!uI?_Glbj z@m4gF)ukX~$Xa}qRHk<=j4Z!>ZZ9tcvT0C2rMc5OezZbhU)bTM>#;<3JF{GDcfqcF zdHaqt@J`1Gm^`a%x1*A>=@Q3jgYVgL$iMAalWb42inQvS;~3Vl+!H)~M2V%EnLR=Q1I zgM)+0z&__s4njwj%C@BY%_~?(wy1X*9;MEGD`^z|E`F|lCD2-9q2F_Abn{PM;c^vZl+=?Gs(7)qZcmh-YRn^JD*;J$p zRfjg5P$|hjfAK3pb$@}n&Lt|Bt+w#d`mClpK`wkD-c?P4Z|Xyd=HZ|ND3H?9T+P0t zOU3`RLD|&EeQnVF_Fg@_sP4mhOKA3rAZPHb;=W@_531{QLxtDxp*fS3?!}~W>pq-q zValbEupQ`PxF_3sX}rKA3i>^AYHCVc1QL7?R?l;lJopPv56KSeW_{eqwG$;wz(=2s zw+TKr*1zbXxh^EiX--(RJ-_`Gco%Qs+Lg_%7}dA5u*Y*nh98X<;kOJMa9m;b7KEUu zo5m>EwlXaXD5wh-D>CNU!ysEUPAzrj<@AH}^ z5c$Rq!SDU{RhDiBQ=~PD5ZW{#tF%Np#MMi|dDk~K%7)PEK^0cLn_()*<3xX9WB-JA z9*Dzq%lZ_>@j|dnjfA<8)Rac>e-z_#Y1O}bolNcGJq1pm>Gpez_ZqfSKm;;PNAZ|3oquG@OVb9Z$7GJ*%pl92 z7L{diL5|7ysBlV`%{oulIEtk;`^nHtk2k*Er33#Z$mG1bwR!t#vw3go3L1ef>Xi~YPT&IK*t@Xu$9IBt3bN>7n2 zA+jd*7K#-d7cxC`KzeQTqwak7=#ahf4nhK{0)o1t5CZaFGsaI}+QZpwt%jro=xX{j` z5-3%h3oC+{eGh{Q2x)A(i>w47y2^G_cEuKsxSooufEqTB+x@wBNYJh88uQ;r&2Y^8Mlig>)LSANLON zgtFaFebu^rh(3Xv9>HAs!q%79ZQlhsHyn!hNYhK%zk|q z31X^|V5Fm9*joK3`e(aq(kdyBM&qM)WgStyW>p|No>OXx);Y(cwMSB=0bO8w<2`%! zl2N%BSKGz##aCc5JZn`5+)?K#a^8kg)@g$}3+$bu<7wrJ#~uCW$;+=6jxyGIB8`6He#Rhri~`&?tz>W3{1Zqqzda%IJ)l3$vFCf~}>ehxgemvBhV6Rvy;eKGQx6Gjw%` zDODH6eb{I@c)#<) zw85x0h6$&l}9oeMGDs_f4S zwO+UX;S!}%GlZHV6JWE;`;My@UcybR>dU0u*Odd%$n$gJJ`>*LG`WGIF5?pN?hbrR zZkBr_Sy|@4oq%1%Q%v%=oBncQUC4B!iglEHXu}2*QjLK65XzNx&x9*nXW-X=>La4wu6aB9>@5Wa^=1uXl%#B@Bc9%fgUn)u*~QZ&d}&n< zZo+jLTBZjtTG$fX;w{YGTT0SI^UbWynFVD~;}bKz8?YT>UUyk?UV6Ej#R+zqX2^2s zKLv10@!FJ5R9>F#1Ebl%p2_JQf^hQ3=?#jMJx&m0Kl>=|Ra-H2J#&SCsjyJg?-c50vW6&e0&P=nn^?bqft+gLP# zaZ;Vv>NFA5pzA(=)T^v&XxCY<-*yO{!FYyGImh9l1KNKoOh{p=M!vt!wWlsMX$c1JzV1LQkXgp2=>7sdrGXei+C)HjP#* zehT7t$KJLuGSyhP9>i+0<|QTF?9}wM=g{uD?b7Vfj%>fr8#l0CO5FnEhyx9}?aGziy2R4erF~kF=|U;;ODFH! zfKfgM+Kz)9b#CnIq6tT&<1~EbJAPATjT3CJUu@~>B0&w=YVOQe4^_{dFm*%NtX3vl z>S%4{G$oOb=NEMnB@vsR4>2up+B&v)O)lk8*Sa1n-K}Afe8f2w`u%hAKlNHP&a+e4 zzNvK!H~5(K`)8k+`gJzE8`P6Jv^S65kIGoyonlOJGrWFDqtiu0Rj$!~ z7~E;FfBnrb6E7;y$!wo}y$;E$<`?U&~+r*zD=o`rw1E zSkk)g?}MZMR(^ky`P}6XCl%R#{`TLAG3?Ca%O2lJ-;I#kQFY4_Z?YAeL^NS3Z~gVG zSWwN0P?S)8`-`%F{eeymxHx3PON&wcdjikh-X4WerCjQr#WtDr=kF-c0CdwZG-dx< z!y`Dpv=1aFDR)Rq>xG9FZ6>kt*dG)OVuUR#GC!rnc851Ff1v$VUU#J_rS~nN=lAGM zL!F?SbJ+gs4-@|WHlP$~^XdJ|UVnonoCnBby|SxNCWW^HpV)XTue0mKsMxSx{D>XslYae*`4_mhw?z40 zwubFP2G~bdpkG)Ub_Dx=ELz$}^o@scBz*3+*1QxXlaoN*8VvRNzH~X|rI1RF*>EUJ zI2PLdPDGdlF-VYpE05j(%724pob3KUfZS@?yM9w-gWRo3m&V+n?@tNX$Vpm>m2ywE`=@KiYbH{E7d~%+ z=Ro+nB7=`Ro@RVpBt>(*Dp8cv&_8u5l#~C=2I)G@^2yTr9DFFYMx38q^8}~D8ET-M zh9z5g?()qBT%YLkMvv0my&PogSy1uOTjj$u8K(9=cy$3kYSGeb&tKEe4WV2ko+6$ z*rfUoNo>RT8PD_aQ(^lv$T58l`kmvd zuYEvr2UX|1`tpmWfAc|08+T^SA$exnt+$q_<-C)eTQ%QBy#i{dX`N@cZfGy~Olkad zE?O&cTrF=OFHzGA`*Owzan^KoU!>+=4$KRz#@H))Y{qPsL_G7kryGRo?gTIujrJk@9E(rE6e{7_Xt6r&qy>j*XI%BBOuKz*oLbdAoyYymCTJFmqME z?%#~se_wz3#<3sj4kenC#y#QJMhcgo751i;-epiqG{}ET?FrW$#5UIQr#CBBZpP** z)})N(;)3L1v=UbhI3g+5;{C2lw(t!{O~3ZcES{zy!x91gG^sZwK{-(ruEPG7V41Vsn8A;#YPKX^{8CfSW6&HDOvIEyVraNJZ$qFP=<_8 zilx@$^39Te>=q>5yuV(o|^73BC!NM;S3_RNa@UEbR9<=)^gIY)K&Ps08* zB$tJ?7(umx)ay*n|L~dqMcjT|p_U?!0R-`Z27gkYV7QMd;9iKhmr1SE-7}!K#ZUw91#%tdulS3xmyEX%= zC37nmgS@h@Nkp=q*|1}nejijsTS&s#cOoKd@YSzECwUQdOZ>7_(|iY@O@F+K4&~ zts|25>^EvMG}=B$e8QqCeku8*#n59C6SDHN?R|u#>CY~n? z{-NOXN`Qjfj`TV1$ibg!4j=sCPX=-7nfld<9EAkK4Y?Gu@y;1&BpqnNGN1;{Nky@l zc5J2_H`Wyt!+3)dJue5vHgmYVgIonWq}4s1e%y}XBT7se2X6_daMw_{G}LK9)yB=uADn>rx}uE5tMu z<#N|Wl~3$PPP5iX?-E3;(>tYOhBP6qgY;nV0^T|OYl~B6>^X_e*bD(K-2<(}3ijaF zP^n=XDQsIc{x}b84S0Uatln5g-<;FW6At^}h4GqLv(Jw+1lD;UbIT^<9{k|FnKWnC#U)$%;_s&P5_LSbx+>Y= zLOW=@`-S_N6WZ0N1Z!mZ`*DS-U=w8xetOYl`@aB)kVIi?uk}waq;<`<6j?rNf5ZZB zjK6n{44tGaM}5?S`tgDovs1FTuGlSGiRY|pgdokpW*27MytuOKwu226{KN)%W;?E-*V-nR|bK8?x;=Y^A$LJXG zXn;GH>+tY-Cp0RlpTwVOmyW@;)HJiLZ>{<#u{tkC$r+w zx&biU!OvIpsn9S>%n|CCxlM0GFMd7Qaj!3FCwl3I?VY;#j@%EzO1U3O7Ip~(SG*Zi z?%Ut42^Sq(KCb;qb$2D2^ZAlMpgA(>m z4xsu(1bv>D&hhd$^=9oRvm`ZDbB0(gUIRJ1PJLsB(n8Z-u2Ltg zS2M6fZ&Z};74&^{6#UO0{mFZO@h>np44ByxIf3OCVQl!P2{vmQ`T6;EqV9QYPZr0i zc@-gxXad#+3CE+)d&=_~ep-pl9FstQrvkSmB>w^-?xa&{e)M3r;quLw!q?G(KFeqY z2^y(LEMu>5@!r$k?4_0_NNZ{|%28o*S%le{_n_reJ1%;P($pFZiy`JxmD5{BrZm!T z0x+G9qhExyM%?xx^**Hdm;bWIgt-3l*jQDEn3Ss9+cxscXlB=%Ly6E7jYNf@&#LV8 zZQs|9(O;A&2}PshaoGkJilA2;yNSn5B5+CTit zHWA@`#A-f4>Vb4*xfG`Pscj)1y}w(k6^t*{b*3twitfo91Usihje#VuXHj zBp%H2ij9FUmZ#q|Io3;**^+7F`}T_bWh`y z1U1$)7`B}qf7o~3RAK80(}qSYZitr11yYJaZ`yCKG`EH~=^ZUEVo8qwWY7Z#iFl`9 zS-j_-($NZApZl7Kg^EA<*-lxw{H-m{zfs}8c~VLh=8eObxd`{(ikpvBBgOLdKOXS< zA_Ivjf@|LrBydXhJ3w^a=X-J;2#+B=&gJ!B z+iivIIloBJdks8LeYtc0wx5H(9;Qeai0hqx-H-#;n_zviejs?D%{SFRtlz|P7k@DR zKTP<)7M6dQ@POj~x4QNpCj5Vf33cCPCjRyt*LR7#w-x`Wfn zpHko-|L~W}{lES2kAL{Tb&UUE!v9;|`xSBg!-W4Z;h#bJ!Px1aNcn%wGylLfe}g+6 z*opmz3IBg!!UwfBAEU*t*cf30=nw9{#y*JfqZ+@i)+GNw)qB5;=6`-TMGv4z^Su5y zhXt0#%7fxPMMG7rslo$FQG_`LzqYM23HskM%^&iq#R#&9QZczX7>hBymuF?Bnwls~ zD9pXPCj^bRke|-AV)tg4NhWzf_+(^d|CIcJUJ2F)@MxX^@4lM{))4=BOaE@%%7Xz( zI@{No|JQiV@6QD<=-0obd}I5dtsMurx`2~@Dr^<$#$;v(OJ*HJ2o|LPYq3-692)Zz zW6G@XrmPQ%jz@5sK-s838ymJ7>;oS_>odUNU%$}6(sFQczFL%u02RST=+e$Zd!+EN z6HNZb5Lsr_VWl%bjtR5mdp%b7u<{v~&56z}TEsLTuowutST}1#?0m9JUPFap8If=R zQTZj@6F_Fl!YQ0Jrw&hMnje80ZdKU>Cae!2g+U-&7i6BlzlOq2z+b=A{3xP7#5CC& zSqeR{SYSg1@dSt&a(2DWx|ro3um9d#u4jH-@85hd0g5u)f&?lJLwhEkBg^8gBeZc;M$a_&uOP#I>R+!tJckq0N#XjWQ6%ipESvB! zJGi9q0{g>q;9rz8!EVha)QRY%*oL8){s+hVbk!|Re(@2RxQt+DZE3&(tk++W@V~C2{4uU%iYCq#Va0=Z z=Ym)lZLS{no3>zsc_uORwGImW|&Q#s6=qPQHlB2kC;rgqGW z7Pk)jO zgipBaec`Z>5SSpIBTYZey5QWCl}>T&oc$N4{R=-)D-CBn1fSZ-$;uhqaDao8RsRaO zs-Ve13e6B6A-XY|YuSfotOQRvvI81yuf2e8y!!xu@Zhi>TyoNpA3T_UHP>MF&>SFh zT3BYMZncV$Q{Ca2mf{NxIU0g(+K&G4u{)p%;niW;Y_0pSoUek~@TB98l_y?1zIu6B z;c2&|R)aabdo;a6)akIwKwp$!!mT9^p)fL8QXe`jY=mI7`}GX&fi&Z|Sbv5kQ8fbUcpaScZy z+UM^;?%Y0Dlfqx)2mKDuGMw8u&mc5l0@X<|Q5v4bdL0MUV`4zg z9J*ymJjC%1)7~myZN8r`HupK{1zh>VVQR4OQ>%mv=b=+9!!*R##%Kt!Y^gc0T`n)e zDR~X8+$uV~@$U7>L*(9@N)ZTixH2^F2z3U}73$9s2Yw|iH>h!Pm2Bbs>;fO41jAuc zf(8~=A*kIjaKf?j>R~F#jT_C-e6eI(5y@m<#(FF z%}HFLwr@Wq_g@$g;w%bpsRHO8xMf~&F|q0Xc0;(rNACFH~M2iSedA6XJu9L`HYsujEqJ7X)#^Q(uKn3clC zg-Y=(Xy9$+*IDkxM!>gO77m9eCNG|wW814g=2)Ls2)RBrD*1WebyZlLoj;wXP$_VA z+b39tN1d|%&t6MYFN_ZyWM!$YsXM@AXSaR!9?dt&haM8O)0&{eEgBjE`pw!k9xU#C zTq@=$AI?Saq++5{MFJ>I+>)Eq(7$co=&Zyycvn)5q*B|#Da{Tx)?`=w2 zqP#F4p=;@ga&LnJJCl>`HA@yo`PUZy`;@2`!V!TED~@;FnC{Q{BjWy_XWdNpc^-F& z8yG_CxM2jK6eq`*43?bd;(>l? zA#+43$8ZSoQjOgW4*mb?SsCU3&cAqAl=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-Xh1oVMIK6N3KsiNIhqNH8ZK90zjRmAEL9d1Md8ZlGdHJE+HhdMYdBadujc3KEkV0uufsEUvYp+A3fDenfypGSA=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.159", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3nnH4yUNJVSyaU5DBlGw2yxc4zlnVvAnc9UOe+La47QVG7/dN+rWAgn4zCbqKk9bWFLDQ1Ek0r56EZE1Qo4UKQ=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.159", "", { "os": "darwin", "cpu": "x64" }, "sha512-iv+NRjz+t4Q1R2+kLdDbccSo3b0wedVJ9jMT3noznOVojZHzgkxVpTvt36/XkXV0rqIDQ5H18bBYIZZzOXu7mg=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.159", "", { "os": "linux", "cpu": "arm64" }, "sha512-FlsS5M4GCpzsQVaNDFF8dRgFGR3QwyAHZFl/xM/2Y2BqVBH+NH17RpKQSJxr1qr41QnsNkinMnu2iSKoc33hKg=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.159", "", { "os": "linux", "cpu": "arm64" }, "sha512-WvwiQBWt3tdu5EwqjpDZszI6p2uetYsw4Cxc6ptO/SmLIYXcDienP8nmirZdsZrS+Gzk6imgY0IY5mmNaRhelQ=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.159", "", { "os": "linux", "cpu": "x64" }, "sha512-uNPEC/iRzVb4bEdzs0KAz1zV7i1PVGEZZnJTQyi1OtgVa81sAoH/H0CbbzDiTsquKdaESf+1DSSEkUlfZmMUEw=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.159", "", { "os": "linux", "cpu": "x64" }, "sha512-kFH6RC2YJbPc8XWRNy/wL4YU7LzdJjSwAdH488sVzIif3q+TrvVrV5y/IW0+MLmta+CKIqtFYpGaucsJYvj7Eg=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.159", "", { "os": "win32", "cpu": "arm64" }, "sha512-WN1QEZGgWXz9GMl61QU6j9E+LEF5plki87bL2xsGwuCPzK+OeVPQU55pabuP8P+vFBFHUo3Y9OlTVyZHnUzmAQ=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.159", "", { "os": "win32", "cpu": "x64" }, "sha512-Ty4seccD+dTDX5hhj89IUELZd/LkxO5O43Uiz5Mo8ZJktoX38SK4XMZlBS935QdqTFLRvPL0hvK4Lt4dTOqzPw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.93.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@3.2.0", "", { "dependencies": { "@csstools/css-calc": "^2.1.3", "@csstools/css-color-parser": "^3.0.9", "@csstools/css-parser-algorithms": "^3.0.4", "@csstools/css-tokenizer": "^3.0.3", "lru-cache": "^10.4.3" } }, "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], + + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], + + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], + + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/plugin-syntax-async-generators": ["@babel/plugin-syntax-async-generators@7.8.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw=="], + + "@babel/plugin-syntax-bigint": ["@babel/plugin-syntax-bigint@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg=="], + + "@babel/plugin-syntax-class-properties": ["@babel/plugin-syntax-class-properties@7.12.13", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA=="], + + "@babel/plugin-syntax-class-static-block": ["@babel/plugin-syntax-class-static-block@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw=="], + + "@babel/plugin-syntax-import-attributes": ["@babel/plugin-syntax-import-attributes@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw=="], + + "@babel/plugin-syntax-import-meta": ["@babel/plugin-syntax-import-meta@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g=="], + + "@babel/plugin-syntax-json-strings": ["@babel/plugin-syntax-json-strings@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-logical-assignment-operators": ["@babel/plugin-syntax-logical-assignment-operators@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig=="], + + "@babel/plugin-syntax-nullish-coalescing-operator": ["@babel/plugin-syntax-nullish-coalescing-operator@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ=="], + + "@babel/plugin-syntax-numeric-separator": ["@babel/plugin-syntax-numeric-separator@7.10.4", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug=="], + + "@babel/plugin-syntax-object-rest-spread": ["@babel/plugin-syntax-object-rest-spread@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA=="], + + "@babel/plugin-syntax-optional-catch-binding": ["@babel/plugin-syntax-optional-catch-binding@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q=="], + + "@babel/plugin-syntax-optional-chaining": ["@babel/plugin-syntax-optional-chaining@7.8.3", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg=="], + + "@babel/plugin-syntax-private-property-in-object": ["@babel/plugin-syntax-private-property-in-object@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg=="], + + "@babel/plugin-syntax-top-level-await": ["@babel/plugin-syntax-top-level-await@7.14.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], + + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="], + + "@codemirror/state": ["@codemirror/state@6.5.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw=="], + + "@codemirror/view": ["@codemirror/view@6.38.6", "", { "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], + + "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@3.1.0", "", { "dependencies": { "@csstools/color-helpers": "^5.1.0", "@csstools/css-calc": "^2.1.4" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@3.0.5", "", { "peerDependencies": { "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], + + "@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.4", "", { "dependencies": { "@eslint/object-schema": "^3.0.4", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.4", "", { "dependencies": { "@eslint/core": "^1.2.0" } }, "sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg=="], + + "@eslint/core": ["@eslint/core@1.2.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A=="], + + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" } }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], + + "@eslint/json": ["@eslint/json@0.14.0", "", { "dependencies": { "@eslint/core": "^0.17.0", "@eslint/plugin-kit": "^0.4.1", "@humanwhocodes/momoa": "^3.3.10", "natural-compare": "^1.4.0" } }, "sha512-rvR/EZtvUG3p9uqrSmcDJPYSH7atmWr0RnFWN6m917MAPx82+zQgPUmDu0whPFG6XTyM0vB/hR6c1Q63OaYtCQ=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.4", "", {}, "sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.0", "levn": "^0.4.1" } }, "sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg=="], + + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/momoa": ["@humanwhocodes/momoa@3.3.10", "", {}, "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@istanbuljs/load-nyc-config": ["@istanbuljs/load-nyc-config@1.1.0", "", { "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", "get-package-type": "^0.1.0", "js-yaml": "^3.13.1", "resolve-from": "^5.0.0" } }, "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ=="], + + "@istanbuljs/schema": ["@istanbuljs/schema@0.1.3", "", {}, "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA=="], + + "@jest/console": ["@jest/console@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "jest-message-util": "30.3.0", "jest-util": "30.3.0", "slash": "^3.0.0" } }, "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww=="], + + "@jest/core": ["@jest/core@30.3.0", "", { "dependencies": { "@jest/console": "30.3.0", "@jest/pattern": "30.0.1", "@jest/reporters": "30.3.0", "@jest/test-result": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "jest-changed-files": "30.3.0", "jest-config": "30.3.0", "jest-haste-map": "30.3.0", "jest-message-util": "30.3.0", "jest-regex-util": "30.0.1", "jest-resolve": "30.3.0", "jest-resolve-dependencies": "30.3.0", "jest-runner": "30.3.0", "jest-runtime": "30.3.0", "jest-snapshot": "30.3.0", "jest-util": "30.3.0", "jest-validate": "30.3.0", "jest-watcher": "30.3.0", "pretty-format": "30.3.0", "slash": "^3.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw=="], + + "@jest/diff-sequences": ["@jest/diff-sequences@30.3.0", "", {}, "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA=="], + + "@jest/environment": ["@jest/environment@30.3.0", "", { "dependencies": { "@jest/fake-timers": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "jest-mock": "30.3.0" } }, "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw=="], + + "@jest/environment-jsdom-abstract": ["@jest/environment-jsdom-abstract@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/fake-timers": "30.3.0", "@jest/types": "30.3.0", "@types/jsdom": "^21.1.7", "@types/node": "*", "jest-mock": "30.3.0", "jest-util": "30.3.0" }, "peerDependencies": { "canvas": "^3.0.0", "jsdom": "*" }, "optionalPeers": ["canvas"] }, "sha512-0hNFs5N6We3DMCwobzI0ydhkY10sT1tZSC0AAiy+0g2Dt/qEWgrcV5BrMxPczhe41cxW4qm6X+jqZaUdpZIajA=="], + + "@jest/expect": ["@jest/expect@30.3.0", "", { "dependencies": { "expect": "30.3.0", "jest-snapshot": "30.3.0" } }, "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg=="], + + "@jest/expect-utils": ["@jest/expect-utils@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0" } }, "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA=="], + + "@jest/fake-timers": ["@jest/fake-timers@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", "jest-message-util": "30.3.0", "jest-mock": "30.3.0", "jest-util": "30.3.0" } }, "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ=="], + + "@jest/get-type": ["@jest/get-type@30.1.0", "", {}, "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA=="], + + "@jest/globals": ["@jest/globals@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/expect": "30.3.0", "@jest/types": "30.3.0", "jest-mock": "30.3.0" } }, "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA=="], + + "@jest/pattern": ["@jest/pattern@30.0.1", "", { "dependencies": { "@types/node": "*", "jest-regex-util": "30.0.1" } }, "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA=="], + + "@jest/reporters": ["@jest/reporters@30.3.0", "", { "dependencies": { "@bcoe/v8-coverage": "^0.2.3", "@jest/console": "30.3.0", "@jest/test-result": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", "jest-message-util": "30.3.0", "jest-util": "30.3.0", "jest-worker": "30.3.0", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"] }, "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw=="], + + "@jest/schemas": ["@jest/schemas@30.0.5", "", { "dependencies": { "@sinclair/typebox": "^0.34.0" } }, "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA=="], + + "@jest/snapshot-utils": ["@jest/snapshot-utils@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" } }, "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g=="], + + "@jest/source-map": ["@jest/source-map@30.0.1", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "callsites": "^3.1.0", "graceful-fs": "^4.2.11" } }, "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg=="], + + "@jest/test-result": ["@jest/test-result@30.3.0", "", { "dependencies": { "@jest/console": "30.3.0", "@jest/types": "30.3.0", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" } }, "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ=="], + + "@jest/test-sequencer": ["@jest/test-sequencer@30.3.0", "", { "dependencies": { "@jest/test-result": "30.3.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.3.0", "slash": "^3.0.0" } }, "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA=="], + + "@jest/transform": ["@jest/transform@30.3.0", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.3.0", "jest-regex-util": "30.0.1", "jest-util": "30.3.0", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" } }, "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A=="], + + "@jest/types": ["@jest/types@30.3.0", "", { "dependencies": { "@jest/pattern": "30.0.1", "@jest/schemas": "30.0.5", "@types/istanbul-lib-coverage": "^2.0.6", "@types/istanbul-reports": "^3.0.4", "@types/node": "*", "@types/yargs": "^17.0.33", "chalk": "^4.1.2" } }, "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + + "@microsoft/eslint-plugin-sdl": ["@microsoft/eslint-plugin-sdl@1.1.0", "", { "dependencies": { "eslint-plugin-n": "17.10.3", "eslint-plugin-react": "7.37.3", "eslint-plugin-security": "1.4.0" }, "peerDependencies": { "eslint": "^9" } }, "sha512-dxdNHOemLnBhfY3eByrujX9KyLigcNtW8sU+axzWv5nLGcsSBeKW2YYyTpfPo1hV8YPOmIGnfA4fZHyKVtWqBQ=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@pkgr/core": ["@pkgr/core@0.1.2", "", {}, "sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ=="], + + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], + + "@sinonjs/commons": ["@sinonjs/commons@3.0.1", "", { "dependencies": { "type-detect": "4.0.8" } }, "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ=="], + + "@sinonjs/fake-timers": ["@sinonjs/fake-timers@15.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.1" } }, "sha512-m2xozxSfCIxjDdvbhIWazlP2i2aha/iUmbl94alpsIbd3iLTfeXgfBVbwyWogB6l++istyGZqamgA/EcqYf+Bg=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/codemirror": ["@types/codemirror@5.60.8", "", { "dependencies": { "@types/tern": "*" } }, "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw=="], + + "@types/eslint": ["@types/eslint@9.6.1", "", { "dependencies": { "@types/estree": "*", "@types/json-schema": "*" } }, "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], + + "@types/istanbul-lib-report": ["@types/istanbul-lib-report@3.0.3", "", { "dependencies": { "@types/istanbul-lib-coverage": "*" } }, "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA=="], + + "@types/istanbul-reports": ["@types/istanbul-reports@3.0.4", "", { "dependencies": { "@types/istanbul-lib-report": "*" } }, "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ=="], + + "@types/jest": ["@types/jest@30.0.0", "", { "dependencies": { "expect": "^30.0.0", "pretty-format": "^30.0.0" } }, "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA=="], + + "@types/jsdom": ["@types/jsdom@21.1.7", "", { "dependencies": { "@types/node": "*", "@types/tough-cookie": "*", "parse5": "^7.0.0" } }, "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], + + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "@types/stack-utils": ["@types/stack-utils@2.0.3", "", {}, "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw=="], + + "@types/tern": ["@types/tern@0.23.9", "", { "dependencies": { "@types/estree": "*" } }, "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw=="], + + "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + + "@types/yargs": ["@types/yargs@17.0.35", "", { "dependencies": { "@types/yargs-parser": "*" } }, "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg=="], + + "@types/yargs-parser": ["@types/yargs-parser@21.0.3", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/type-utils": "8.58.0", "@typescript-eslint/utils": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.0", "@typescript-eslint/types": "^8.58.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0" } }, "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0", "@typescript-eslint/utils": "8.58.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.58.0", "", {}, "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.0", "@typescript-eslint/tsconfig-utils": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], + + "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="], + + "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.11.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g=="], + + "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.11.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ=="], + + "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.11.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw=="], + + "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw=="], + + "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw=="], + + "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ=="], + + "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w=="], + + "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.11.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA=="], + + "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ=="], + + "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew=="], + + "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.11.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg=="], + + "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w=="], + + "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA=="], + + "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.11.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ=="], + + "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.11.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw=="], + + "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.11.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ=="], + + "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" }, "peerDependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], + + "array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "babel-jest": ["babel-jest@30.3.0", "", { "dependencies": { "@jest/transform": "30.3.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ=="], + + "babel-plugin-istanbul": ["babel-plugin-istanbul@7.0.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-instrument": "^6.0.2", "test-exclude": "^6.0.0" } }, "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA=="], + + "babel-plugin-jest-hoist": ["babel-plugin-jest-hoist@30.3.0", "", { "dependencies": { "@types/babel__core": "^7.20.5" } }, "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg=="], + + "babel-preset-current-node-syntax": ["babel-preset-current-node-syntax@1.2.0", "", { "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", "@babel/plugin-syntax-class-properties": "^7.12.13", "@babel/plugin-syntax-class-static-block": "^7.14.5", "@babel/plugin-syntax-import-attributes": "^7.24.7", "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", "@babel/plugin-syntax-private-property-in-object": "^7.14.5", "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg=="], + + "babel-preset-jest": ["babel-preset-jest@30.3.0", "", { "dependencies": { "babel-plugin-jest-hoist": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.15", "", { "bin": "dist/cli.cjs" }, "sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "bs-logger": ["bs-logger@0.2.6", "", { "dependencies": { "fast-json-stable-stringify": "2.x" } }, "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog=="], + + "bser": ["bser@2.1.1", "", { "dependencies": { "node-int64": "^0.4.0" } }, "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001785", "", {}, "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + + "cjs-module-lexer": ["cjs-module-lexer@2.2.0", "", {}, "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "co": ["co@4.6.0", "", {}, "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ=="], + + "collect-v8-coverage": ["collect-v8-coverage@1.0.3", "", {}, "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssstyle": ["cssstyle@4.6.0", "", { "dependencies": { "@asamuzakjp/css-color": "^3.2.0", "rrweb-cssom": "^0.8.0" } }, "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg=="], + + "data-urls": ["data-urls@5.0.0", "", { "dependencies": { "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.0.0" } }, "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "detect-newline": ["detect-newline@3.1.0", "", {}, "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA=="], + + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.331", "", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="], + + "emittery": ["emittery@0.13.1", "", {}, "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "empathic": ["empathic@2.0.1", "", {}, "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "enhanced-resolve": ["enhanced-resolve@5.21.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-iterator-helpers": ["es-iterator-helpers@1.3.2", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + + "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": "bin/esbuild" }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.2.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.4", "@eslint/config-helpers": "^0.5.4", "@eslint/core": "^1.2.0", "@eslint/plugin-kit": "^0.7.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": "bin/eslint.js" }, "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA=="], + + "eslint-compat-utils": ["eslint-compat-utils@0.5.1", "", { "dependencies": { "semver": "^7.5.4" }, "peerDependencies": { "eslint": ">=6.0.0" } }, "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q=="], + + "eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="], + + "eslint-module-utils": ["eslint-module-utils@2.12.1", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw=="], + + "eslint-plugin-depend": ["eslint-plugin-depend@1.3.1", "", { "dependencies": { "empathic": "^2.0.0", "module-replacements": "^2.8.0", "semver": "^7.6.3" } }, "sha512-1uo2rFAr9vzNrCYdp7IBZRB54LiyVxfaIso0R6/QV3t6Dax6DTbW/EV2Hktf0f4UtmGHK8UyzJWI382pwW04jw=="], + + "eslint-plugin-es-x": ["eslint-plugin-es-x@7.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.1.2", "@eslint-community/regexpp": "^4.11.0", "eslint-compat-utils": "^0.5.1" }, "peerDependencies": { "eslint": ">=8" } }, "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ=="], + + "eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="], + + "eslint-plugin-jest": ["eslint-plugin-jest@29.15.1", "", { "dependencies": { "@typescript-eslint/utils": "^8.0.0" }, "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "jest": "*", "typescript": ">=4.8.4 <7.0.0" } }, "sha512-6BjyErCQauz3zfJvzLw/kAez2lf4LEpbHLvWBfEcG4EI0ZiRSwjoH2uZulMouU8kRkBH+S0rhqn11IhTvxKgKw=="], + + "eslint-plugin-json-schema-validator": ["eslint-plugin-json-schema-validator@5.1.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.3.0", "ajv": "^8.0.0", "debug": "^4.3.1", "eslint-compat-utils": "^0.5.0", "json-schema-migrate": "^2.0.0", "jsonc-eslint-parser": "^2.0.0", "minimatch": "^8.0.0", "synckit": "^0.9.0", "toml-eslint-parser": "^0.9.0", "tunnel-agent": "^0.6.0", "yaml-eslint-parser": "^1.0.0" }, "peerDependencies": { "eslint": ">=6.0.0" } }, "sha512-ZmVyxRIjm58oqe2kTuy90PpmZPrrKvOjRPXKzq8WCgRgAkidCgm5X8domL2KSfadZ3QFAmifMgGTcVNhZ5ez2g=="], + + "eslint-plugin-n": ["eslint-plugin-n@17.10.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "enhanced-resolve": "^5.17.0", "eslint-plugin-es-x": "^7.5.0", "get-tsconfig": "^4.7.0", "globals": "^15.8.0", "ignore": "^5.2.4", "minimatch": "^9.0.5", "semver": "^7.5.3" }, "peerDependencies": { "eslint": ">=8.23.0" } }, "sha512-ySZBfKe49nQZWR1yFaA0v/GsH6Fgp8ah6XV0WDz6CN8WO0ek4McMzb7A2xnf4DCYV43frjCygvb9f/wx7UUxRw=="], + + "eslint-plugin-no-unsanitized": ["eslint-plugin-no-unsanitized@4.1.5", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-MSB4hXPVFQrI8weqzs6gzl7reP2k/qSjtCoL2vUMSDejIIq9YL1ZKvq5/ORBXab/PvfBBrWO2jWviYpL+4Ghfg=="], + + "eslint-plugin-obsidianmd": ["eslint-plugin-obsidianmd@0.3.0", "", { "dependencies": { "@eslint/config-helpers": "^0.4.2", "@eslint/js": "^9.30.1", "@eslint/json": "0.14.0", "@microsoft/eslint-plugin-sdl": "^1.1.0", "@types/eslint": "9.6.1", "@types/node": "20.12.12", "@typescript-eslint/types": "^8.33.1", "@typescript-eslint/utils": "^8.33.1", "eslint": ">=9.0.0", "eslint-plugin-depend": "1.3.1", "eslint-plugin-import": "^2.31.0", "eslint-plugin-json-schema-validator": "5.1.0", "eslint-plugin-no-unsanitized": "^4.1.5", "eslint-plugin-security": "2.1.1", "globals": "14.0.0", "obsidian": "1.12.3", "semver": "^7.7.4", "typescript": "5.4.5", "typescript-eslint": "^8.35.1" }, "bin": { "eslint-plugin-obsidian": "dist/lib/index.js" } }, "sha512-QvGDI6B2nxJBrsZKGTg31da2A/fEJNlnwN+fRZkaoPIu1QL3fYXUdpP7ThyMdr/0iTYQxifb9lt2X9cpydQx1w=="], + + "eslint-plugin-react": ["eslint-plugin-react@7.37.3", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.8", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-DomWuTQPFYZwF/7c9W2fkKkStqZmBd3uugfqBYLdkZ3Hii23WzZuOLUskGxB8qkSKqftxEeGL1TB2kMhrce0jA=="], + + "eslint-plugin-security": ["eslint-plugin-security@2.1.1", "", { "dependencies": { "safe-regex": "^2.1.1" } }, "sha512-7cspIGj7WTfR3EhaILzAPcfCo5R9FbeWvbgsPYWivSurTBKW88VQxtP3c4aWMG9Hz/GfJlJVdXEJ3c8LqS+u2w=="], + + "eslint-plugin-simple-import-sort": ["eslint-plugin-simple-import-sort@12.1.1", "", { "peerDependencies": { "eslint": ">=5.0.0" } }, "sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "exit-x": ["exit-x@0.2.2", "", {}, "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ=="], + + "expect": ["expect@30.3.0", "", { "dependencies": { "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.3.0", "jest-message-util": "30.3.0", "jest-mock": "30.3.0", "jest-util": "30.3.0" } }, "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + + "fb-watchman": ["fb-watchman@2.0.2", "", { "dependencies": { "bser": "2.1.1" } }, "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-package-type": ["get-package-type@0.1.0", "", {}, "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], + + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": "dist/esm/bin.mjs" }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "handlebars": ["handlebars@4.7.9", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": "bin/handlebars" }, "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ=="], + + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.12.26", "", {}, "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="], + + "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-generator-fn": ["is-generator-fn@2.1.0", "", {}, "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ=="], + + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="], + + "istanbul-lib-instrument": ["istanbul-lib-instrument@6.0.3", "", { "dependencies": { "@babel/core": "^7.23.9", "@babel/parser": "^7.23.9", "@istanbuljs/schema": "^0.1.3", "istanbul-lib-coverage": "^3.2.0", "semver": "^7.5.4" } }, "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q=="], + + "istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="], + + "istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="], + + "istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "jest": ["jest@30.3.0", "", { "dependencies": { "@jest/core": "30.3.0", "@jest/types": "30.3.0", "import-local": "^3.2.0", "jest-cli": "30.3.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": "bin/jest.js" }, "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg=="], + + "jest-changed-files": ["jest-changed-files@30.3.0", "", { "dependencies": { "execa": "^5.1.1", "jest-util": "30.3.0", "p-limit": "^3.1.0" } }, "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA=="], + + "jest-circus": ["jest-circus@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/expect": "30.3.0", "@jest/test-result": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", "jest-each": "30.3.0", "jest-matcher-utils": "30.3.0", "jest-message-util": "30.3.0", "jest-runtime": "30.3.0", "jest-snapshot": "30.3.0", "jest-util": "30.3.0", "p-limit": "^3.1.0", "pretty-format": "30.3.0", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA=="], + + "jest-cli": ["jest-cli@30.3.0", "", { "dependencies": { "@jest/core": "30.3.0", "@jest/test-result": "30.3.0", "@jest/types": "30.3.0", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", "jest-config": "30.3.0", "jest-util": "30.3.0", "jest-validate": "30.3.0", "yargs": "^17.7.2" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" }, "optionalPeers": ["node-notifier"], "bin": { "jest": "bin/jest.js" } }, "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw=="], + + "jest-config": ["jest-config@30.3.0", "", { "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", "@jest/pattern": "30.0.1", "@jest/test-sequencer": "30.3.0", "@jest/types": "30.3.0", "babel-jest": "30.3.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "jest-circus": "30.3.0", "jest-docblock": "30.2.0", "jest-environment-node": "30.3.0", "jest-regex-util": "30.0.1", "jest-resolve": "30.3.0", "jest-runner": "30.3.0", "jest-util": "30.3.0", "jest-validate": "30.3.0", "parse-json": "^5.2.0", "pretty-format": "30.3.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, "peerDependencies": { "@types/node": "*", "esbuild-register": ">=3.4.0", "ts-node": ">=9.0.0" }, "optionalPeers": ["esbuild-register", "ts-node"] }, "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w=="], + + "jest-diff": ["jest-diff@30.3.0", "", { "dependencies": { "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "pretty-format": "30.3.0" } }, "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ=="], + + "jest-docblock": ["jest-docblock@30.2.0", "", { "dependencies": { "detect-newline": "^3.1.0" } }, "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA=="], + + "jest-each": ["jest-each@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.3.0", "chalk": "^4.1.2", "jest-util": "30.3.0", "pretty-format": "30.3.0" } }, "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA=="], + + "jest-environment-jsdom": ["jest-environment-jsdom@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/environment-jsdom-abstract": "30.3.0", "jsdom": "^26.1.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-RLEOJy6ip1lpw0yqJ8tB3i88FC7VBz7i00Zvl2qF71IdxjS98gC9/0SPWYIBVXHm5hgCYK0PAlSlnHGGy9RoMg=="], + + "jest-environment-node": ["jest-environment-node@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/fake-timers": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "jest-mock": "30.3.0", "jest-util": "30.3.0", "jest-validate": "30.3.0" } }, "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ=="], + + "jest-haste-map": ["jest-haste-map@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", "jest-util": "30.3.0", "jest-worker": "30.3.0", "picomatch": "^4.0.3", "walker": "^1.0.8" }, "optionalDependencies": { "fsevents": "^2.3.3" } }, "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA=="], + + "jest-leak-detector": ["jest-leak-detector@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "pretty-format": "30.3.0" } }, "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ=="], + + "jest-matcher-utils": ["jest-matcher-utils@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", "jest-diff": "30.3.0", "pretty-format": "30.3.0" } }, "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA=="], + + "jest-message-util": ["jest-message-util@30.3.0", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3", "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" } }, "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw=="], + + "jest-mock": ["jest-mock@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "jest-util": "30.3.0" } }, "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog=="], + + "jest-pnp-resolver": ["jest-pnp-resolver@1.2.3", "", { "peerDependencies": { "jest-resolve": "*" } }, "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w=="], + + "jest-regex-util": ["jest-regex-util@30.0.1", "", {}, "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA=="], + + "jest-resolve": ["jest-resolve@30.3.0", "", { "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "jest-haste-map": "30.3.0", "jest-pnp-resolver": "^1.2.3", "jest-util": "30.3.0", "jest-validate": "30.3.0", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" } }, "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g=="], + + "jest-resolve-dependencies": ["jest-resolve-dependencies@30.3.0", "", { "dependencies": { "jest-regex-util": "30.0.1", "jest-snapshot": "30.3.0" } }, "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw=="], + + "jest-runner": ["jest-runner@30.3.0", "", { "dependencies": { "@jest/console": "30.3.0", "@jest/environment": "30.3.0", "@jest/test-result": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "jest-docblock": "30.2.0", "jest-environment-node": "30.3.0", "jest-haste-map": "30.3.0", "jest-leak-detector": "30.3.0", "jest-message-util": "30.3.0", "jest-resolve": "30.3.0", "jest-runtime": "30.3.0", "jest-util": "30.3.0", "jest-watcher": "30.3.0", "jest-worker": "30.3.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" } }, "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw=="], + + "jest-runtime": ["jest-runtime@30.3.0", "", { "dependencies": { "@jest/environment": "30.3.0", "@jest/fake-timers": "30.3.0", "@jest/globals": "30.3.0", "@jest/source-map": "30.0.1", "@jest/test-result": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", "glob": "^10.5.0", "graceful-fs": "^4.2.11", "jest-haste-map": "30.3.0", "jest-message-util": "30.3.0", "jest-mock": "30.3.0", "jest-regex-util": "30.0.1", "jest-resolve": "30.3.0", "jest-snapshot": "30.3.0", "jest-util": "30.3.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" } }, "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng=="], + + "jest-snapshot": ["jest-snapshot@30.3.0", "", { "dependencies": { "@babel/core": "^7.27.4", "@babel/generator": "^7.27.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", "@jest/snapshot-utils": "30.3.0", "@jest/transform": "30.3.0", "@jest/types": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", "expect": "30.3.0", "graceful-fs": "^4.2.11", "jest-diff": "30.3.0", "jest-matcher-utils": "30.3.0", "jest-message-util": "30.3.0", "jest-util": "30.3.0", "pretty-format": "30.3.0", "semver": "^7.7.2", "synckit": "^0.11.8" } }, "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ=="], + + "jest-util": ["jest-util@30.3.0", "", { "dependencies": { "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg=="], + + "jest-validate": ["jest-validate@30.3.0", "", { "dependencies": { "@jest/get-type": "30.1.0", "@jest/types": "30.3.0", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", "pretty-format": "30.3.0" } }, "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q=="], + + "jest-watcher": ["jest-watcher@30.3.0", "", { "dependencies": { "@jest/test-result": "30.3.0", "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", "jest-util": "30.3.0", "string-length": "^4.0.2" } }, "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w=="], + + "jest-worker": ["jest-worker@30.3.0", "", { "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", "jest-util": "30.3.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" } }, "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ=="], + + "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="], + + "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-migrate": ["json-schema-migrate@2.0.0", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonc-eslint-parser": ["jsonc-eslint-parser@2.4.2", "", { "dependencies": { "acorn": "^8.5.0", "eslint-visitor-keys": "^3.0.0", "espree": "^9.0.0", "semver": "^7.3.5" } }, "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.memoize": ["lodash.memoize@4.1.2", "", {}, "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="], + + "make-error": ["make-error@1.3.6", "", {}, "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw=="], + + "makeerror": ["makeerror@1.0.12", "", { "dependencies": { "tmpl": "1.0.5" } }, "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "module-replacements": ["module-replacements@2.11.0", "", {}, "sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA=="], + + "moment": ["moment@2.29.4", "", {}, "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": "lib/cli.js" }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + + "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], + + "node-int64": ["node-int64@0.4.0", "", {}, "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw=="], + + "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "nwsapi": ["nwsapi@2.2.23", "", {}, "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + + "obsidian": ["obsidian@1.12.3", "", { "dependencies": { "@types/codemirror": "5.60.8", "moment": "2.29.4" }, "peerDependencies": { "@codemirror/state": "6.5.0", "@codemirror/view": "6.38.6" } }, "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "pretty-format": ["pretty-format@30.3.0", "", { "dependencies": { "@jest/schemas": "30.0.5", "ansi-styles": "^5.2.0", "react-is": "^18.3.1" } }, "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], + + "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], + + "resolve-cwd": ["resolve-cwd@3.0.0", "", { "dependencies": { "resolve-from": "^5.0.0" } }, "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg=="], + + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + + "safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex": ["safe-regex@2.1.1", "", { "dependencies": { "regexp-tree": "~0.1.1" } }, "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + + "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-support": ["source-map-support@0.5.13", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w=="], + + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "string-length": ["string-length@4.0.2", "", { "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" } }, "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], + + "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "synckit": ["synckit@0.9.3", "", { "dependencies": { "@pkgr/core": "^0.1.0", "tslib": "^2.6.2" } }, "sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg=="], + + "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], + + "test-exclude": ["test-exclude@6.0.0", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", "minimatch": "^3.0.4" } }, "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": "bin/cli.js" }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], + + "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], + + "tmpl": ["tmpl@1.0.5", "", {}, "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "toml-eslint-parser": ["toml-eslint-parser@0.9.3", "", { "dependencies": { "eslint-visitor-keys": "^3.0.0" } }, "sha512-moYoCvkNUAPCxSW9jmHmRElhm4tVJpHL8ItC/+uYD0EpPSFXbck7yREz9tNdJVTSpHVod8+HoipcpbQ0oE6gsw=="], + + "tough-cookie": ["tough-cookie@5.1.2", "", { "dependencies": { "tldts": "^6.1.32" } }, "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A=="], + + "tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "ts-jest": ["ts-jest@29.4.9", "", { "dependencies": { "bs-logger": "^0.2.6", "fast-json-stable-stringify": "^2.1.0", "handlebars": "^4.7.9", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", "semver": "^7.7.4", "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", "@jest/transform": "^29.0.0 || ^30.0.0", "@jest/types": "^29.0.0 || ^30.0.0", "babel-jest": "^29.0.0 || ^30.0.0", "jest": "^29.0.0 || ^30.0.0", "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <7" }, "bin": "cli.js" }, "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ=="], + + "tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + + "typescript-eslint": ["typescript-eslint@8.59.3", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.3", "@typescript-eslint/parser": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg=="], + + "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], + + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "write-file-atomic": ["write-file-atomic@5.0.1", "", { "dependencies": { "imurmurhash": "^0.1.4", "signal-exit": "^4.0.1" } }, "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "yaml-eslint-parser": ["yaml-eslint-parser@1.3.2", "", { "dependencies": { "eslint-visitor-keys": "^3.0.0", "yaml": "^2.0.0" } }, "sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/json/@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/json/@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "@istanbuljs/load-nyc-config/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + + "@istanbuljs/load-nyc-config/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "@microsoft/eslint-plugin-sdl/eslint-plugin-security": ["eslint-plugin-security@1.4.0", "", { "dependencies": { "safe-regex": "^1.1.0" } }, "sha512-xlS7P2PLMXeqfhyf3NpqbvbnW04kN8M9NtmhpR3XGyOvt/vNKS7XPXT5EDbwKW9vCjWH4PpfQvgD/+JgN0VJKA=="], + + "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "@types/babel__core/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@types/babel__core/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@types/babel__generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@types/babel__template/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@types/babel__template/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@types/babel__traverse/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], + + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "es-shim-unscopables/hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "eslint-plugin-import/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "eslint-plugin-import/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "eslint-plugin-json-schema-validator/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "eslint-plugin-json-schema-validator/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="], + + "eslint-plugin-n/globals": ["globals@15.15.0", "", {}, "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg=="], + + "eslint-plugin-n/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "eslint-plugin-n/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "eslint-plugin-obsidianmd/@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "eslint-plugin-obsidianmd/@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + + "eslint-plugin-obsidianmd/@types/node": ["@types/node@20.12.12", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw=="], + + "eslint-plugin-obsidianmd/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], + + "eslint-plugin-react/hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "eslint-plugin-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "is-core-module/hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "is-regex/hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "istanbul-lib-instrument/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "jest-message-util/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "jest-runtime/strip-bom": ["strip-bom@4.0.0", "", {}, "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w=="], + + "jest-snapshot/@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "jest-snapshot/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "jest-snapshot/synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="], + + "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + + "json-schema-migrate/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "jsonc-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "jsonc-eslint-parser/espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], + + "node-exports-info/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "parse-json/@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + + "test-exclude/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "test-exclude/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "toml-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + + "tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": "bin/esbuild" }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="], + + "typescript-eslint/@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg=="], + + "typescript-eslint/@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.3", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.3", "@typescript-eslint/tsconfig-utils": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg=="], + + "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="], + + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "yaml-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "@istanbuljs/load-nyc-config/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "@microsoft/eslint-plugin-sdl/eslint-plugin-security/safe-regex": ["safe-regex@1.1.0", "", { "dependencies": { "ret": "~0.1.10" } }, "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg=="], + + "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@types/babel__core/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@types/babel__core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@types/babel__generator/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@types/babel__generator/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@types/babel__template/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@types/babel__template/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@types/babel__traverse/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@types/babel__traverse/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "eslint-plugin-import/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], + + "eslint-plugin-json-schema-validator/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "eslint-plugin-json-schema-validator/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], + + "eslint-plugin-n/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], + + "eslint-plugin-obsidianmd/@eslint/config-helpers/@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "eslint-plugin-obsidianmd/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], + + "istanbul-lib-instrument/@babel/parser/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "jest-message-util/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "jest-snapshot/@babel/generator/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "jest-snapshot/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "jest-snapshot/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "jest-snapshot/synckit/@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="], + + "json-schema-migrate/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "parse-json/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + + "test-exclude/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + + "typescript-eslint/@typescript-eslint/parser/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.3", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.3", "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.3", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + + "typescript-eslint/@typescript-eslint/typescript-estree/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], + + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="], + + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + + "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "eslint-plugin-import/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "eslint-plugin-json-schema-validator/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "eslint-plugin-n/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "istanbul-lib-instrument/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "istanbul-lib-instrument/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "pkg-dir/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + + "test-exclude/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + + "typescript-eslint/@typescript-eslint/eslint-plugin/@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="], + + "typescript-eslint/@typescript-eslint/utils/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="], + + "@istanbuljs/load-nyc-config/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + + "pkg-dir/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + } +} diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..01ca4e9 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,195 @@ +import esbuild from 'esbuild'; +import { builtinModules } from 'node:module'; +import path from 'path'; +import process from 'process'; +import { + copyFileSync, + existsSync, + mkdirSync, + promises as fsPromises, + readFileSync, + rmSync, +} from 'fs'; +import rendererSafeUnrefHelpers from './scripts/rendererSafeUnref.js'; + +const { + findUnsafeTimerUnrefSites, + patchRendererUnsafeUnrefSites, +} = rendererSafeUnrefHelpers; + +// Load .env.local if it exists +if (existsSync('.env.local')) { + const envContent = readFileSync('.env.local', 'utf-8'); + for (const line of envContent.split('\n')) { + const match = line.match(/^([^=]+)=["']?(.+?)["']?$/); + if (match && !process.env[match[1]]) { + process.env[match[1]] = match[2]; + } + } +} + +const prod = process.argv[2] === 'production'; + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function getNamedImportAliases(contents, exportName, moduleNames) { + const aliases = new Set([exportName]); + const importPattern = /import\s*\{([^}]+)\}\s*from\s*["']([^"']+)["']/g; + let match; + + while ((match = importPattern.exec(contents)) !== null) { + const [, specifiers, moduleName] = match; + if (!moduleNames.includes(moduleName)) continue; + + for (const specifier of specifiers.split(',')) { + const parts = specifier.trim().split(/\s+as\s+/); + if (parts[0] === exportName) { + aliases.add(parts[1] ?? exportName); + } + } + } + + return [...aliases]; +} + +function patchSdkImportMetaUrl(contents) { + let patched = contents.replace( + 'createRequire(import.meta.url)', + 'createRequire(__filename)', + ); + + for (const alias of getNamedImportAliases(patched, 'createRequire', ['module', 'node:module'])) { + patched = patched.replace( + new RegExp(`\\b${escapeRegExp(alias)}\\(import\\.meta\\.url\\)`, 'g'), + `${alias}(__filename)`, + ); + } + + for (const alias of getNamedImportAliases(patched, 'fileURLToPath', ['url', 'node:url'])) { + patched = patched.replace( + new RegExp(`\\b${escapeRegExp(alias)}\\(import\\.meta\\.url\\)`, 'g'), + '__filename', + ); + } + + return patched; +} + +const patchSdkImportMeta = { + name: 'patch-sdk-import-meta', + setup(build) { + build.onLoad( + { + filter: /[\\/]node_modules[\\/](?:@openai[\\/]codex-sdk[\\/]dist[\\/]index\.js|@anthropic-ai[\\/]claude-agent-sdk[\\/]sdk\.mjs)$/, + }, + async (args) => { + const contents = await fsPromises.readFile(args.path, 'utf8'); + return { + contents: patchSdkImportMetaUrl(contents), + loader: 'js', + }; + }, + ); + }, +}; + +const patchRendererUnsafeUnref = { + name: 'patch-renderer-unsafe-unref', + setup(build) { + build.onEnd(async (result) => { + if (result.errors.length > 0 || !existsSync('main.js')) return; + + const bundlePath = path.join(process.cwd(), 'main.js'); + const originalContents = await fsPromises.readFile(bundlePath, 'utf8'); + const patchedBundle = patchRendererUnsafeUnrefSites(originalContents); + + if (patchedBundle.contents !== originalContents) { + await fsPromises.writeFile(bundlePath, patchedBundle.contents, 'utf8'); + } + + const unsafeMatches = findUnsafeTimerUnrefSites(patchedBundle.contents); + if (unsafeMatches.length > 0) { + const details = unsafeMatches + .slice(0, 5) + .map((match) => `line ${match.line}: ${match.snippet}`) + .join('\n'); + + throw new Error( + `Renderer-unsafe timer .unref() calls remain in main.js:\n${details}`, + ); + } + }); + }, +}; + +// Obsidian plugin folder path (set via OBSIDIAN_VAULT env var or .env.local) +const OBSIDIAN_VAULT = process.env.OBSIDIAN_VAULT; +const OBSIDIAN_PLUGIN_PATH = OBSIDIAN_VAULT && existsSync(OBSIDIAN_VAULT) + ? path.join(OBSIDIAN_VAULT, '.obsidian', 'plugins', 'claudian') + : null; + +// Plugin to copy built files to Obsidian plugin folder +const copyToObsidian = { + name: 'copy-to-obsidian', + setup(build) { + build.onEnd((result) => { + if (result.errors.length > 0) return; + rmSync(path.join(process.cwd(), '.codex-vendor'), { recursive: true, force: true }); + + if (!OBSIDIAN_PLUGIN_PATH) return; + + if (!existsSync(OBSIDIAN_PLUGIN_PATH)) { + mkdirSync(OBSIDIAN_PLUGIN_PATH, { recursive: true }); + } + + const files = ['main.js', 'manifest.json', 'styles.css']; + for (const file of files) { + if (existsSync(file)) { + copyFileSync(file, path.join(OBSIDIAN_PLUGIN_PATH, file)); + console.log(`Copied ${file} to Obsidian plugin folder`); + } + } + + const pluginVendorRoot = path.join(OBSIDIAN_PLUGIN_PATH, '.codex-vendor'); + rmSync(pluginVendorRoot, { recursive: true, force: true }); + }); + } +}; + +const context = await esbuild.context({ + entryPoints: ['src/main.ts'], + bundle: true, + plugins: [patchSdkImportMeta, patchRendererUnsafeUnref, copyToObsidian], + external: [ + 'obsidian', + 'electron', + '@codemirror/autocomplete', + '@codemirror/collab', + '@codemirror/commands', + '@codemirror/language', + '@codemirror/lint', + '@codemirror/search', + '@codemirror/state', + '@codemirror/view', + '@lezer/common', + '@lezer/highlight', + '@lezer/lr', + ...builtinModules, + ...builtinModules.map(m => `node:${m}`), + ], + format: 'cjs', + target: 'es2018', + logLevel: 'info', + sourcemap: prod ? false : 'inline', + treeShaking: true, + outfile: 'main.js', +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..fcacce9 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,147 @@ +import js from '@eslint/js'; +import tseslint from '@typescript-eslint/eslint-plugin'; +import jestPlugin from 'eslint-plugin-jest'; +import obsidianmd from 'eslint-plugin-obsidianmd'; +import { DEFAULT_ACRONYMS } from 'eslint-plugin-obsidianmd/dist/lib/rules/ui/acronyms.js'; +import { DEFAULT_BRANDS } from 'eslint-plugin-obsidianmd/dist/lib/rules/ui/brands.js'; +import simpleImportSort from 'eslint-plugin-simple-import-sort'; +import { defineConfig } from 'eslint/config'; +import { dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const jestRecommended = jestPlugin.configs['flat/recommended']; +const tsconfigRootDir = dirname(fileURLToPath(import.meta.url)); +const obsidianRuleSeverity = 'warn'; + +const stagedObsidianRules = { + 'obsidianmd/commands/no-command-in-command-id': obsidianRuleSeverity, + 'obsidianmd/commands/no-command-in-command-name': obsidianRuleSeverity, + 'obsidianmd/commands/no-default-hotkeys': obsidianRuleSeverity, + 'obsidianmd/commands/no-plugin-id-in-command-id': obsidianRuleSeverity, + 'obsidianmd/commands/no-plugin-name-in-command-name': obsidianRuleSeverity, + 'obsidianmd/detach-leaves': obsidianRuleSeverity, + 'obsidianmd/editor-drop-paste': obsidianRuleSeverity, + 'obsidianmd/hardcoded-config-path': obsidianRuleSeverity, + 'obsidianmd/no-forbidden-elements': obsidianRuleSeverity, + 'obsidianmd/no-global-this': obsidianRuleSeverity, + 'obsidianmd/no-plugin-as-component': obsidianRuleSeverity, + 'obsidianmd/no-sample-code': obsidianRuleSeverity, + 'obsidianmd/no-static-styles-assignment': obsidianRuleSeverity, + 'obsidianmd/no-tfile-tfolder-cast': obsidianRuleSeverity, + 'obsidianmd/no-unsupported-api': obsidianRuleSeverity, + 'obsidianmd/no-view-references-in-plugin': obsidianRuleSeverity, + 'obsidianmd/object-assign': obsidianRuleSeverity, + 'obsidianmd/platform': obsidianRuleSeverity, + 'obsidianmd/prefer-abstract-input-suggest': obsidianRuleSeverity, + 'obsidianmd/prefer-active-doc': obsidianRuleSeverity, + 'obsidianmd/prefer-file-manager-trash-file': obsidianRuleSeverity, + 'obsidianmd/prefer-get-language': obsidianRuleSeverity, + 'obsidianmd/prefer-instanceof': obsidianRuleSeverity, + 'obsidianmd/prefer-window-timers': obsidianRuleSeverity, + 'obsidianmd/regex-lookbehind': obsidianRuleSeverity, + 'obsidianmd/sample-names': obsidianRuleSeverity, + 'obsidianmd/settings-tab/no-manual-html-headings': obsidianRuleSeverity, + 'obsidianmd/settings-tab/no-problematic-settings-headings': obsidianRuleSeverity, + 'obsidianmd/ui/sentence-case': [ + obsidianRuleSeverity, + { + ignoreWords: ['Claudian', 'Codex', 'OpenCode', 'Pi', 'WSL'], + brands: [...DEFAULT_BRANDS, 'Claudian', 'Codex', 'OpenCode', 'Pi'], + acronyms: [...DEFAULT_ACRONYMS, 'TOML', 'WSL'], + ignoreRegex: ['\\.(?:claude|codex|opencode)/'], + enforceCamelCaseLower: true, + }, + ], + 'obsidianmd/vault/iterate': obsidianRuleSeverity, +}; + +export default defineConfig([ + { + ignores: ['dist/**', 'node_modules/**', 'coverage/**', 'main.js'], + }, + js.configs.recommended, + { + files: ['esbuild.config.mjs', 'scripts/**/*.js', 'scripts/**/*.mjs'], + languageOptions: { + globals: { + console: 'readonly', + module: 'readonly', + process: 'readonly', + }, + }, + }, + ...tseslint.configs['flat/recommended'], + { + files: ['src/**/*.ts', 'tests/**/*.ts'], + plugins: { + 'simple-import-sort': simpleImportSort, + }, + rules: { + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'type-imports', fixStyle: 'separate-type-imports' }, + ], + '@typescript-eslint/no-unused-vars': [ + 'error', + { args: 'none', ignoreRestSiblings: true }, + ], + '@typescript-eslint/no-explicit-any': 'off', + 'simple-import-sort/imports': 'error', + 'simple-import-sort/exports': 'error', + }, + }, + { + files: ['src/**/*.ts'], + languageOptions: { + parserOptions: { + project: './tsconfig.json', + tsconfigRootDir, + }, + }, + plugins: { + obsidianmd, + }, + rules: { + ...stagedObsidianRules, + '@typescript-eslint/no-unsafe-argument': 'error', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + }, + }, + { + files: [ + 'src/ClaudianService.ts', + 'src/InlineEditService.ts', + 'src/InstructionRefineService.ts', + 'src/images/**/*.ts', + 'src/prompt/**/*.ts', + 'src/sdk/**/*.ts', + 'src/security/**/*.ts', + 'src/tools/**/*.ts', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['./ui', './ui/*', '../ui', '../ui/*'], + message: 'Service and shared modules must not import UI modules.', + }, + { + group: ['./ClaudianView', '../ClaudianView'], + message: 'Service and shared modules must not import the view.', + }, + ], + }, + ], + }, + }, + { + files: ['tests/**/*.ts'], + ...jestRecommended, + rules: { + ...jestRecommended.rules, + '@typescript-eslint/no-explicit-any': 'off', + }, + }, +]); diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..7d4b0a1 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,41 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +const baseConfig = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.jest.json' }], + }, + roots: ['/src', '/tests'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + setupFilesAfterEnv: ['/tests/setupWindow.ts'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + '^@test/(.*)$': '/tests/$1', + '^@anthropic-ai/claude-agent-sdk$': '/tests/__mocks__/claude-agent-sdk.ts', + '^obsidian$': '/tests/__mocks__/obsidian.ts', + '^@modelcontextprotocol/sdk/(.*)$': '/node_modules/@modelcontextprotocol/sdk/dist/cjs/$1', + }, + transformIgnorePatterns: [ + 'node_modules/(?!(@anthropic-ai/claude-agent-sdk)/)', + ], +}; + +module.exports = { + projects: [ + { + ...baseConfig, + displayName: 'unit', + testMatch: ['/tests/unit/**/*.test.ts'], + }, + { + ...baseConfig, + displayName: 'integration', + testMatch: ['/tests/integration/**/*.test.ts'], + }, + ], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + ], + coverageDirectory: 'coverage', +}; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..a41e01a --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "realclaudian", + "name": "Claudian", + "version": "2.0.32", + "minAppVersion": "1.7.2", + "description": "Embeds Claude Code, Codex, and other coding agents as AI collaborators in your vault. Your vault becomes their working directory, giving them capabilities for file reads and writes, search, bash commands, and multi-step workflows.", + "author": "Yishen Tu", + "authorUrl": "https://github.com/YishenTu", + "isDesktopOnly": true +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4ed4218 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,11472 @@ +{ + "name": "claudian", + "version": "2.0.32", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "claudian", + "version": "2.0.32", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.159", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.38.6", + "@modelcontextprotocol/sdk": "~1.29.0", + "smol-toml": "^1.6.1", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/jest": "^30.0.0", + "@types/node": "^25.5.2", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "esbuild": "^0.28.0", + "eslint": "^10.2.0", + "eslint-plugin-jest": "^29.15.1", + "eslint-plugin-obsidianmd": "^0.3.0", + "eslint-plugin-simple-import-sort": "^12.1.1", + "jest": "^30.3.0", + "jest-environment-jsdom": "^30.3.0", + "obsidian": "latest", + "ts-jest": "^29.4.9", + "tsx": "^4.21.0", + "typescript": "^6.0.2" + }, + "engines": { + "node": ">=24 <25" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk": { + "version": "0.3.159", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.159.tgz", + "integrity": "sha512-Xh1oVMIK6N3KsiNIhqNH8ZK90zjRmAEL9d1Md8ZlGdHJE+HhdMYdBadujc3KEkV0uufsEUvYp+A3fDenfypGSA==", + "license": "SEE LICENSE IN README.md", + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.159", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.159", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.159", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.159", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.159", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.159", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.159", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.159" + }, + "peerDependencies": { + "@anthropic-ai/sdk": ">=0.93.0", + "@modelcontextprotocol/sdk": "^1.29.0", + "zod": "^4.0.0" + } + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { + "version": "0.3.159", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.159.tgz", + "integrity": "sha512-3nnH4yUNJVSyaU5DBlGw2yxc4zlnVvAnc9UOe+La47QVG7/dN+rWAgn4zCbqKk9bWFLDQ1Ek0r56EZE1Qo4UKQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { + "version": "0.3.159", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.159.tgz", + "integrity": "sha512-iv+NRjz+t4Q1R2+kLdDbccSo3b0wedVJ9jMT3noznOVojZHzgkxVpTvt36/XkXV0rqIDQ5H18bBYIZZzOXu7mg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { + "version": "0.3.159", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.159.tgz", + "integrity": "sha512-FlsS5M4GCpzsQVaNDFF8dRgFGR3QwyAHZFl/xM/2Y2BqVBH+NH17RpKQSJxr1qr41QnsNkinMnu2iSKoc33hKg==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { + "version": "0.3.159", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.159.tgz", + "integrity": "sha512-WvwiQBWt3tdu5EwqjpDZszI6p2uetYsw4Cxc6ptO/SmLIYXcDienP8nmirZdsZrS+Gzk6imgY0IY5mmNaRhelQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { + "version": "0.3.159", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.159.tgz", + "integrity": "sha512-uNPEC/iRzVb4bEdzs0KAz1zV7i1PVGEZZnJTQyi1OtgVa81sAoH/H0CbbzDiTsquKdaESf+1DSSEkUlfZmMUEw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { + "version": "0.3.159", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.159.tgz", + "integrity": "sha512-kFH6RC2YJbPc8XWRNy/wL4YU7LzdJjSwAdH488sVzIif3q+TrvVrV5y/IW0+MLmta+CKIqtFYpGaucsJYvj7Eg==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { + "version": "0.3.159", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.159.tgz", + "integrity": "sha512-WN1QEZGgWXz9GMl61QU6j9E+LEF5plki87bL2xsGwuCPzK+OeVPQU55pabuP8P+vFBFHUo3Y9OlTVyZHnUzmAQ==", + "cpu": [ + "arm64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { + "version": "0.3.159", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.159.tgz", + "integrity": "sha512-Ty4seccD+dTDX5hhj89IUELZd/LkxO5O43Uiz5Mo8ZJktoX38SK4XMZlBS935QdqTFLRvPL0hvK4Lt4dTOqzPw==", + "cpu": [ + "x64" + ], + "license": "SEE LICENSE IN LICENSE.md", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.93.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.93.0.tgz", + "integrity": "sha512-q9vaSZQVFx6B/gPxetGYfLXSJD5v0sOmh0OpZDq7yCrTSA+Rscvrtyol7JJTW40wEpQB4U1B4JXzxQitbQ3CAA==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.4", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.4.tgz", + "integrity": "sha512-lf19F24LSMfF8weXvW5QEtnLqW70u7kgit5e9PSx0MsHAFclGd1T9ynvWEMDT1w5J4Qt54tomGeAhdoAku1Xow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.4", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.4.tgz", + "integrity": "sha512-jJhqiY3wPMlWWO3370M86CPJ7pt8GmEwSLglMfQhjXal07RCvhmU0as4IuUEW5SJeunfItiEetHmSxCCe9lDBg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.0.tgz", + "integrity": "sha512-8FTGbNzTvmSlc4cZBaShkC6YvFMG0riksYWRFKXztqVdXaQbcZLXlFbSpC05s70sGEsXAw0qwhx69JiW7hQS7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/json": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/json/-/json-0.14.0.tgz", + "integrity": "sha512-rvR/EZtvUG3p9uqrSmcDJPYSH7atmWr0RnFWN6m917MAPx82+zQgPUmDu0whPFG6XTyM0vB/hR6c1Q63OaYtCQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "@eslint/plugin-kit": "^0.4.1", + "@humanwhocodes/momoa": "^3.3.10", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/json/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/json/node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.4.tgz", + "integrity": "sha512-55lO/7+Yp0ISKRP0PsPtNTeNGapXaO085aELZmWCVc5SH3jfrqpuU6YgOdIxMS99ZHkQN1cXKE+cdIqwww9ptw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.0.tgz", + "integrity": "sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/momoa": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-3.3.10.tgz", + "integrity": "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", + "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.3.0.tgz", + "integrity": "sha512-0hNFs5N6We3DMCwobzI0ydhkY10sT1tZSC0AAiy+0g2Dt/qEWgrcV5BrMxPczhe41cxW4qm6X+jqZaUdpZIajA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/jsdom": "^21.1.7", + "@types/node": "*", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/@jest/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.3.0", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", + "@types/node": "*", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/types": "30.3.0", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", + "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", + "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", + "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/types": "30.3.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", + "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", + "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.0.tgz", + "integrity": "sha512-m2xozxSfCIxjDdvbhIWazlP2i2aha/iUmbl94alpsIbd3iLTfeXgfBVbwyWogB6l++istyGZqamgA/EcqYf+Bg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.3", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", + "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.3.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", + "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", + "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.15", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.15.tgz", + "integrity": "sha512-1nfKCq9wuAZFTkA2ey/3OXXx7GzFjLdkTiFVNwlJ9WqdI706CZRIhEqjuwanjMIja+84jDLa9rcyZDPDiVkASQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001785", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001785.tgz", + "integrity": "sha512-blhOL/WNR+Km1RI/LCVAvA73xplXA7ZbjzI4YkMK9pa6T/P3F2GxjNpEkyw5repTw9IvkyrjyHpwjnhZ5FOvYQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", + "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", + "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.4", + "@eslint/config-helpers": "^0.5.4", + "@eslint/core": "^1.2.0", + "@eslint/plugin-kit": "^0.7.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-depend": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-depend/-/eslint-plugin-depend-1.3.1.tgz", + "integrity": "sha512-1uo2rFAr9vzNrCYdp7IBZRB54LiyVxfaIso0R6/QV3t6Dax6DTbW/EV2Hktf0f4UtmGHK8UyzJWI382pwW04jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "empathic": "^2.0.0", + "module-replacements": "^2.8.0", + "semver": "^7.6.3" + } + }, + "node_modules/eslint-plugin-es-x": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz", + "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/ota-meshi", + "https://opencollective.com/eslint" + ], + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.1.2", + "@eslint-community/regexpp": "^4.11.0", + "eslint-compat-utils": "^0.5.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": ">=8" + } + }, + "node_modules/eslint-plugin-jest": { + "version": "29.15.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.15.1.tgz", + "integrity": "sha512-6BjyErCQauz3zfJvzLw/kAez2lf4LEpbHLvWBfEcG4EI0ZiRSwjoH2uZulMouU8kRkBH+S0rhqn11IhTvxKgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.0.0" + }, + "engines": { + "node": "^20.12.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "jest": "*", + "typescript": ">=4.8.4 <7.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-json-schema-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-json-schema-validator/-/eslint-plugin-json-schema-validator-5.1.0.tgz", + "integrity": "sha512-ZmVyxRIjm58oqe2kTuy90PpmZPrrKvOjRPXKzq8WCgRgAkidCgm5X8domL2KSfadZ3QFAmifMgGTcVNhZ5ez2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.3.0", + "ajv": "^8.0.0", + "debug": "^4.3.1", + "eslint-compat-utils": "^0.5.0", + "json-schema-migrate": "^2.0.0", + "jsonc-eslint-parser": "^2.0.0", + "minimatch": "^8.0.0", + "synckit": "^0.9.0", + "toml-eslint-parser": "^0.9.0", + "tunnel-agent": "^0.6.0", + "yaml-eslint-parser": "^1.0.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/@pkgr/core": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.2.tgz", + "integrity": "sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/minimatch": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-8.0.7.tgz", + "integrity": "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-plugin-json-schema-validator/node_modules/synckit": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.3.tgz", + "integrity": "sha512-JJoOEKTfL1urb1mDoEblhD9NhEbWmq9jHEMEnxoC4ujUaZ4itA8vKgwkFAyNClgxplLi9tsUKX+EduK0p/l7sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/eslint-plugin-n": { + "version": "17.10.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-17.10.3.tgz", + "integrity": "sha512-ySZBfKe49nQZWR1yFaA0v/GsH6Fgp8ah6XV0WDz6CN8WO0ek4McMzb7A2xnf4DCYV43frjCygvb9f/wx7UUxRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "enhanced-resolve": "^5.17.0", + "eslint-plugin-es-x": "^7.5.0", + "get-tsconfig": "^4.7.0", + "globals": "^15.8.0", + "ignore": "^5.2.4", + "minimatch": "^9.0.5", + "semver": "^7.5.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": ">=8.23.0" + } + }, + "node_modules/eslint-plugin-n/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-n/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/eslint-plugin-n/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-n/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint-plugin-n/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-plugin-no-unsanitized": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-no-unsanitized/-/eslint-plugin-no-unsanitized-4.1.5.tgz", + "integrity": "sha512-MSB4hXPVFQrI8weqzs6gzl7reP2k/qSjtCoL2vUMSDejIIq9YL1ZKvq5/ORBXab/PvfBBrWO2jWviYpL+4Ghfg==", + "dev": true, + "license": "MPL-2.0", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-plugin-obsidianmd": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-obsidianmd/-/eslint-plugin-obsidianmd-0.3.0.tgz", + "integrity": "sha512-QvGDI6B2nxJBrsZKGTg31da2A/fEJNlnwN+fRZkaoPIu1QL3fYXUdpP7ThyMdr/0iTYQxifb9lt2X9cpydQx1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint/config-helpers": "^0.4.2", + "@eslint/js": "^9.30.1", + "@eslint/json": "0.14.0", + "@microsoft/eslint-plugin-sdl": "^1.1.0", + "@types/eslint": "9.6.1", + "@types/node": "20.12.12", + "@typescript-eslint/types": "^8.33.1", + "@typescript-eslint/utils": "^8.33.1", + "eslint": ">=9.0.0", + "eslint-plugin-depend": "1.3.1", + "eslint-plugin-import": "^2.31.0", + "eslint-plugin-json-schema-validator": "5.1.0", + "eslint-plugin-no-unsanitized": "^4.1.5", + "eslint-plugin-security": "2.1.1", + "globals": "14.0.0", + "obsidian": "1.12.3", + "semver": "^7.7.4", + "typescript": "5.4.5", + "typescript-eslint": "^8.35.1" + }, + "bin": { + "eslint-plugin-obsidian": "dist/lib/index.js" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "@eslint/js": "^9.30.1", + "@eslint/json": "0.14.0", + "eslint": ">=9.0.0", + "obsidian": "1.8.7", + "typescript-eslint": "^8.35.1" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/@microsoft/eslint-plugin-sdl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@microsoft/eslint-plugin-sdl/-/eslint-plugin-sdl-1.1.0.tgz", + "integrity": "sha512-dxdNHOemLnBhfY3eByrujX9KyLigcNtW8sU+axzWv5nLGcsSBeKW2YYyTpfPo1hV8YPOmIGnfA4fZHyKVtWqBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-plugin-n": "17.10.3", + "eslint-plugin-react": "7.37.3", + "eslint-plugin-security": "1.4.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "eslint": "^9" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/@microsoft/eslint-plugin-sdl/node_modules/eslint-plugin-security": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-1.4.0.tgz", + "integrity": "sha512-xlS7P2PLMXeqfhyf3NpqbvbnW04kN8M9NtmhpR3XGyOvt/vNKS7XPXT5EDbwKW9vCjWH4PpfQvgD/+JgN0VJKA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^1.1.0" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/@types/node": { + "version": "20.12.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", + "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/eslint-plugin-react": { + "version": "7.37.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.3.tgz", + "integrity": "sha512-DomWuTQPFYZwF/7c9W2fkKkStqZmBd3uugfqBYLdkZ3Hii23WzZuOLUskGxB8qkSKqftxEeGL1TB2kMhrce0jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.8", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha512-aJXcif4xnaNUzvUuC5gcb46oTS7zvg4jpMTnuqtrEPlR3vFr4pxtdTwaF1Qs3Enjn9HK+ZlwQui+a7z0SywIzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ret": "~0.1.10" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/eslint-plugin-obsidianmd/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-security": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-2.1.1.tgz", + "integrity": "sha512-7cspIGj7WTfR3EhaILzAPcfCo5R9FbeWvbgsPYWivSurTBKW88VQxtP3c4aWMG9Hz/GfJlJVdXEJ3c8LqS+u2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + } + }, + "node_modules/eslint-plugin-simple-import-sort": { + "version": "12.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-simple-import-sort/-/eslint-plugin-simple-import-sort-12.1.1.tgz", + "integrity": "sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=5.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.26", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", + "integrity": "sha512-uyZtpnYxM9CmQ7QsQknM4zN8EftNqhON1qYeIKM0Se67CCEe2c44xyGURwB0axX2fBDu1dqHrHAc1hmNT8ITkw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", + "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "30.3.0", + "@jest/types": "30.3.0", + "import-local": "^3.2.0", + "jest-cli": "30.3.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", + "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.3.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", + "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "p-limit": "^3.1.0", + "pretty-format": "30.3.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", + "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", + "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.3.0", + "@jest/types": "30.3.0", + "babel-jest": "30.3.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.3.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-runner": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "parse-json": "^5.2.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.3.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", + "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "jest-util": "30.3.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-jsdom": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.3.0.tgz", + "integrity": "sha512-RLEOJy6ip1lpw0yqJ8tB3i88FC7VBz7i00Zvl2qF71IdxjS98gC9/0SPWYIBVXHm5hgCYK0PAlSlnHGGy9RoMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/environment-jsdom-abstract": "30.3.0", + "jsdom": "^26.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jest-environment-node": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", + "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", + "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", + "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.3.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", + "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", + "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", + "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/environment": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-leak-detector": "30.3.0", + "jest-message-util": "30.3.0", + "jest-resolve": "30.3.0", + "jest-runtime": "30.3.0", + "jest-util": "30.3.0", + "jest-watcher": "30.3.0", + "jest-worker": "30.3.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", + "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/globals": "30.3.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", + "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "pretty-format": "30.3.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", + "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", + "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.3.0", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.3.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-migrate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-2.0.0.tgz", + "integrity": "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-eslint-parser": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.2.tgz", + "integrity": "sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.5.0", + "eslint-visitor-keys": "^3.0.0", + "espree": "^9.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, + "node_modules/jsonc-eslint-parser/node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/module-replacements": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/module-replacements/-/module-replacements-2.11.0.tgz", + "integrity": "sha512-j5sNQm3VCpQQ7nTqGeOZtoJtV3uKERgCBm9QRhmGRiXiqkf7iRFOkfxdJRZWLkqYY8PNf4cDQF/WfXUYLENrRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/smol-toml": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.6.1.tgz", + "integrity": "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toml-eslint-parser": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-0.9.3.tgz", + "integrity": "sha512-moYoCvkNUAPCxSW9jmHmRElhm4tVJpHL8ItC/+uYD0EpPSFXbck7yREz9tNdJVTSpHVod8+HoipcpbQ0oE6gsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.0.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-jest": { + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.4", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yaml-eslint-parser": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/yaml-eslint-parser/-/yaml-eslint-parser-1.3.2.tgz", + "integrity": "sha512-odxVsHAkZYYglR30aPYRY4nUGJnoJ2y1ww2HDvZALo0BDETv9kWbi16J52eHs+PWRNmF4ub6nZqfVOeesOvntg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.0.0", + "yaml": "^2.0.0" + }, + "engines": { + "node": "^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ota-meshi" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..45f9daf --- /dev/null +++ b/package.json @@ -0,0 +1,64 @@ +{ + "name": "claudian", + "version": "2.0.32", + "description": "Claudian - Claude Code embedded in Obsidian sidebar", + "main": "main.js", + "engines": { + "node": ">=24 <25" + }, + "scripts": { + "postinstall": "node scripts/postinstall.mjs", + "build:css": "node scripts/build-css.mjs", + "dev": "npm run build:css && node esbuild.config.mjs", + "build": "node scripts/build.mjs production", + "typecheck": "tsc --noEmit", + "lint": "eslint \"{src,tests}/**/*.ts\"", + "lint:fix": "npm run lint -- --fix", + "test": "node scripts/run-tests.js", + "test:unit": "node scripts/run-jest.js", + "test:architecture": "node --test scripts/check-architecture-boundaries.test.mjs", + "test:watch": "node scripts/run-jest.js --watch", + "test:coverage": "node scripts/run-jest.js --coverage", + "version": "node scripts/sync-version.js && git add manifest.json" + }, + "keywords": [ + "claude-code", + "obsidian", + "obsidian-plugin", + "productivity", + "ide" + ], + "author": "Yishen Tu", + "license": "MIT", + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/jest": "^30.0.0", + "@types/node": "^25.5.2", + "@typescript-eslint/eslint-plugin": "^8.58.0", + "@typescript-eslint/parser": "^8.58.0", + "esbuild": "^0.28.0", + "eslint": "^10.2.0", + "eslint-plugin-jest": "^29.15.1", + "eslint-plugin-obsidianmd": "^0.3.0", + "eslint-plugin-simple-import-sort": "^12.1.1", + "jest": "^30.3.0", + "jest-environment-jsdom": "^30.3.0", + "obsidian": "latest", + "ts-jest": "^29.4.9", + "tsx": "^4.21.0", + "typescript": "^6.0.2" + }, + "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.3.159", + "@codemirror/state": "^6.5.0", + "@codemirror/view": "^6.38.6", + "@modelcontextprotocol/sdk": "~1.29.0", + "smol-toml": "^1.6.1", + "tslib": "^2.8.1" + }, + "overrides": { + "@babel/core": "7.29.7", + "hono": "4.12.26", + "js-yaml": "4.2.0" + } +} diff --git a/scripts/build-css.mjs b/scripts/build-css.mjs new file mode 100644 index 0000000..48e827b --- /dev/null +++ b/scripts/build-css.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * CSS Build Script + * Concatenates modular CSS files from src/style/ into root styles.css + */ + +import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs'; +import { join, dirname, resolve, relative } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); +const STYLE_DIR = join(ROOT, 'src', 'style'); +const OUTPUT = join(ROOT, 'styles.css'); +const INDEX_FILE = join(STYLE_DIR, 'index.css'); + +const IMPORT_PATTERN = /^\s*@import\s+(?:url\()?['"]([^'"]+)['"]\)?\s*;/gm; + +function getModuleOrder() { + if (!existsSync(INDEX_FILE)) { + console.error('Missing src/style/index.css'); + process.exit(1); + } + + const content = readFileSync(INDEX_FILE, 'utf-8'); + const matches = [...content.matchAll(IMPORT_PATTERN)]; + + if (matches.length === 0) { + console.error('No @import entries found in src/style/index.css'); + process.exit(1); + } + + return matches.map((match) => match[1]); +} + +function listCssFiles(dir, baseDir = dir) { + const entries = readdirSync(dir, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const entryPath = join(dir, entry.name); + + if (entry.isDirectory()) { + files.push(...listCssFiles(entryPath, baseDir)); + continue; + } + + if (entry.isFile() && entry.name.endsWith('.css')) { + const relativePath = relative(baseDir, entryPath).split('\\').join('/'); + files.push(relativePath); + } + } + + return files; +} + +function build() { + const moduleOrder = getModuleOrder(); + const parts = ['/* Claudian Plugin Styles */\n/* Built from src/style/ modules */\n']; + const missingFiles = []; + const invalidImports = []; + const normalizedImports = []; + + for (const modulePath of moduleOrder) { + const resolvedPath = resolve(STYLE_DIR, modulePath); + const relativePath = relative(STYLE_DIR, resolvedPath); + + if (relativePath.startsWith('..') || !relativePath.endsWith('.css')) { + invalidImports.push(modulePath); + continue; + } + + const normalizedPath = relativePath.split('\\').join('/'); + normalizedImports.push(normalizedPath); + + if (!existsSync(resolvedPath)) { + missingFiles.push(normalizedPath); + continue; + } + + const content = readFileSync(resolvedPath, 'utf-8'); + const header = `\n/* ============================================\n ${normalizedPath}\n ============================================ */\n`; + parts.push(header + content); + } + + let hasErrors = false; + + if (invalidImports.length > 0) { + console.error('Invalid @import entries in src/style/index.css:'); + invalidImports.forEach((modulePath) => console.error(` - ${modulePath}`)); + hasErrors = true; + } + + if (missingFiles.length > 0) { + console.error('Missing CSS files:'); + missingFiles.forEach((f) => console.error(` - ${f}`)); + hasErrors = true; + } + + const allCssFiles = listCssFiles(STYLE_DIR).filter((file) => file !== 'index.css'); + const importedSet = new Set(normalizedImports); + const unlistedFiles = allCssFiles.filter((file) => !importedSet.has(file)); + + if (unlistedFiles.length > 0) { + console.error('Unlisted CSS files (not imported in src/style/index.css):'); + unlistedFiles.forEach((file) => console.error(` - ${file}`)); + hasErrors = true; + } + + if (hasErrors) { + process.exit(1); + } + + const output = parts.join('\n'); + writeFileSync(OUTPUT, output); + console.log(`Built styles.css (${(output.length / 1024).toFixed(1)} KB)`); +} + +build(); diff --git a/scripts/build.mjs b/scripts/build.mjs new file mode 100644 index 0000000..e78165b --- /dev/null +++ b/scripts/build.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node +/** + * Combined build script - runs CSS build then esbuild + * Avoids npm echoing commands + */ + +import { execSync } from 'child_process'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +// Run CSS build silently +execSync('node scripts/build-css.mjs', { cwd: ROOT, stdio: 'inherit' }); + +// Run esbuild with args passed through +const args = process.argv.slice(2).join(' '); +execSync(`node esbuild.config.mjs ${args}`, { cwd: ROOT, stdio: 'inherit' }); diff --git a/scripts/check-architecture-boundaries.test.mjs b/scripts/check-architecture-boundaries.test.mjs new file mode 100644 index 0000000..09ea6d2 --- /dev/null +++ b/scripts/check-architecture-boundaries.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import test from 'node:test'; + +function listTypeScriptFiles(root) { + const files = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) files.push(...listTypeScriptFiles(entryPath)); + else if (entry.isFile() && entry.name.endsWith('.ts')) files.push(entryPath); + } + return files; +} + +function findMatches(roots, pattern) { + const matches = []; + for (const root of roots) { + for (const file of listTypeScriptFiles(root)) { + if (pattern.test(fs.readFileSync(file, 'utf8'))) { + matches.push(path.relative(process.cwd(), file)); + } + } + } + return matches; +} + +const sourceRoot = path.join(process.cwd(), 'src'); + +test('core is independent from main, features, and concrete providers', () => { + const pattern = /from\s+['"][^'"]*(?:main['"]|features\/|providers\/(?:claude|codex|opencode|pi))/; + assert.deepEqual(findMatches([path.join(sourceRoot, 'core')], pattern), []); +}); + +test('providers are independent from main and features', () => { + const pattern = /from\s+['"][^'"]*(?:main['"]|features\/)/; + assert.deepEqual(findMatches([path.join(sourceRoot, 'providers')], pattern), []); +}); + +test('features are independent from the composition root and app adapters', () => { + const pattern = /from\s+['"][^'"]*(?:main['"]|app\/)/; + assert.deepEqual(findMatches([path.join(sourceRoot, 'features')], pattern), []); +}); + +test('features and shared UI are independent from concrete providers', () => { + const pattern = /from\s+['"][^'"]*providers\/(?:claude|codex|opencode|pi)/; + assert.deepEqual(findMatches([ + path.join(sourceRoot, 'features'), + path.join(sourceRoot, 'shared'), + ], pattern), []); +}); + +test('persisted settings changes use the coordinator boundary', () => { + const matches = findMatches([sourceRoot], /\.saveSettings\(\)/).filter(file => ![ + 'src/main.ts', + 'src/app/providers/ClaudianProviderHost.ts', + ].includes(file)); + assert.deepEqual(matches, []); +}); diff --git a/scripts/check-release-version.mjs b/scripts/check-release-version.mjs new file mode 100644 index 0000000..3b7b0de --- /dev/null +++ b/scripts/check-release-version.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function validateReleaseVersions({ tag, packageVersion, manifestVersion }) { + if (typeof tag !== 'string' || !/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(tag)) { + throw new Error(`Invalid or missing release tag: ${JSON.stringify(tag)}`); + } + if (tag !== packageVersion || tag !== manifestVersion) { + throw new Error( + `Release version mismatch: tag=${tag}, package.json=${packageVersion}, manifest.json=${manifestVersion}`, + ); + } +} + +function run() { + const scriptDir = path.dirname(fileURLToPath(import.meta.url)); + const rootDir = path.resolve(scriptDir, '..'); + const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8')); + const manifestJson = JSON.parse(fs.readFileSync(path.join(rootDir, 'manifest.json'), 'utf8')); + validateReleaseVersions({ + tag: process.argv[2], + packageVersion: packageJson.version, + manifestVersion: manifestJson.version, + }); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + run(); + } catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/check-release-version.test.mjs b/scripts/check-release-version.test.mjs new file mode 100644 index 0000000..5d1d638 --- /dev/null +++ b/scripts/check-release-version.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { validateReleaseVersions } from './check-release-version.mjs'; + +test('accepts matching release, package, and manifest versions', () => { + assert.doesNotThrow(() => validateReleaseVersions({ + tag: '2.0.31', + packageVersion: '2.0.31', + manifestVersion: '2.0.31', + })); +}); + +test('rejects version mismatches', () => { + assert.throws(() => validateReleaseVersions({ + tag: '2.0.32', + packageVersion: '2.0.31', + manifestVersion: '2.0.31', + }), /Release version mismatch/); +}); + +test('rejects malformed or missing release tags', () => { + for (const tag of [undefined, '', 'v2.0.31', 'refs/tags/2.0.31']) { + assert.throws(() => validateReleaseVersions({ + tag, + packageVersion: '2.0.31', + manifestVersion: '2.0.31', + }), /Invalid or missing release tag/); + } +}); diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs new file mode 100644 index 0000000..07b5d94 --- /dev/null +++ b/scripts/postinstall.mjs @@ -0,0 +1,25 @@ +#!/usr/bin/env node +/** + * Post-install script - copies .env.local.example to .env.local if it doesn't exist + */ + +import { copyFileSync, existsSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +// Skip in CI environments +if (process.env.CI) { + process.exit(0); +} + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +const example = join(ROOT, '.env.local.example'); +const target = join(ROOT, '.env.local'); + +if (existsSync(example) && !existsSync(target)) { + copyFileSync(example, target); + console.log('Created .env.local from .env.local.example'); + console.log('Edit it to set your OBSIDIAN_VAULT path for auto-copy during development.'); +} diff --git a/scripts/rendererSafeUnref.js b/scripts/rendererSafeUnref.js new file mode 100644 index 0000000..65210fd --- /dev/null +++ b/scripts/rendererSafeUnref.js @@ -0,0 +1,205 @@ +const JS_IDENTIFIER = '[A-Za-z_$][A-Za-z0-9_$]*'; + +const UNSAFE_TIMER_UNREF_PATTERNS = [ + { + name: 'claude-sdk-process-transport-close-async', + pattern: new RegExp( + `if \\((${JS_IDENTIFIER}) && !\\1\\.killed && \\1\\.exitCode === null\\) setTimeout\\(\\((${JS_IDENTIFIER}), (${JS_IDENTIFIER})\\) => \\{\\s*` + + `if \\(\\2\\.exitCode !== null\\) \\{\\s*` + + `\\3\\(\\);\\s*` + + `return;\\s*` + + `\\}\\s*` + + `if \\(process\\.platform === "win32"\\) \\{\\s*` + + `setTimeout\\(\\((${JS_IDENTIFIER}), (${JS_IDENTIFIER})\\) => \\{\\s*` + + `if \\(\\4\\.exitCode === null\\) \\4\\.kill\\("SIGKILL"\\);\\s*` + + `\\5\\(\\);\\s*` + + `\\}, 5e3, \\2, \\3\\)\\.unref\\(\\);\\s*` + + `return;\\s*` + + `\\}\\s*` + + `\\2\\.kill\\("SIGTERM"\\), setTimeout\\(\\((${JS_IDENTIFIER})\\) => \\{\\s*` + + `if \\(\\6\\.exitCode === null\\) \\6\\.kill\\("SIGKILL"\\);\\s*` + + `\\}, 5e3, \\2\\)\\.unref\\(\\), \\3\\(\\);\\s*` + + `\\}, (${JS_IDENTIFIER}), \\1, (${JS_IDENTIFIER})\\)\\.unref\\(\\), \\1\\.once\\("exit", (\\(\\) => (?:\\{[^{}]*\\}|[^;{}]+))\\);`, + 'g', + ), + replacement: + 'if ($1 && !$1.killed && $1.exitCode === null) {' + + '\n const processKillTimer = setTimeout(($2, $3) => {' + + '\n if ($2.exitCode !== null) {' + + '\n $3();' + + '\n return;' + + '\n }' + + '\n if (process.platform === "win32") {' + + '\n const windowsForceKillTimer = setTimeout(($4, $5) => {' + + '\n if ($4.exitCode === null) $4.kill("SIGKILL");' + + '\n $5();' + + '\n }, 5e3, $2, $3);' + + '\n windowsForceKillTimer.unref?.();' + + '\n return;' + + '\n }' + + '\n $2.kill("SIGTERM");' + + '\n const forceKillTimer = setTimeout(($6) => {' + + '\n if ($6.exitCode === null) $6.kill("SIGKILL");' + + '\n }, 5e3, $2);' + + '\n forceKillTimer.unref?.();' + + '\n $3();' + + '\n }, $7, $1, $8);' + + '\n processKillTimer.unref?.();' + + '\n $1.once("exit", $9);' + + '\n }', + }, + { + name: 'claude-sdk-process-transport-close', + pattern: new RegExp( + `if \\((${JS_IDENTIFIER}) && !\\1\\.killed && \\1\\.exitCode === null\\) setTimeout\\(\\((${JS_IDENTIFIER})\\) => \\{\\s*` + + `if \\(\\2\\.killed \\|\\| \\2\\.exitCode !== null\\) return;\\s*` + + `\\2\\.kill\\("SIGTERM"\\), setTimeout\\(\\((${JS_IDENTIFIER})\\) => \\{\\s*` + + `if \\(\\3\\.exitCode === null\\) \\3\\.kill\\("SIGKILL"\\);\\s*` + + `\\}, 5e3, \\2\\)\\.unref\\(\\);\\s*` + + `\\}, (${JS_IDENTIFIER}), \\1\\)\\.unref\\(\\), \\1\\.once\\("exit", (\\(\\) => (?:\\{[^{}]*\\}|[^;{}]+))\\);`, + 'g', + ), + replacement: + 'if ($1 && !$1.killed && $1.exitCode === null) {' + + '\n const processKillTimer = setTimeout(($2) => {' + + '\n if ($2.killed || $2.exitCode !== null) return;' + + '\n $2.kill("SIGTERM");' + + '\n const forceKillTimer = setTimeout(($3) => {' + + '\n if ($3.exitCode === null) $3.kill("SIGKILL");' + + '\n }, 5e3, $2);' + + '\n forceKillTimer.unref?.();' + + '\n }, $4, $1);' + + '\n processKillTimer.unref?.();' + + '\n $1.once("exit", $5);' + + '\n }', + }, + { + name: 'mcp-sdk-stdio-close-wait', + pattern: /new Promise\(\((resolve\d+)\) => setTimeout\(\1, 2e3\)\.unref\(\)\)/g, + replacement: + 'new Promise(($1) => {' + + '\n const closeTimeout = setTimeout($1, 2e3);' + + '\n closeTimeout.unref?.();' + + '\n })', + }, +]; + +const TIMER_CALL_PREFIXES = ['setTimeout(', 'setInterval(']; + +function patchRendererUnsafeUnrefSites(contents) { + let nextContents = contents; + const appliedPatches = []; + + for (const patch of UNSAFE_TIMER_UNREF_PATTERNS) { + const matchCount = [...nextContents.matchAll(patch.pattern)].length; + if (matchCount === 0) { + continue; + } + nextContents = nextContents.replace(patch.pattern, patch.replacement); + appliedPatches.push({ name: patch.name, count: matchCount }); + } + + return { + contents: nextContents, + appliedPatches, + }; +} + +function findUnsafeTimerUnrefSites(contents) { + const matches = []; + + let searchIndex = 0; + while (searchIndex < contents.length) { + const timerStart = findNextTimerCall(contents, searchIndex); + if (!timerStart) { + break; + } + + const callEnd = findMatchingParen(contents, timerStart.openParenIndex); + if (callEnd === -1) { + searchIndex = timerStart.startIndex + timerStart.prefix.length; + continue; + } + + const unrefMatch = contents.slice(callEnd + 1).match(/^\s*\.unref\(\)/); + if (unrefMatch) { + const startIndex = timerStart.startIndex; + const endIndex = callEnd + 1 + unrefMatch[0].length; + const line = contents.slice(0, startIndex).split('\n').length; + matches.push({ + line, + snippet: contents.slice(startIndex, endIndex), + }); + searchIndex = endIndex; + continue; + } + + searchIndex = callEnd + 1; + } + + return matches; +} + +function findNextTimerCall(contents, startIndex) { + let nextMatch = null; + + for (const prefix of TIMER_CALL_PREFIXES) { + const index = contents.indexOf(prefix, startIndex); + if (index === -1) { + continue; + } + if (!nextMatch || index < nextMatch.startIndex) { + nextMatch = { + prefix, + startIndex: index, + openParenIndex: index + prefix.length - 1, + }; + } + } + + return nextMatch; +} + +function findMatchingParen(contents, openParenIndex) { + let depth = 1; + let quote = null; + + for (let index = openParenIndex + 1; index < contents.length; index += 1) { + const char = contents[index]; + + if (quote) { + if (char === '\\') { + index += 1; + continue; + } + if (char === quote) { + quote = null; + } + continue; + } + + if (char === '"' || char === '\'' || char === '`') { + quote = char; + continue; + } + + if (char === '(') { + depth += 1; + continue; + } + + if (char === ')') { + depth -= 1; + if (depth === 0) { + return index; + } + } + } + + return -1; +} + +module.exports = { + findUnsafeTimerUnrefSites, + patchRendererUnsafeUnrefSites, +}; diff --git a/scripts/run-jest.js b/scripts/run-jest.js new file mode 100644 index 0000000..72637b2 --- /dev/null +++ b/scripts/run-jest.js @@ -0,0 +1,19 @@ +const { spawnSync } = require('child_process'); +const os = require('os'); +const path = require('path'); + +const jestPath = require.resolve('jest/bin/jest'); +const localStorageFile = path.join(os.tmpdir(), 'claudian-localstorage'); + +const result = spawnSync( + process.execPath, + [`--localstorage-file=${localStorageFile}`, jestPath, ...process.argv.slice(2)], + { stdio: 'inherit' } +); + +if (result.error) { + console.error(result.error); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/scripts/run-tests.js b/scripts/run-tests.js new file mode 100644 index 0000000..8f1530e --- /dev/null +++ b/scripts/run-tests.js @@ -0,0 +1,29 @@ +const { spawnSync } = require('child_process'); +const path = require('path'); + +const root = path.join(__dirname, '..'); + +function run(args) { + const result = spawnSync(process.execPath, args, { + cwd: root, + stdio: 'inherit', + }); + + if (result.error) { + console.error(result.error); + process.exit(1); + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +run([ + path.join(__dirname, 'run-jest.js'), + ...process.argv.slice(2), +]); +run([ + '--test', + path.join(__dirname, 'check-architecture-boundaries.test.mjs'), + path.join(__dirname, 'check-release-version.test.mjs'), +]); diff --git a/scripts/sync-version.js b/scripts/sync-version.js new file mode 100644 index 0000000..53d995f --- /dev/null +++ b/scripts/sync-version.js @@ -0,0 +1,16 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const packagePath = path.join(__dirname, '..', 'package.json'); +const manifestPath = path.join(__dirname, '..', 'manifest.json'); + +const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +const manifestJson = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + +manifestJson.version = packageJson.version; + +fs.writeFileSync(manifestPath, JSON.stringify(manifestJson, null, 2) + '\n'); + +console.log(`Synced version to ${packageJson.version}`); diff --git a/src/app/conversations/ConversationRepository.ts b/src/app/conversations/ConversationRepository.ts new file mode 100644 index 0000000..af9f81c --- /dev/null +++ b/src/app/conversations/ConversationRepository.ts @@ -0,0 +1,317 @@ +import { normalizeProviderModelSelection, resolveConversationModel } from '../../core/providers/conversationModel'; +import { getRuntimeEnvironmentVariables } from '../../core/providers/providerEnvironment'; +import { ProviderRegistry } from '../../core/providers/ProviderRegistry'; +import { ProviderSettingsCoordinator } from '../../core/providers/ProviderSettingsCoordinator'; +import type { AppSessionStorage, ProviderHistoryPathContext } from '../../core/providers/types'; +import { DEFAULT_CHAT_PROVIDER_ID, type ProviderId } from '../../core/providers/types'; +import type { Conversation, ConversationMeta } from '../../core/types'; +import { extractUserDisplayContent } from '../../utils/context'; + +export interface ConversationRepositoryDeps { + getSettings: () => Record; + getVaultPath: () => string | null; + sessions: AppSessionStorage; + onConversationDeleted: (conversationId: string) => Promise; +} + +export class ConversationRepository { + private conversations: Conversation[] = []; + + constructor(private readonly deps: ConversationRepositoryDeps) {} + + replaceAll(conversations: Conversation[]): void { + this.conversations = conversations; + } + + getAll(): Conversation[] { + return this.conversations; + } + + backfillResponseTimestamps(): Conversation[] { + const updated: Conversation[] = []; + for (const conversation of this.conversations) { + if (conversation.lastResponseAt != null || conversation.messages.length === 0) { + continue; + } + + for (let index = conversation.messages.length - 1; index >= 0; index -= 1) { + const message = conversation.messages[index]; + if (message.role === 'assistant') { + conversation.lastResponseAt = message.timestamp; + updated.push(conversation); + break; + } + } + } + return updated; + } + + async create(options?: { + providerId?: ProviderId; + sessionId?: string; + selectedModel?: string; + }): Promise { + const settings = this.deps.getSettings(); + const providerId = options?.providerId ?? DEFAULT_CHAT_PROVIDER_ID; + const sessionId = options?.sessionId; + const providerSettings = ProviderSettingsCoordinator.getProviderSettingsSnapshot(settings, providerId); + const selectedModel = normalizeProviderModelSelection( + providerId, + settings, + options?.selectedModel ?? providerSettings.model, + ) ?? undefined; + const conversation: Conversation = { + id: sessionId ?? this.generateId(), + providerId, + title: this.generateDefaultTitle(), + createdAt: Date.now(), + updatedAt: Date.now(), + sessionId: sessionId ?? null, + selectedModel, + messages: [], + }; + + this.conversations.unshift(conversation); + await this.save(conversation); + return conversation; + } + + async switchTo(id: string): Promise { + const conversation = this.getSync(id); + if (!conversation) return null; + + await this.reconcileProviderSession(conversation); + await this.ensureSelectedModel(conversation); + await this.hydrate(conversation); + return conversation; + } + + async delete( + id: string, + options: { deleteProviderSession?: boolean } = {}, + ): Promise { + const index = this.conversations.findIndex(conversation => conversation.id === id); + if (index === -1) return; + + const conversation = this.conversations[index]; + this.conversations.splice(index, 1); + + if (options.deleteProviderSession !== false) { + const vaultPath = this.deps.getVaultPath(); + await ProviderRegistry + .getConversationHistoryService(conversation.providerId) + .deleteConversationSession( + conversation, + vaultPath, + this.getHistoryPathContext(conversation.providerId, vaultPath), + ); + } + + await this.deps.sessions.deleteMetadata(id); + await this.deps.onConversationDeleted(id); + } + + async handleMissingProviderSession( + id: string, + missingProviderSessionId?: string, + ): Promise<'deleted' | 'reset' | 'preserved' | 'not_found'> { + const conversation = this.getSync(id); + if (!conversation) return 'not_found'; + + const historyService = ProviderRegistry.getConversationHistoryService(conversation.providerId); + if (!historyService.resolveMissingConversationSession) return 'preserved'; + + const previousSessionId = conversation.sessionId; + const previousProviderState = conversation.providerState; + const previousResumeAtMessageId = conversation.resumeAtMessageId; + const vaultPath = this.deps.getVaultPath(); + try { + const resolution = await historyService.resolveMissingConversationSession( + conversation, + vaultPath, + missingProviderSessionId, + this.getHistoryPathContext(conversation.providerId, vaultPath), + ); + if (resolution === 'delete') { + await this.delete(id, { deleteProviderSession: false }); + return 'deleted'; + } + if (resolution === 'reset') { + await this.save(conversation); + return 'reset'; + } + return 'preserved'; + } catch { + conversation.sessionId = previousSessionId; + conversation.providerState = previousProviderState; + conversation.resumeAtMessageId = previousResumeAtMessageId; + return 'preserved'; + } + } + + async rename(id: string, title: string): Promise { + const conversation = this.getSync(id); + if (!conversation) return; + + conversation.title = title.trim() || this.generateDefaultTitle(); + conversation.updatedAt = Date.now(); + await this.save(conversation); + } + + async update(id: string, updates: Partial): Promise { + const conversation = this.getSync(id); + if (!conversation) return; + + const safeUpdates = { ...updates }; + delete safeUpdates.providerId; + if ('selectedModel' in safeUpdates) { + const selectedModel = normalizeProviderModelSelection( + conversation.providerId, + this.deps.getSettings(), + safeUpdates.selectedModel, + ); + if (selectedModel) { + safeUpdates.selectedModel = selectedModel; + } else { + delete safeUpdates.selectedModel; + } + } + Object.assign(conversation, safeUpdates, { updatedAt: Date.now() }); + await this.save(conversation); + } + + async getById(id: string): Promise { + const conversation = this.getSync(id); + if (conversation) { + await this.reconcileProviderSession(conversation); + await this.ensureSelectedModel(conversation); + await this.hydrate(conversation); + } + return conversation; + } + + getSync(id: string): Conversation | null { + return this.conversations.find(conversation => conversation.id === id) ?? null; + } + + findEmpty(): Conversation | null { + return this.conversations.find(conversation => conversation.messages.length === 0) ?? null; + } + + list(): ConversationMeta[] { + return this.conversations.map(conversation => ({ + id: conversation.id, + providerId: conversation.providerId, + title: conversation.title, + createdAt: conversation.createdAt, + updatedAt: conversation.updatedAt, + lastResponseAt: conversation.lastResponseAt, + messageCount: conversation.messages.length, + preview: this.getPreview(conversation), + titleGenerationStatus: conversation.titleGenerationStatus, + })); + } + + private async reconcileProviderSession(conversation: Conversation): Promise { + const historyService = ProviderRegistry.getConversationHistoryService(conversation.providerId); + if (!historyService.getConversationSessionAvailability) return; + + const vaultPath = this.deps.getVaultPath(); + const pathContext = this.getHistoryPathContext(conversation.providerId, vaultPath); + let availability; + try { + availability = await historyService.getConversationSessionAvailability( + conversation, + vaultPath, + pathContext, + ); + } catch { + return; + } + if (availability !== 'relocated' || !historyService.prepareRelocatedConversationSession) return; + + const previousSessionId = conversation.sessionId; + const previousProviderState = conversation.providerState; + const previousResumeAtMessageId = conversation.resumeAtMessageId; + try { + if (await historyService.prepareRelocatedConversationSession( + conversation, + vaultPath, + pathContext, + )) { + await this.save(conversation); + } + } catch { + conversation.sessionId = previousSessionId; + conversation.providerState = previousProviderState; + conversation.resumeAtMessageId = previousResumeAtMessageId; + } + } + + private async ensureSelectedModel(conversation: Conversation): Promise { + const resolved = resolveConversationModel( + this.deps.getSettings(), + conversation.providerId, + conversation, + ); + if (!resolved.shouldPersist || !resolved.model || conversation.selectedModel === resolved.model) return; + + conversation.selectedModel = resolved.model; + await this.save(conversation); + } + + private async hydrate(conversation: Conversation): Promise { + const vaultPath = this.deps.getVaultPath(); + await ProviderRegistry + .getConversationHistoryService(conversation.providerId) + .hydrateConversationHistory( + conversation, + vaultPath, + this.getHistoryPathContext(conversation.providerId, vaultPath), + ); + } + + private getHistoryPathContext( + providerId: ProviderId, + vaultPath: string | null = this.deps.getVaultPath(), + ): ProviderHistoryPathContext { + const settings = this.deps.getSettings(); + return { + environment: { + ...process.env, + ...getRuntimeEnvironmentVariables(settings, providerId), + }, + hostPlatform: process.platform, + settings, + vaultPath, + }; + } + + private save(conversation: Conversation): Promise { + return this.deps.sessions.saveMetadata(this.deps.sessions.toSessionMetadata(conversation)); + } + + private generateId(): string { + return `conv-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + } + + private generateDefaultTitle(): string { + const now = new Date(); + return now.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } + + private getPreview(conversation: Conversation): string { + const firstUserMessage = conversation.messages.find(message => message.role === 'user'); + if (!firstUserMessage) return 'New conversation'; + + const previewText = firstUserMessage.displayContent + ?? extractUserDisplayContent(firstUserMessage.content) + ?? firstUserMessage.content; + return previewText.substring(0, 50) + (previewText.length > 50 ? '...' : ''); + } +} diff --git a/src/app/providers/ClaudianProviderHost.ts b/src/app/providers/ClaudianProviderHost.ts new file mode 100644 index 0000000..59a94b7 --- /dev/null +++ b/src/app/providers/ClaudianProviderHost.ts @@ -0,0 +1,112 @@ +import type { ProviderHost } from '../../core/providers/ProviderHost'; +import type { ProviderCliResolutionContext, ProviderId } from '../../core/providers/types'; +import type { ChatRuntime } from '../../core/runtime/ChatRuntime'; +import type { EnvironmentScope } from '../../core/types/settings'; +import type ClaudianPlugin from '../../main'; + +/** Delegates provider-facing capabilities to the application composition root. */ +export class ClaudianProviderHost implements ProviderHost { + constructor(private readonly plugin: ClaudianPlugin) {} + + get app() { + return this.plugin.app; + } + + get settings() { + return this.plugin.settings; + } + + get storage() { + return this.plugin.storage; + } + + get manifest() { + return this.plugin.manifest; + } + + saveSettings(): Promise { + return this.plugin.saveSettings(); + } + + mutateSettings( + mutation: (settings: typeof this.plugin.settings) => void | Promise, + ): Promise { + return this.plugin.mutateSettings(mutation); + } + + mutateSettingsConditionally( + mutation: (settings: typeof this.plugin.settings) => boolean | Promise, + ): Promise { + return this.plugin.mutateSettingsConditionally(mutation); + } + + loadData(): Promise { + return this.plugin.loadData(); + } + + saveData(data: unknown): Promise { + return this.plugin.saveData(data); + } + + normalizeModelVariantSettings(): boolean { + return this.plugin.normalizeModelVariantSettings(); + } + + getActiveEnvironmentVariables(providerId: ProviderId): string { + return this.plugin.getActiveEnvironmentVariables(providerId); + } + + getEnvironmentVariablesForScope(scope: EnvironmentScope): string { + return this.plugin.getEnvironmentVariablesForScope(scope); + } + + applyEnvironmentVariables(scope: EnvironmentScope, envText: string): Promise { + return this.plugin.applyEnvironmentVariables(scope, envText); + } + + applyEnvironmentVariablesBatch( + updates: Array<{ scope: EnvironmentScope; envText: string }>, + ): Promise { + return this.plugin.applyEnvironmentVariablesBatch(updates); + } + + getResolvedProviderCliPath( + providerId: ProviderId, + context?: ProviderCliResolutionContext, + ): string | null { + return this.plugin.getResolvedProviderCliPath(providerId, context); + } + + refreshModelSelectors(): void { + for (const view of this.plugin.getAllViews()) { + view.refreshModelSelector(); + } + } + + async broadcastToActiveViewRuntimes( + action: (runtime: ChatRuntime) => Promise | void, + ): Promise { + await this.plugin.getView()?.getTabManager()?.broadcastToAllTabs( + (runtime) => Promise.resolve(action(runtime)), + ); + } + + async broadcastToAllViewRuntimes( + action: (runtime: ChatRuntime) => Promise | void, + ): Promise { + for (const view of this.plugin.getAllViews()) { + await view.getTabManager()?.broadcastToAllTabs( + (runtime) => Promise.resolve(action(runtime)), + ); + } + } + + async recycleProviderRuntimes(providerId: ProviderId): Promise { + for (const view of this.plugin.getAllViews()) { + const tabManager = view.getTabManager(); + await tabManager?.recycleProviderRuntimes(providerId); + view.invalidateProviderCommandCaches?.([providerId]); + view.refreshModelSelector?.(); + } + } +} diff --git a/src/app/settings/ClaudianSettingsStorage.ts b/src/app/settings/ClaudianSettingsStorage.ts new file mode 100644 index 0000000..5b4f05b --- /dev/null +++ b/src/app/settings/ClaudianSettingsStorage.ts @@ -0,0 +1,409 @@ +import { + CLAUDIAN_SETTINGS_PATH, + LEGACY_CLAUDIAN_SETTINGS_PATH, +} from '../../core/bootstrap/StoragePaths'; +import { + normalizeHiddenCommandList, + normalizeHiddenProviderCommands, +} from '../../core/providers/commands/hiddenCommands'; +import { + getSharedEnvironmentVariables, + inferEnvironmentSnippetScope, + resolveEnvironmentSnippetScope, +} from '../../core/providers/providerEnvironment'; +import { ProviderRegistry } from '../../core/providers/ProviderRegistry'; +import type { VaultFileAdapter } from '../../core/storage/VaultFileAdapter'; +import { + CHAT_VIEW_PLACEMENTS, + type ChatViewPlacement, + type ClaudianSettings, + type EnvironmentScope, + type EnvSnippet, + type HiddenProviderCommands, + type ProviderConfigMap, +} from '../../core/types/settings'; +import { DEFAULT_CLAUDIAN_SETTINGS } from './defaultSettings'; + +export { + CLAUDIAN_SETTINGS_PATH, + LEGACY_CLAUDIAN_SETTINGS_PATH, +}; + +export type StoredClaudianSettings = ClaudianSettings; + +const LEGACY_STRIPPED_SHARED_SETTING_FIELDS = [ + 'activeConversationId', + 'show1MModel', + 'hiddenSlashCommands', + 'slashCommands', + 'allowExternalAccess', + 'allowedExportPaths', + 'enableBlocklist', + 'blockedCommands', + 'openInMainTab', +] as const; + +function getProviderSettingsAdapters() { + return ProviderRegistry.getRegisteredProviderIds().map(providerId => ({ + adapter: ProviderRegistry.getSettingsStorageAdapter(providerId), + providerId, + })); +} + +function getLegacyTopLevelProviderFields(): string[] { + return getProviderSettingsAdapters().flatMap(({ adapter }) => adapter.legacyTopLevelFields ?? []); +} + +function stripLegacyFields(settings: Record): Record { + const cleaned = { ...settings }; + for (const key of [ + ...LEGACY_STRIPPED_SHARED_SETTING_FIELDS, + ...getLegacyTopLevelProviderFields(), + ]) { + delete cleaned[key]; + } + return cleaned; +} + +function isChatViewPlacement(value: unknown): value is ChatViewPlacement { + return typeof value === 'string' + && (CHAT_VIEW_PLACEMENTS as readonly string[]).includes(value); +} + +function normalizeChatViewPlacement( + value: unknown, + legacyOpenInMainTab: unknown, +): ChatViewPlacement { + if (isChatViewPlacement(value)) { + return value; + } + + if (typeof legacyOpenInMainTab === 'boolean') { + return legacyOpenInMainTab ? 'main-tab' : 'right-sidebar'; + } + + return DEFAULT_CLAUDIAN_SETTINGS.chatViewPlacement; +} + +function shouldPersistChatViewPlacementMigration( + stored: Record, + normalized: ChatViewPlacement, +): boolean { + return 'openInMainTab' in stored + || ( + 'chatViewPlacement' in stored + && stored.chatViewPlacement !== normalized + ); +} + +function normalizeProviderConfigs(value: unknown): ProviderConfigMap { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const result: ProviderConfigMap = {}; + for (const [providerId, config] of Object.entries(value as Record)) { + if (config && typeof config === 'object' && !Array.isArray(config)) { + result[providerId] = { ...(config as Record) }; + } + } + return result; +} + +function projectPersistableProviderConfigs(value: unknown): { + changed: boolean; + providerConfigs: ProviderConfigMap; +} { + const providerConfigs = normalizeProviderConfigs(value); + let changed = false; + + for (const { adapter, providerId } of getProviderSettingsAdapters()) { + const fields = adapter.runtimeOnlyFields ?? []; + const config = providerConfigs[providerId]; + if (!config) { + continue; + } + + for (const field of fields) { + if (field in config) { + delete config[field]; + changed = true; + } + } + } + + return { changed, providerConfigs }; +} + +function hasHostScopedProviderConfigNormalization( + original: ProviderConfigMap, + normalized: unknown, +): boolean { + if (!normalized || typeof normalized !== 'object' || Array.isArray(normalized)) { + return false; + } + + const normalizedConfigs = normalized as ProviderConfigMap; + for (const { adapter, providerId } of getProviderSettingsAdapters()) { + const fields = adapter.hostScopedFields ?? []; + const originalConfig = original[providerId]; + const normalizedConfig = normalizedConfigs[providerId]; + if (!originalConfig || !normalizedConfig) { + continue; + } + + for (const field of fields) { + if ( + field in originalConfig + && JSON.stringify(originalConfig[field]) !== JSON.stringify(normalizedConfig[field]) + ) { + return true; + } + } + } + + return false; +} + +function isEnvironmentScope(value: unknown): value is EnvironmentScope { + return value === 'shared' || (typeof value === 'string' && value.startsWith('provider:')); +} + +function normalizeContextLimits(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'number' && Number.isFinite(entry) && entry > 0) { + result[key] = entry; + } + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function normalizeModelAliases(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const result: Record = {}; + for (const [key, alias] of Object.entries(value)) { + if (typeof alias !== 'string') { + continue; + } + + const modelId = key.trim(); + const normalizedAlias = alias.trim(); + if (modelId && normalizedAlias) { + result[modelId] = normalizedAlias; + } + } + + return result; +} + +function normalizeEnvSnippets(value: unknown): EnvSnippet[] { + if (!Array.isArray(value)) { + return []; + } + + const snippets: EnvSnippet[] = []; + for (const item of value) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + continue; + } + + const candidate = item as Record; + if ( + typeof candidate.id !== 'string' + || typeof candidate.name !== 'string' + || typeof candidate.description !== 'string' + || typeof candidate.envVars !== 'string' + ) { + continue; + } + + const modelAliases = 'modelAliases' in candidate + ? normalizeModelAliases(candidate.modelAliases) + : undefined; + + snippets.push({ + id: candidate.id, + name: candidate.name, + description: candidate.description, + envVars: candidate.envVars, + scope: resolveEnvironmentSnippetScope( + candidate.envVars, + isEnvironmentScope(candidate.scope) + ? candidate.scope + : inferEnvironmentSnippetScope(candidate.envVars), + ), + contextLimits: normalizeContextLimits(candidate.contextLimits), + modelAliases, + }); + } + + return snippets; +} + +function hasLegacyTopLevelProviderFields(stored: Record): boolean { + return getLegacyTopLevelProviderFields().some((key) => key in stored); +} + +function mergeLegacyClaudeHiddenCommands( + hiddenProviderCommands: HiddenProviderCommands, + legacyHiddenSlashCommands: unknown, +): HiddenProviderCommands { + const legacyCommands = normalizeHiddenCommandList(legacyHiddenSlashCommands); + if (legacyCommands.length === 0 || hiddenProviderCommands.claude) { + return hiddenProviderCommands; + } + + return { + ...hiddenProviderCommands, + claude: legacyCommands, + }; +} + +export class ClaudianSettingsStorage { + constructor(private adapter: VaultFileAdapter) {} + + async load(): Promise { + const settingsPath = await this.getLoadPath(); + if (!settingsPath) { + return this.getDefaults(); + } + + const content = await this.adapter.read(settingsPath); + const stored = JSON.parse(content) as Record; + const hiddenProviderCommands = mergeLegacyClaudeHiddenCommands( + normalizeHiddenProviderCommands(stored.hiddenProviderCommands), + stored.hiddenSlashCommands, + ); + const envSnippets = normalizeEnvSnippets(stored.envSnippets); + const customModelAliases = normalizeModelAliases(stored.customModelAliases); + const { + changed: didStripRuntimeProviderConfig, + providerConfigs, + } = projectPersistableProviderConfigs(stored.providerConfigs); + const chatViewPlacement = normalizeChatViewPlacement( + stored.chatViewPlacement, + stored.openInMainTab, + ); + const legacyProviderSettings = { + ...stored, + hiddenProviderCommands, + providerConfigs, + }; + const storedWithoutLegacy = stripLegacyFields({ + ...legacyProviderSettings, + }); + + const legacyNormalized = { + ...storedWithoutLegacy, + sharedEnvironmentVariables: getSharedEnvironmentVariables(legacyProviderSettings), + envSnippets, + customModelAliases, + hiddenProviderCommands, + providerConfigs, + chatViewPlacement, + }; + + const merged = { + ...this.getDefaults(), + ...legacyNormalized, + }; + + let didNormalizeProviderSettings = false; + for (const { adapter } of getProviderSettingsAdapters()) { + didNormalizeProviderSettings = adapter.normalizeStored( + merged, + legacyProviderSettings, + ) || didNormalizeProviderSettings; + } + const didNormalizeHostScopedProviderConfigs = hasHostScopedProviderConfigNormalization( + providerConfigs, + merged.providerConfigs, + ); + + if ( + settingsPath !== CLAUDIAN_SETTINGS_PATH + || ( + hasLegacyTopLevelProviderFields(stored) + || 'show1MModel' in stored + || 'slashCommands' in stored + || 'hiddenSlashCommands' in stored + || 'activeConversationId' in stored + || 'allowExternalAccess' in stored + || 'allowedExportPaths' in stored + || 'enableBlocklist' in stored + || 'blockedCommands' in stored + || shouldPersistChatViewPlacementMigration(stored, chatViewPlacement) + || JSON.stringify(envSnippets) !== JSON.stringify(stored.envSnippets ?? []) + || ( + 'customModelAliases' in stored + && JSON.stringify(customModelAliases) !== JSON.stringify(stored.customModelAliases ?? {}) + ) + || didNormalizeProviderSettings + || didStripRuntimeProviderConfig + || didNormalizeHostScopedProviderConfigs + ) + ) { + await this.save(merged); + } + + return merged; + } + + async save(settings: StoredClaudianSettings): Promise { + const { providerConfigs } = projectPersistableProviderConfigs(settings.providerConfigs); + const content = JSON.stringify( + stripLegacyFields({ + ...settings, + providerConfigs, + }), + null, + 2, + ); + await this.adapter.write(CLAUDIAN_SETTINGS_PATH, content); + await this.deleteLegacyFileIfPresent(); + } + + async exists(): Promise { + if (await this.adapter.exists(CLAUDIAN_SETTINGS_PATH)) { + return true; + } + + return this.adapter.exists(LEGACY_CLAUDIAN_SETTINGS_PATH); + } + + async update(updates: Partial): Promise { + const current = await this.load(); + await this.save({ ...current, ...updates }); + } + + private getDefaults(): StoredClaudianSettings { + return DEFAULT_CLAUDIAN_SETTINGS; + } + + private async getLoadPath(): Promise { + if (await this.adapter.exists(CLAUDIAN_SETTINGS_PATH)) { + return CLAUDIAN_SETTINGS_PATH; + } + + if (await this.adapter.exists(LEGACY_CLAUDIAN_SETTINGS_PATH)) { + return LEGACY_CLAUDIAN_SETTINGS_PATH; + } + + return null; + } + + private async deleteLegacyFileIfPresent(): Promise { + if (await this.adapter.exists(LEGACY_CLAUDIAN_SETTINGS_PATH)) { + await this.adapter.delete(LEGACY_CLAUDIAN_SETTINGS_PATH); + } + } +} diff --git a/src/app/settings/SettingsCoordinator.ts b/src/app/settings/SettingsCoordinator.ts new file mode 100644 index 0000000..bdf2269 --- /dev/null +++ b/src/app/settings/SettingsCoordinator.ts @@ -0,0 +1,41 @@ +export type SettingsMutation = ( + settings: T, +) => void | Promise; + +export type ConditionalSettingsMutation = ( + settings: T, +) => boolean | Promise; + +export class SettingsCoordinator { + private tail: Promise = Promise.resolve(); + + constructor( + private readonly settings: T, + private readonly persist: (settings: T) => Promise, + ) {} + + mutate(mutation: SettingsMutation): Promise { + return this.enqueue(async () => { + await mutation(this.settings); + await this.persist(this.settings); + }); + } + + mutateConditionally(mutation: ConditionalSettingsMutation): Promise { + return this.enqueue(async () => { + if (await mutation(this.settings)) { + await this.persist(this.settings); + } + }); + } + + persistCurrent(): Promise { + return this.enqueue(() => this.persist(this.settings)); + } + + private enqueue(operation: () => Promise): Promise { + const result = this.tail.then(operation); + this.tail = result.catch(() => undefined); + return result; + } +} diff --git a/src/app/settings/defaultSettings.ts b/src/app/settings/defaultSettings.ts new file mode 100644 index 0000000..d7fb071 --- /dev/null +++ b/src/app/settings/defaultSettings.ts @@ -0,0 +1,55 @@ +import { getDefaultHiddenProviderCommands } from '../../core/providers/commands/hiddenCommands'; +import { DEFAULT_REASONING_VALUE } from '../../core/providers/reasoning'; +import { type ClaudianSettings } from '../../core/types/settings'; +import { getBuiltInProviderDefaultConfigs } from '../../providers/defaultProviderConfigs'; + +export const DEFAULT_CLAUDIAN_SETTINGS: ClaudianSettings = { + userName: '', + + permissionMode: 'yolo', + + model: 'haiku', + thinkingBudget: 'off', + effortLevel: DEFAULT_REASONING_VALUE, + serviceTier: 'default', + enableAutoTitleGeneration: true, + titleGenerationModel: '', + + excludedTags: [], + mediaFolder: '', + systemPrompt: '', + persistentExternalContextPaths: [], + + sharedEnvironmentVariables: '', + envSnippets: [], + customContextLimits: {}, + customModelAliases: {}, + + keyboardNavigation: { + scrollUpKey: 'w', + scrollDownKey: 's', + focusInputKey: 'i', + }, + requireCommandOrControlEnterToSend: false, + + locale: 'en', + + providerConfigs: getBuiltInProviderDefaultConfigs(), + + settingsProvider: 'claude', + savedProviderModel: {}, + savedProviderEffort: {}, + savedProviderServiceTier: {}, + savedProviderThinkingBudget: {}, + savedProviderPermissionMode: {}, + + lastCustomModel: '', + + maxTabs: 3, + enableAutoScroll: true, + deferMathRenderingDuringStreaming: true, + expandFileEditsByDefault: false, + chatViewPlacement: 'right-sidebar', + + hiddenProviderCommands: getDefaultHiddenProviderCommands(), +}; diff --git a/src/app/storage/SharedStorageService.ts b/src/app/storage/SharedStorageService.ts new file mode 100644 index 0000000..3ef68d4 --- /dev/null +++ b/src/app/storage/SharedStorageService.ts @@ -0,0 +1,73 @@ +import type { Plugin } from 'obsidian'; +import { Notice } from 'obsidian'; + +import { SESSIONS_PATH, SessionStorage } from '../../core/bootstrap/SessionStorage'; +import type { SharedAppStorage } from '../../core/bootstrap/storage'; +import { CLAUDIAN_STORAGE_PATH } from '../../core/bootstrap/StoragePaths'; +import { normalizeTabManagerState } from '../../core/bootstrap/tabManagerState'; +import type { AppTabManagerState } from '../../core/providers/types'; +import { VaultFileAdapter } from '../../core/storage/VaultFileAdapter'; +import { ClaudianSettingsStorage, type StoredClaudianSettings } from '../settings/ClaudianSettingsStorage'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +export class SharedStorageService implements SharedAppStorage { + readonly claudianSettings: ClaudianSettingsStorage; + readonly sessions: SessionStorage; + + private adapter: VaultFileAdapter; + private plugin: Plugin; + + constructor(plugin: Plugin) { + this.plugin = plugin; + this.adapter = new VaultFileAdapter(plugin.app); + this.claudianSettings = new ClaudianSettingsStorage(this.adapter); + this.sessions = new SessionStorage(this.adapter); + } + + async initialize(): Promise<{ claudian: Record }> { + await this.ensureDirectories(); + const claudian = await this.claudianSettings.load(); + return { claudian }; + } + + async saveClaudianSettings(settings: Record): Promise { + await this.claudianSettings.save(settings as StoredClaudianSettings); + } + + async setTabManagerState(state: AppTabManagerState): Promise { + try { + const loaded: unknown = await this.plugin.loadData(); + const data = isRecord(loaded) ? loaded : {}; + data.tabManagerState = state; + await this.plugin.saveData(data); + } catch { + new Notice('Failed to save tab layout'); + } + } + + async getTabManagerState(): Promise { + try { + const data: unknown = await this.plugin.loadData(); + if (!isRecord(data) || !data.tabManagerState) { + return null; + } + + return normalizeTabManagerState(data.tabManagerState); + } catch { + return null; + } + } + + getAdapter(): VaultFileAdapter { + return this.adapter; + } + + private async ensureDirectories(): Promise { + await this.adapter.ensureFolder(CLAUDIAN_STORAGE_PATH); + await this.adapter.ensureFolder(SESSIONS_PATH); + } + +} diff --git a/src/core/AGENTS.md b/src/core/AGENTS.md new file mode 100644 index 0000000..8e68123 --- /dev/null +++ b/src/core/AGENTS.md @@ -0,0 +1,61 @@ +# Core Infrastructure + +`src/core/` is provider-neutral infrastructure. Features depend on core contracts; providers implement those contracts behind the registry boundary. + +## Ownership + +| Module | Owns | +| --- | --- | +| `bootstrap/` | Provider-neutral session metadata storage and shared app-storage contracts | +| `commands/` | Built-in cross-provider commands | +| `mcp/` | Provider-neutral MCP coordination and config parsing | +| `prompt/` | Shared prompt templates | +| `providers/` | Registry, capability, environment, model-routing, and workspace-service contracts | +| `providers/commands/` | Shared command catalog contracts | +| `runtime/` | `ChatRuntime`, turn preparation, streaming, approval, and query contracts | +| `security/` | Permission and approval helpers | +| `storage/` | Generic vault/home filesystem adapters | +| `tools/` | Shared tool constants and formatting helpers | +| `types/` | Shared type definitions | + +## Dependency Rules + +```text +types/ <- all modules +storage/ <- bootstrap/, provider workspace services +runtime/ + providers/ <- provider implementations +features/ -> core contracts only +``` + +Do not import provider implementation files from `core/`. If shared behavior needs provider data, add an explicit contract and have providers implement it. + +## Key Contracts + +```typescript +const runtime = ProviderRegistry.createChatRuntime({ plugin, providerId }); +const preparedTurn = runtime.prepareTurn(request); + +for await (const chunk of runtime.query(preparedTurn, history)) { + // Feature layer consumes provider-neutral StreamChunk values. +} +``` + +Title generation is provider-routed by the global `titleGenerationModel` setting and is independent from the active chat tab provider. + +Workspace services are resolved through `ProviderWorkspaceRegistry`: + +```typescript +const catalog = ProviderWorkspaceRegistry.getCommandCatalog(providerId); +const agentMentions = ProviderWorkspaceRegistry.getAgentMentionProvider(providerId); +const cliResolver = ProviderWorkspaceRegistry.getCliResolver(providerId); +``` + +## Gotchas + +- `ChatRuntime.cleanup()` must run when a tab is disposed. +- `Conversation.providerState` is opaque to feature code. Provider-specific fields belong behind typed provider helpers. +- Plan mode is capability-driven. Do not hardcode provider IDs in feature logic unless the provider contract cannot express the distinction. +- Command discovery differs by provider: + - Claude merges runtime-discovered commands with vault commands and skills. + - Codex skills come from `CodexSkillCatalog` and do not depend on runtime command discovery. + - OpenCode and Pi expose runtime commands through their provider protocols. diff --git a/src/core/CLAUDE.md b/src/core/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/src/core/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/core/auxiliary/AuxQueryRunner.ts b/src/core/auxiliary/AuxQueryRunner.ts new file mode 100644 index 0000000..d3dc3c0 --- /dev/null +++ b/src/core/auxiliary/AuxQueryRunner.ts @@ -0,0 +1,11 @@ +export interface AuxQueryConfig { + systemPrompt: string; + model?: string; + abortController?: AbortController; + onTextChunk?: (accumulatedText: string) => void; +} + +export interface AuxQueryRunner { + query(config: AuxQueryConfig, prompt: string): Promise; + reset(): void; +} diff --git a/src/core/auxiliary/QueryBackedInlineEditService.ts b/src/core/auxiliary/QueryBackedInlineEditService.ts new file mode 100644 index 0000000..e9fb6e6 --- /dev/null +++ b/src/core/auxiliary/QueryBackedInlineEditService.ts @@ -0,0 +1,73 @@ +import { appendContextFiles } from '../../utils/context'; +import { + buildInlineEditPrompt, + getInlineEditSystemPrompt, + parseInlineEditResponse, +} from '../prompt/inlineEdit'; +import type { + InlineEditRequest, + InlineEditResult, + InlineEditService, +} from '../providers/types'; +import type { AuxQueryRunner } from './AuxQueryRunner'; + +export class QueryBackedInlineEditService implements InlineEditService { + private abortController: AbortController | null = null; + private hasConversation = false; + private modelOverride: string | undefined; + + constructor(private readonly runner: AuxQueryRunner) {} + + setModelOverride(model?: string): void { + const trimmed = model?.trim(); + this.modelOverride = trimmed ? trimmed : undefined; + } + + resetConversation(): void { + this.runner.reset(); + this.hasConversation = false; + } + + async editText(request: InlineEditRequest): Promise { + this.resetConversation(); + return this.sendMessage(buildInlineEditPrompt(request)); + } + + async continueConversation(message: string, contextFiles?: string[]): Promise { + if (!this.hasConversation) { + return { success: false, error: 'No active conversation to continue' }; + } + + let prompt = message; + if (contextFiles && contextFiles.length > 0) { + prompt = appendContextFiles(message, contextFiles); + } + return this.sendMessage(prompt); + } + + cancel(): void { + this.abortController?.abort(); + this.abortController = null; + } + + private async sendMessage(prompt: string): Promise { + this.abortController = new AbortController(); + + try { + const text = await this.runner.query({ + abortController: this.abortController, + model: this.modelOverride, + systemPrompt: getInlineEditSystemPrompt(), + }, prompt); + this.hasConversation = true; + return parseInlineEditResponse(text); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } finally { + this.abortController = null; + } + } +} diff --git a/src/core/auxiliary/QueryBackedInstructionRefineService.ts b/src/core/auxiliary/QueryBackedInstructionRefineService.ts new file mode 100644 index 0000000..52be481 --- /dev/null +++ b/src/core/auxiliary/QueryBackedInstructionRefineService.ts @@ -0,0 +1,78 @@ +import { buildRefineSystemPrompt, parseInstructionRefineResponse } from '../prompt/instructionRefine'; +import type { + InstructionRefineService, + RefineProgressCallback, +} from '../providers/types'; +import type { InstructionRefineResult } from '../types'; +import type { AuxQueryRunner } from './AuxQueryRunner'; + +export class QueryBackedInstructionRefineService implements InstructionRefineService { + private abortController: AbortController | null = null; + private existingInstructions = ''; + private hasConversation = false; + private modelOverride: string | undefined; + + constructor(private readonly runner: AuxQueryRunner) {} + + setModelOverride(model?: string): void { + const trimmed = model?.trim(); + this.modelOverride = trimmed ? trimmed : undefined; + } + + resetConversation(): void { + this.runner.reset(); + this.hasConversation = false; + } + + async refineInstruction( + rawInstruction: string, + existingInstructions: string, + onProgress?: RefineProgressCallback, + ): Promise { + this.resetConversation(); + this.existingInstructions = existingInstructions; + return this.sendMessage(`Please refine this instruction: "${rawInstruction}"`, onProgress); + } + + async continueConversation( + message: string, + onProgress?: RefineProgressCallback, + ): Promise { + if (!this.hasConversation) { + return { success: false, error: 'No active conversation to continue' }; + } + return this.sendMessage(message, onProgress); + } + + cancel(): void { + this.abortController?.abort(); + this.abortController = null; + } + + private async sendMessage( + prompt: string, + onProgress?: RefineProgressCallback, + ): Promise { + this.abortController = new AbortController(); + + try { + const text = await this.runner.query({ + abortController: this.abortController, + model: this.modelOverride, + onTextChunk: onProgress + ? (accumulatedText: string) => onProgress(parseInstructionRefineResponse(accumulatedText)) + : undefined, + systemPrompt: buildRefineSystemPrompt(this.existingInstructions), + }, prompt); + this.hasConversation = true; + return parseInstructionRefineResponse(text); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } finally { + this.abortController = null; + } + } +} diff --git a/src/core/auxiliary/QueryBackedTitleGenerationService.ts b/src/core/auxiliary/QueryBackedTitleGenerationService.ts new file mode 100644 index 0000000..5d93263 --- /dev/null +++ b/src/core/auxiliary/QueryBackedTitleGenerationService.ts @@ -0,0 +1,90 @@ +import { + buildTitleGenerationPrompt, + parseTitleGenerationResponse, + TITLE_GENERATION_SYSTEM_PROMPT, +} from '../prompt/titleGeneration'; +import type { + TitleGenerationCallback, + TitleGenerationResult, + TitleGenerationService, +} from '../providers/types'; +import type { AuxQueryRunner } from './AuxQueryRunner'; + +interface ActiveGeneration { + abortController: AbortController; + runner: AuxQueryRunner; +} + +export interface QueryBackedTitleGenerationServiceOptions { + createRunner: () => AuxQueryRunner; + resolveModel?: () => string | undefined; +} + +export class QueryBackedTitleGenerationService implements TitleGenerationService { + private readonly activeGenerations = new Map(); + + constructor(private readonly options: QueryBackedTitleGenerationServiceOptions) {} + + async generateTitle( + conversationId: string, + userMessage: string, + callback: TitleGenerationCallback, + ): Promise { + const existing = this.activeGenerations.get(conversationId); + if (existing) { + existing.abortController.abort(); + existing.runner.reset(); + } + + const abortController = new AbortController(); + const runner = this.options.createRunner(); + const generation = { abortController, runner }; + this.activeGenerations.set(conversationId, generation); + + try { + const text = await runner.query({ + abortController, + model: this.options.resolveModel?.(), + systemPrompt: TITLE_GENERATION_SYSTEM_PROMPT, + }, buildTitleGenerationPrompt(userMessage)); + const title = parseTitleGenerationResponse(text); + await this.safeCallback( + callback, + conversationId, + title + ? { success: true, title } + : { success: false, error: 'Failed to parse title from response' }, + ); + } catch (error) { + await this.safeCallback(callback, conversationId, { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } finally { + runner.reset(); + if (this.activeGenerations.get(conversationId) === generation) { + this.activeGenerations.delete(conversationId); + } + } + } + + cancel(): void { + for (const active of this.activeGenerations.values()) { + active.abortController.abort(); + active.runner.reset(); + } + this.activeGenerations.clear(); + } + + private async safeCallback( + callback: TitleGenerationCallback, + conversationId: string, + result: TitleGenerationResult, + ): Promise { + try { + await callback(conversationId, result); + } catch { + // Ignore callback failures to match existing service behavior. + } + } +} diff --git a/src/core/bootstrap/SessionStorage.ts b/src/core/bootstrap/SessionStorage.ts new file mode 100644 index 0000000..4cf1344 --- /dev/null +++ b/src/core/bootstrap/SessionStorage.ts @@ -0,0 +1,218 @@ +import { ProviderRegistry } from '../providers/ProviderRegistry'; +import { DEFAULT_CHAT_PROVIDER_ID } from '../providers/types'; +import type { VaultFileAdapter } from '../storage/VaultFileAdapter'; +import type { + Conversation, + ConversationMeta, + SessionMetadata, +} from '../types'; +import { LEGACY_SESSIONS_PATH, SESSIONS_PATH } from './StoragePaths'; + +export { + LEGACY_SESSIONS_PATH, + SESSIONS_PATH, +}; + +const SAFE_METADATA_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +export function isValidSessionMetadataId(id: string): boolean { + return SAFE_METADATA_ID_PATTERN.test(id) + && id !== '.' + && id !== '..' + && !/%(?:2f|5c)/i.test(id); +} + +function assertValidSessionMetadataId(id: string): void { + if (!isValidSessionMetadataId(id)) { + throw new Error(`Invalid session metadata id: ${JSON.stringify(id)}`); + } +} + +export class SessionStorage { + constructor(private adapter: VaultFileAdapter) {} + + getMetadataPath(id: string): string { + assertValidSessionMetadataId(id); + return `${SESSIONS_PATH}/${id}.meta.json`; + } + + getLegacyMetadataPath(id: string): string { + assertValidSessionMetadataId(id); + return `${LEGACY_SESSIONS_PATH}/${id}.meta.json`; + } + + async saveMetadata(metadata: SessionMetadata): Promise { + const filePath = this.getMetadataPath(metadata.id); + const content = JSON.stringify(metadata, null, 2); + await this.adapter.write(filePath, content); + await this.deleteLegacyMetadataIfPresent(metadata.id); + } + + async loadMetadata(id: string): Promise { + if (!isValidSessionMetadataId(id)) { + return null; + } + const filePath = await this.getLoadPath(id); + + try { + if (!filePath) { + return null; + } + + const content = await this.adapter.read(filePath); + const metadata = JSON.parse(content) as SessionMetadata; + if (metadata.id !== id || !isValidSessionMetadataId(metadata.id)) { + return null; + } + + if (filePath !== this.getMetadataPath(id)) { + await this.saveMetadata(metadata); + } + + return metadata; + } catch { + return null; + } + } + + async deleteMetadata(id: string): Promise { + await this.adapter.delete(this.getMetadataPath(id)); + await this.deleteLegacyMetadataIfPresent(id); + } + + async listMetadata(): Promise { + const metas: SessionMetadata[] = []; + + const files = await this.listUniqueMetadataFiles(); + + for (const filePath of files) { + const fileId = this.getMetadataIdFromPath(filePath); + if (!fileId || !isValidSessionMetadataId(fileId)) { + continue; + } + try { + const content = await this.adapter.read(filePath); + const raw = JSON.parse(content) as SessionMetadata; + if (raw.id !== fileId || !isValidSessionMetadataId(raw.id)) { + continue; + } + metas.push(raw); + + if (filePath.startsWith(`${LEGACY_SESSIONS_PATH}/`)) { + await this.saveMetadata(raw); + } + } catch { + // Skip files that fail to load. + } + } + + return metas; + } + + async listAllConversations(): Promise { + const nativeMetas = await this.listMetadata(); + + const metas: ConversationMeta[] = nativeMetas.map((meta) => ({ + id: meta.id, + providerId: meta.providerId ?? DEFAULT_CHAT_PROVIDER_ID, + title: meta.title, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + lastResponseAt: meta.lastResponseAt, + messageCount: 0, + preview: 'SDK session', + titleGenerationStatus: meta.titleGenerationStatus, + })); + + return metas.sort((a, b) => + (b.lastResponseAt ?? b.createdAt) - (a.lastResponseAt ?? a.createdAt) + ); + } + + toSessionMetadata(conversation: Conversation): SessionMetadata { + const historyService = ProviderRegistry.getConversationHistoryService(conversation.providerId); + const providerState = historyService.buildPersistedProviderState + ? historyService.buildPersistedProviderState(conversation) + : conversation.providerState; + + return { + id: conversation.id, + providerId: conversation.providerId, + title: conversation.title, + titleGenerationStatus: conversation.titleGenerationStatus, + createdAt: conversation.createdAt, + updatedAt: conversation.updatedAt, + lastResponseAt: conversation.lastResponseAt, + sessionId: conversation.sessionId, + selectedModel: conversation.selectedModel, + providerState: providerState && Object.keys(providerState).length > 0 ? providerState : undefined, + currentNote: conversation.currentNote, + externalContextPaths: conversation.externalContextPaths, + enabledMcpServers: conversation.enabledMcpServers, + usage: conversation.usage, + resumeAtMessageId: conversation.resumeAtMessageId, + }; + } + + private async getLoadPath(id: string): Promise { + const filePath = this.getMetadataPath(id); + if (await this.adapter.exists(filePath)) { + return filePath; + } + + const legacyFilePath = this.getLegacyMetadataPath(id); + if (await this.adapter.exists(legacyFilePath)) { + return legacyFilePath; + } + + return null; + } + + private async deleteLegacyMetadataIfPresent(id: string): Promise { + const legacyFilePath = this.getLegacyMetadataPath(id); + if (await this.adapter.exists(legacyFilePath)) { + await this.adapter.delete(legacyFilePath); + } + } + + private async listUniqueMetadataFiles(): Promise { + const preferredFiles = await this.listMetadataFiles(SESSIONS_PATH); + const fallbackFiles = await this.listMetadataFiles(LEGACY_SESSIONS_PATH); + const filesByName = new Map(); + + for (const filePath of preferredFiles) { + filesByName.set(this.getFileName(filePath), filePath); + } + + for (const filePath of fallbackFiles) { + const fileName = this.getFileName(filePath); + if (!filesByName.has(fileName)) { + filesByName.set(fileName, filePath); + } + } + + return Array.from(filesByName.values()); + } + + private async listMetadataFiles(folderPath: string): Promise { + try { + const files = await this.adapter.listFiles(folderPath); + return files.filter((filePath) => filePath.endsWith('.meta.json')); + } catch { + return []; + } + } + + private getFileName(filePath: string): string { + const parts = filePath.split('/'); + return parts[parts.length - 1] ?? filePath; + } + + private getMetadataIdFromPath(filePath: string): string | null { + const fileName = this.getFileName(filePath); + const suffix = '.meta.json'; + return fileName.endsWith(suffix) + ? fileName.slice(0, -suffix.length) + : null; + } +} diff --git a/src/core/bootstrap/StoragePaths.ts b/src/core/bootstrap/StoragePaths.ts new file mode 100644 index 0000000..9b3b26b --- /dev/null +++ b/src/core/bootstrap/StoragePaths.ts @@ -0,0 +1,7 @@ +export const CLAUDIAN_STORAGE_PATH = '.claudian'; + +export const LEGACY_CLAUDIAN_SETTINGS_PATH = '.claude/claudian-settings.json'; +export const CLAUDIAN_SETTINGS_PATH = `${CLAUDIAN_STORAGE_PATH}/claudian-settings.json`; + +export const LEGACY_SESSIONS_PATH = '.claude/sessions'; +export const SESSIONS_PATH = `${CLAUDIAN_STORAGE_PATH}/sessions`; diff --git a/src/core/bootstrap/storage.ts b/src/core/bootstrap/storage.ts new file mode 100644 index 0000000..e0d0c48 --- /dev/null +++ b/src/core/bootstrap/storage.ts @@ -0,0 +1,20 @@ +import type { AppSessionStorage, AppTabManagerState } from '../providers/types'; +import type { VaultFileAdapter } from '../storage/VaultFileAdapter'; + +/** + * Minimal shared app storage contract. + * + * This interface covers only the storage concerns that are shared across + * all providers: Claudian settings, tab manager state, and session metadata. + * + * Provider-specific storage surfaces (CC settings, slash commands, skills, + * agents, MCP config) live behind provider-owned modules. + */ +export interface SharedAppStorage { + initialize(): Promise<{ claudian: Record }>; + saveClaudianSettings(settings: Record): Promise; + setTabManagerState(state: AppTabManagerState): Promise; + getTabManagerState(): Promise; + sessions: AppSessionStorage; + getAdapter(): VaultFileAdapter; +} diff --git a/src/core/bootstrap/tabManagerState.ts b/src/core/bootstrap/tabManagerState.ts new file mode 100644 index 0000000..c933ebb --- /dev/null +++ b/src/core/bootstrap/tabManagerState.ts @@ -0,0 +1,51 @@ +import type { AppTabManagerState } from '../providers/types'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +export function normalizeTabManagerState(data: unknown): AppTabManagerState | null { + if (!isRecord(data) || !Array.isArray(data.openTabs)) { + return null; + } + + const openTabs: AppTabManagerState['openTabs'] = []; + const openTabIds = new Set(); + for (const tab of data.openTabs) { + if (!isRecord(tab) || typeof tab.tabId !== 'string') { + continue; + } + + openTabs.push({ + tabId: tab.tabId, + conversationId: typeof tab.conversationId === 'string' ? tab.conversationId : null, + ...(typeof tab.draftModel === 'string' + ? { draftModel: tab.draftModel } + : {}), + }); + openTabIds.add(tab.tabId); + } + + const expandedTitleTabIds: string[] = []; + const seenExpandedTabIds = new Set(); + if (Array.isArray(data.expandedTitleTabIds)) { + for (const tabId of data.expandedTitleTabIds) { + if ( + typeof tabId !== 'string' + || !openTabIds.has(tabId) + || seenExpandedTabIds.has(tabId) + ) { + continue; + } + + expandedTitleTabIds.push(tabId); + seenExpandedTabIds.add(tabId); + } + } + + return { + openTabs, + activeTabId: typeof data.activeTabId === 'string' ? data.activeTabId : null, + ...(expandedTitleTabIds.length > 0 ? { expandedTitleTabIds } : {}), + }; +} diff --git a/src/core/commands/builtInCommands.ts b/src/core/commands/builtInCommands.ts new file mode 100644 index 0000000..86b3f4d --- /dev/null +++ b/src/core/commands/builtInCommands.ts @@ -0,0 +1,141 @@ +/** + * Claudian - Built-in slash commands + * + * System commands that perform actions (not prompt expansions). + * These are handled separately from user-defined slash commands. + */ + +import { ProviderRegistry } from '../providers/ProviderRegistry'; +import type { ProviderCapabilities, ProviderId } from '../providers/types'; + +export type BuiltInCommandAction = 'clear' | 'add-dir' | 'resume' | 'fork'; +type BuiltInCommandCapability = 'supportsNativeHistory' | 'supportsFork'; +type BuiltInCommandSupportContext = ProviderId | Pick; + +export interface BuiltInCommand { + name: string; + aliases?: string[]; + description: string; + action: BuiltInCommandAction; + /** Whether this command accepts arguments. */ + hasArgs?: boolean; + /** Hint for arguments shown in dropdown (e.g., "path"). */ + argumentHint?: string; + /** When set, provider capabilities must expose this feature. */ + requiredCapability?: BuiltInCommandCapability; +} + +export interface BuiltInCommandResult { + command: BuiltInCommand; + /** Arguments passed to the command (trimmed, after command name). */ + args: string; +} + +export const BUILT_IN_COMMANDS: BuiltInCommand[] = [ + { + name: 'clear', + aliases: ['new'], + description: 'Start a new conversation', + action: 'clear', + }, + { + name: 'add-dir', + description: 'Add external context directory', + action: 'add-dir', + hasArgs: true, + argumentHint: '[path/to/directory]', + }, + { + name: 'resume', + description: 'Resume a previous conversation', + action: 'resume', + requiredCapability: 'supportsNativeHistory', + }, + { + name: 'fork', + description: 'Fork entire conversation to new session', + action: 'fork', + requiredCapability: 'supportsFork', + }, +]; + +/** Map of command names/aliases to their definitions. */ +const commandMap = new Map(); + +for (const cmd of BUILT_IN_COMMANDS) { + commandMap.set(cmd.name.toLowerCase(), cmd); + if (cmd.aliases) { + for (const alias of cmd.aliases) { + commandMap.set(alias.toLowerCase(), cmd); + } + } +} + +function resolveCapabilities( + context: BuiltInCommandSupportContext, +): Pick | null { + if (typeof context !== 'string') { + return context; + } + + try { + return ProviderRegistry.getCapabilities(context); + } catch { + return null; + } +} + +export function isBuiltInCommandSupported( + command: BuiltInCommand, + context?: BuiltInCommandSupportContext, +): boolean { + if (!command.requiredCapability || !context) { + return true; + } + + const capabilities = resolveCapabilities(context); + return capabilities ? capabilities[command.requiredCapability] : false; +} + +/** + * Checks if input is a built-in command. + * Returns the command and arguments if found, null otherwise. + */ +export function detectBuiltInCommand(input: string): BuiltInCommandResult | null { + const trimmed = input.trim(); + if (!trimmed.startsWith('/')) return null; + + // Extract command name (first word after /) + const match = trimmed.match(/^\/([a-zA-Z0-9_-]+)(?:\s(.*))?$/); + if (!match) return null; + + const cmdName = match[1].toLowerCase(); + const command = commandMap.get(cmdName); + if (!command) return null; + + const args = (match[2] || '').trim(); + + return { command, args }; +} + +/** + * Gets built-in commands for dropdown display. + * When providerId is given, excludes commands restricted to other providers. + */ +export function getBuiltInCommandsForDropdown(context?: BuiltInCommandSupportContext): Array<{ + id: string; + name: string; + description: string; + content: string; + argumentHint?: string; +}> { + return BUILT_IN_COMMANDS + .filter((cmd) => isBuiltInCommandSupported(cmd, context)) + .map((cmd) => ({ + id: `builtin:${cmd.name}`, + name: cmd.name, + description: cmd.description, + content: '', // Built-in commands don't have prompt content + argumentHint: cmd.argumentHint, + })); +} diff --git a/src/core/mcp/McpConfigParser.ts b/src/core/mcp/McpConfigParser.ts new file mode 100644 index 0000000..cd0e56c --- /dev/null +++ b/src/core/mcp/McpConfigParser.ts @@ -0,0 +1,102 @@ +import type { McpServerConfig, ParsedMcpConfig } from '../types'; +import { isValidMcpServerConfig } from '../types'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +/** + * Parse pasted JSON (supports multiple formats). + * + * Formats supported: + * 1. Full Claude Code format: { "mcpServers": { "name": {...} } } + * 2. Single server with name: { "name": { "command": "..." } } + * 3. Single server without name: { "command": "..." } + * 4. Multiple named servers: { "server1": {...}, "server2": {...} } + */ +export function parseClipboardConfig(json: string): ParsedMcpConfig { + try { + const parsed: unknown = JSON.parse(json); + + if (!isRecord(parsed)) { + throw new Error('Invalid JSON object'); + } + + // Format 1: Full Claude Code format + // { "mcpServers": { "server-name": { "command": "...", ... } } } + if (isRecord(parsed.mcpServers)) { + const servers: Array<{ name: string; config: McpServerConfig }> = []; + + for (const [name, config] of Object.entries(parsed.mcpServers)) { + if (isValidMcpServerConfig(config)) { + servers.push({ name, config }); + } + } + + if (servers.length === 0) { + throw new Error('No valid server configs found in mcpServers'); + } + + return { servers, needsName: false }; + } + + // Format 2: Single server config without name + // { "command": "...", "args": [...] } or { "type": "sse", "url": "..." } + if (isValidMcpServerConfig(parsed)) { + return { + servers: [{ name: '', config: parsed }], + needsName: true, + }; + } + + // Format 3: Single named server + // { "server-name": { "command": "...", ... } } + const entries = Object.entries(parsed); + if (entries.length === 1) { + const [name, config] = entries[0]; + if (isValidMcpServerConfig(config)) { + return { + servers: [{ name, config }], + needsName: false, + }; + } + } + + // Format 4: Multiple named servers (without mcpServers wrapper) + // { "server1": {...}, "server2": {...} } + const servers: Array<{ name: string; config: McpServerConfig }> = []; + for (const [name, config] of entries) { + if (isValidMcpServerConfig(config)) { + servers.push({ name, config }); + } + } + + if (servers.length > 0) { + return { servers, needsName: false }; + } + + throw new Error('Invalid MCP configuration format'); + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('Invalid JSON', { cause: error }); + } + throw error; + } +} + +/** + * Try to parse clipboard content as MCP config. + * Returns null if not valid MCP config. + */ +export function tryParseClipboardConfig(text: string): ParsedMcpConfig | null { + const trimmed = text.trim(); + if (!trimmed.startsWith('{')) { + return null; + } + + try { + return parseClipboardConfig(trimmed); + } catch { + return null; + } +} diff --git a/src/core/mcp/McpServerManager.ts b/src/core/mcp/McpServerManager.ts new file mode 100644 index 0000000..eb35d37 --- /dev/null +++ b/src/core/mcp/McpServerManager.ts @@ -0,0 +1,119 @@ +import { extractMcpMentions, transformMcpMentions } from '../../utils/mcp'; +import type { ManagedMcpServer, McpServerConfig } from '../types'; + +/** Storage interface for loading MCP servers. */ +export interface McpStorageAdapter { + load(): Promise; +} + +export class McpServerManager { + private servers: ManagedMcpServer[] = []; + private storage: McpStorageAdapter; + + constructor(storage: McpStorageAdapter) { + this.storage = storage; + } + + async loadServers(): Promise { + this.servers = await this.storage.load(); + } + + getServers(): ManagedMcpServer[] { + return this.servers; + } + + getEnabledCount(): number { + return this.servers.filter((s) => s.enabled).length; + } + + /** + * Get servers to include in SDK options. + * + * A server is included if: + * - It is enabled AND + * - Either context-saving is disabled OR the server is @-mentioned + * + * @param mentionedNames Set of server names that were @-mentioned in the prompt + */ + getActiveServers(mentionedNames: Set): Record { + const result: Record = {}; + + for (const server of this.servers) { + if (!server.enabled) continue; + + // If context-saving is enabled, only include if @-mentioned + if (server.contextSaving && !mentionedNames.has(server.name)) { + continue; + } + + result[server.name] = server.config; + } + + return result; + } + + /** + * Get disabled MCP tools formatted for SDK disallowedTools option. + * + * Only returns disabled tools from servers that would be active (same filter as getActiveServers). + * + * @param mentionedNames Set of server names that were @-mentioned in the prompt + */ + getDisallowedMcpTools(mentionedNames: Set): string[] { + return this.collectDisallowedTools( + (s) => !s.contextSaving || mentionedNames.has(s.name) + ); + } + + /** + * Get all disabled MCP tools from ALL enabled servers (ignoring @-mentions). + * + * Used for persistent queries to pre-register all disabled tools upfront, + * so @-mentioning servers doesn't require cold start. + */ + getAllDisallowedMcpTools(): string[] { + return this.collectDisallowedTools().sort(); + } + + private collectDisallowedTools(filter?: (server: ManagedMcpServer) => boolean): string[] { + const disallowed = new Set(); + + for (const server of this.servers) { + if (!server.enabled) continue; + if (filter && !filter(server)) continue; + if (!server.disabledTools || server.disabledTools.length === 0) continue; + + for (const tool of server.disabledTools) { + const normalized = tool.trim(); + if (!normalized) continue; + disallowed.add(`mcp__${server.name}__${normalized}`); + } + } + + return Array.from(disallowed); + } + + hasServers(): boolean { + return this.servers.length > 0; + } + + getContextSavingServers(): ManagedMcpServer[] { + return this.servers.filter((s) => s.enabled && s.contextSaving); + } + + private getContextSavingNames(): Set { + return new Set(this.getContextSavingServers().map((s) => s.name)); + } + + /** Only matches against enabled servers with context-saving mode. */ + extractMentions(text: string): Set { + return extractMcpMentions(text, this.getContextSavingNames()); + } + + /** + * Appends " MCP" after each valid @mention. Applied to API requests only, not shown in UI. + */ + transformMentions(text: string): string { + return transformMcpMentions(text, this.getContextSavingNames()); + } +} diff --git a/src/core/mcp/McpTester.ts b/src/core/mcp/McpTester.ts new file mode 100644 index 0000000..e1854ef --- /dev/null +++ b/src/core/mcp/McpTester.ts @@ -0,0 +1,310 @@ +import { Client } from '@modelcontextprotocol/sdk/client'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport'; +import * as http from 'http'; +import * as https from 'https'; + +import { getEnhancedPath } from '../../utils/env'; +import { parseCommand } from '../../utils/mcp'; +import type { ManagedMcpServer } from '../types'; +import { getMcpServerType } from '../types'; + +export interface McpTool { + name: string; + description?: string; + inputSchema?: Record; +} + +export interface McpTestResult { + success: boolean; + serverName?: string; + serverVersion?: string; + tools: McpTool[]; + error?: string; +} + +interface UrlServerConfig { + url: string; + headers?: Record; +} + +type StreamableHttpTransportOptions = ConstructorParameters[1]; +type LegacySseTransportConstructor = new ( + url: URL, + options?: StreamableHttpTransportOptions, +) => Transport; + +function createLegacySseTransport(url: URL, options: StreamableHttpTransportOptions): Transport { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Legacy SSE MCP servers still need the SDK's deprecated compatibility transport. + const module = require('@modelcontextprotocol/sdk/client/sse') as { + SSEClientTransport: LegacySseTransportConstructor; + }; + return new module.SSEClientTransport(url, options); +} + +/** + * Use Node's HTTP stack for MCP server verification to avoid renderer CORS restrictions. + * We still rely on official SDK transports for MCP protocol semantics. + */ +export function createNodeFetch(): (input: string | URL | Request, init?: RequestInit) => Promise { + return async (input: string | URL | Request, init?: RequestInit): Promise => { + const requestUrl = getRequestUrl(input); + const method = init?.method ?? (input instanceof Request ? input.method : 'GET'); + const headers = mergeHeaders(input, init); + const signal = init?.signal ?? (input instanceof Request ? input.signal : undefined); + const body = await getRequestBody(init?.body ?? (input instanceof Request ? input.body : undefined)); + const transport = requestUrl.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + let settled = false; + + const fail = (error: unknown) => { + if (settled) return; + settled = true; + signal?.removeEventListener('abort', onAbort); + reject(error instanceof Error ? error : new Error(String(error))); + }; + + const onAbort = () => { + req.destroy(new Error('Request aborted')); + fail(signal?.reason ?? new Error('Request aborted')); + }; + + const requestHeaders: Record = {}; + headers.forEach((value, key) => { + requestHeaders[key] = value; + }); + if (body) { + requestHeaders['content-length'] = String(body.byteLength); + } + + const req = transport.request( + requestUrl, + { + method, + headers: requestHeaders, + }, + (res: http.IncomingMessage) => { + if (settled) return; + settled = true; + signal?.removeEventListener('abort', onAbort); + resolve(createFetchResponse(res) as Response); + }, + ); + + req.on('error', (error: Error) => fail(error)); + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + + if (body) { + req.end(body); + } else { + req.end(); + } + }); + }; +} + +interface MinimalFetchResponse { + ok: boolean; + status: number; + statusText: string; + headers: Headers; + body: ReadableStream | null; + text: () => Promise; + json: () => Promise; +} + +function createFetchResponse(res: http.IncomingMessage): MinimalFetchResponse { + const responseHeaders = new Headers(); + for (const [key, value] of Object.entries(res.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const headerValue of value) { + responseHeaders.append(key, headerValue); + } + } else { + responseHeaders.append(key, value); + } + } + + const body = new ReadableStream({ + start(controller) { + res.on('data', (chunk: Buffer | string) => { + const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk; + controller.enqueue(new Uint8Array(buffer)); + }); + res.on('end', () => controller.close()); + res.on('error', (error: Error) => controller.error(error)); + }, + cancel(reason?: unknown) { + res.destroy(reason instanceof Error ? reason : new Error('Response body cancelled')); + }, + }); + + let bodyUsed = false; + const readAsText = async (): Promise => { + if (bodyUsed) { + throw new TypeError('Body has already been consumed'); + } + bodyUsed = true; + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + let done = false; + try { + while (!done) { + const { value, done: streamDone } = await reader.read(); + done = streamDone; + if (done) break; + if (value) { + chunks.push(value); + total += value.byteLength; + } + } + } finally { + reader.releaseLock(); + } + + const merged = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + merged.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(merged); + }; + + return { + ok: (res.statusCode ?? 500) >= 200 && (res.statusCode ?? 500) < 300, + status: res.statusCode ?? 500, + statusText: res.statusMessage ?? '', + headers: responseHeaders, + body, + text: readAsText, + json: async () => { + const parsed: unknown = JSON.parse(await readAsText()); + return parsed; + }, + }; +} + +function getRequestUrl(input: string | URL | Request): URL { + if (input instanceof URL) { + return input; + } + if (typeof input === 'string') { + return new URL(input); + } + return new URL(input.url); +} + +function mergeHeaders(input: string | URL | Request, init?: RequestInit): Headers { + const headers = new Headers(input instanceof Request ? input.headers : undefined); + if (init?.headers) { + const initHeaders = new Headers(init.headers); + initHeaders.forEach((value, key) => { + headers.set(key, value); + }); + } + return headers; +} + +async function getRequestBody(body: BodyInit | null | undefined): Promise { + if (body === undefined || body === null) { + return undefined; + } + + const serialized = await new Response(body).arrayBuffer(); + return Buffer.from(serialized); +} + +const nodeFetch = createNodeFetch(); + +export async function testMcpServer(server: ManagedMcpServer): Promise { + const type = getMcpServerType(server.config); + + let transport: Transport; + try { + if (type === 'stdio') { + const config = server.config as { command: string; args?: string[]; env?: Record }; + const { cmd, args } = parseCommand(config.command, config.args); + if (!cmd) { + return { success: false, tools: [], error: 'Missing command' }; + } + transport = new StdioClientTransport({ + command: cmd, + args, + env: { ...process.env, ...config.env, PATH: getEnhancedPath(config.env?.PATH) }, + stderr: 'ignore', + }); + } else { + const config = server.config as UrlServerConfig; + const url = new URL(config.url); + const options = { + fetch: nodeFetch, + requestInit: config.headers ? { headers: config.headers } : undefined, + }; + transport = type === 'sse' + ? createLegacySseTransport(url, options) + : new StreamableHTTPClientTransport(url, options); + } + } catch (error) { + return { + success: false, + tools: [], + error: error instanceof Error ? error.message : 'Invalid server configuration', + }; + } + + const client = new Client({ name: 'claudian-tester', version: '1.0.0' }); + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 10000); + + try { + await client.connect(transport, { signal: controller.signal }); + + const serverVersion = client.getServerVersion(); + let tools: McpTool[] = []; + try { + const result = await client.listTools(undefined, { signal: controller.signal }); + tools = result.tools.map((t: { name: string; description?: string; inputSchema?: Record }) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema as Record, + })); + } catch { + // listTools failure after successful connect = partial success + } + + return { + success: true, + serverName: serverVersion?.name, + serverVersion: serverVersion?.version, + tools, + }; + } catch (error) { + if (controller.signal.aborted) { + return { success: false, tools: [], error: 'Connection timeout (10s)' }; + } + return { + success: false, + tools: [], + error: error instanceof Error ? error.message : 'Unknown error', + }; + } finally { + window.clearTimeout(timeout); + try { + await client.close(); + } catch { + // Ignore close errors + } + } +} diff --git a/src/core/prompt/inlineEdit.ts b/src/core/prompt/inlineEdit.ts new file mode 100644 index 0000000..eb1d1a3 --- /dev/null +++ b/src/core/prompt/inlineEdit.ts @@ -0,0 +1,252 @@ +import { appendContextFiles } from '../../utils/context'; +import { getTodayDate } from '../../utils/date'; +import type { + InlineEditCursorRequest, + InlineEditRequest, + InlineEditResult, +} from '../providers/types'; + +export function parseInlineEditResponse(responseText: string): InlineEditResult { + const replacementMatch = responseText.match(/([\s\S]*?)<\/replacement>/); + if (replacementMatch) { + return { success: true, editedText: replacementMatch[1] }; + } + + const insertionMatch = responseText.match(/([\s\S]*?)<\/insertion>/); + if (insertionMatch) { + return { success: true, insertedText: insertionMatch[1] }; + } + + const trimmed = responseText.trim(); + if (trimmed) { + return { success: true, clarification: trimmed }; + } + + return { success: false, error: 'Empty response' }; +} + +function buildCursorPrompt(request: InlineEditCursorRequest): string { + const ctx = request.cursorContext; + const lineAttr = ` line="${ctx.line + 1}"`; + + let cursorContent: string; + if (ctx.isInbetween) { + const parts = []; + if (ctx.beforeCursor) parts.push(ctx.beforeCursor); + parts.push('| #inbetween'); + if (ctx.afterCursor) parts.push(ctx.afterCursor); + cursorContent = parts.join('\n'); + } else { + cursorContent = `${ctx.beforeCursor}|${ctx.afterCursor} #inline`; + } + + return [ + request.instruction, + '', + ``, + cursorContent, + '', + ].join('\n'); +} + +export function buildInlineEditPrompt(request: InlineEditRequest): string { + let prompt: string; + + if (request.mode === 'cursor') { + prompt = buildCursorPrompt(request); + } else { + const lineAttr = request.startLine && request.lineCount + ? ` lines="${request.startLine}-${request.startLine + request.lineCount - 1}"` + : ''; + prompt = [ + request.instruction, + '', + ``, + request.selectedText, + '', + ].join('\n'); + } + + if (request.contextFiles && request.contextFiles.length > 0) { + prompt = appendContextFiles(prompt, request.contextFiles); + } + + return prompt; +} + +export function getInlineEditSystemPrompt(): string { + const pathRules = '- **Paths**: Must be RELATIVE to vault root (e.g., "notes/file.md").'; + + return `Today is ${getTodayDate()}. + +You are **Claudian**, an expert editor and writing assistant embedded in Obsidian. You help users refine their text, answer questions, and generate content with high precision. + +## Core Directives + +1. **Style Matching**: Mimic the user's tone, voice, and formatting style (indentation, bullet points, capitalization). +2. **Context Awareness**: Always Read the full file (or significant context) to understand the broader topic before editing. Do not rely solely on the selection. +3. **Silent Execution**: Use tools (Read, WebSearch) silently. Your final output must be ONLY the result. +4. **No Fluff**: No pleasantries, no "Here is the text", no "I have updated...". Just the content. + +## Input Format + +User messages have the instruction first, followed by XML context tags: + +### Selection Mode +\`\`\` +user's instruction + + +selected text here + +\`\`\` +Use \`\` tags for edits. + +### Cursor Mode +\`\`\` +user's instruction + + +text before|text after #inline + +\`\`\` +Or between paragraphs: +\`\`\` +user's instruction + + +Previous paragraph +| #inbetween +Next paragraph + +\`\`\` +Use \`\` tags to insert new content at the cursor position (\`|\`). + +## Tools & Path Rules + +- **Tools**: Read, Grep, Glob, LS, WebSearch, WebFetch. (All read-only). +${pathRules} + +## Thinking Process + +Before generating the final output, mentally check: +1. **Context**: Have I read enough of the file to understand the *topic* and *structure*? +2. **Style**: What is the user's indentation (2 vs 4 spaces, tabs)? What is their tone? +3. **Type**: Is this **Prose** (flow, grammar, clarity) or **Code** (syntax, logic, variable names)? + - *Prose*: Ensure smooth transitions. + - *Code*: Preserve syntax validity; do not break surrounding brackets/indentation. + +## Output Rules - CRITICAL + +**ABSOLUTE RULE**: Your text output must contain ONLY the final answer, replacement, or insertion. NEVER output: +- "I'll read the file..." / "Let me check..." / "I will..." +- "I'm asked about..." / "The user wants..." +- "Based on my analysis..." / "After reading..." +- "Here's..." / "The answer is..." +- ANY announcement of what you're about to do or did + +Use tools silently. Your text output = final result only. + +### When Replacing Selected Text (Selection Mode) + +If the user wants to MODIFY or REPLACE the selected text, wrap the replacement in tags: + +your replacement text here + +The content inside the tags should be ONLY the replacement text - no explanation. + +### When Inserting at Cursor (Cursor Mode) + +If the user wants to INSERT new content at the cursor position, wrap the insertion in tags: + +your inserted text here + +The content inside the tags should be ONLY the text to insert - no explanation. + +### When Answering Questions or Providing Information + +If the user is asking a QUESTION, respond WITHOUT tags. Output the answer directly. + +WRONG: "I'll read the full context of this file to give you a better explanation. This is a guide about..." +CORRECT: "This is a guide about..." + +### When Clarification is Needed + +If the request is ambiguous, ask a clarifying question. Keep questions concise and specific. + +## Examples + +### Selection Mode +Input: +\`\`\` +translate to French + + +Hello world + +\`\`\` + +CORRECT (replacement): +Bonjour le monde + +Input: +\`\`\` +what does this do? + + +const x = arr.reduce((a, b) => a + b, 0); + +\`\`\` + +CORRECT (question - no tags): +This code sums all numbers in the array \`arr\`. It uses \`reduce\` to iterate through the array, accumulating the total starting from 0. + +### Cursor Mode + +Input: +\`\`\` +what animal? + + +The quick brown | jumps over the lazy dog. #inline + +\`\`\` + +CORRECT (insertion): +fox + +### Q&A +Input: +\`\`\` +add a brief description section + + +# Introduction +This is my project. +| #inbetween +## Features + +\`\`\` + +CORRECT (insertion): + +## Description + +This project provides tools for managing your notes efficiently. + + +Input: +\`\`\` +translate to Spanish + + +The bank was steep. + +\`\`\` + +CORRECT (asking for clarification): +"Bank" can mean a financial institution (banco) or a river bank (orilla). Which meaning should I use? + +Then after user clarifies "river bank": +La orilla era empinada.`; +} diff --git a/src/core/prompt/instructionRefine.ts b/src/core/prompt/instructionRefine.ts new file mode 100644 index 0000000..1b61c91 --- /dev/null +++ b/src/core/prompt/instructionRefine.ts @@ -0,0 +1,72 @@ +export function buildRefineSystemPrompt(existingInstructions: string): string { + const existingSection = existingInstructions.trim() + ? `\n\nEXISTING INSTRUCTIONS (already in the user's system prompt): +\`\`\` +${existingInstructions.trim()} +\`\`\` + +When refining the new instruction: +- Consider how it fits with existing instructions +- Avoid duplicating existing instructions +- If the new instruction conflicts with an existing one, refine it to be complementary or note the conflict +- Match the format of existing instructions (section, heading, bullet points, style, etc.)` + : ''; + + return `You are an expert Prompt Engineer. You help users craft precise, effective system instructions for their AI assistant. + +**Your Goal**: Transform vague or simple user requests into **high-quality, actionable, and non-conflicting** system prompt instructions. + +**Process**: +1. **Analyze Intent**: What behavior does the user want to enforce or change? +2. **Check Context**: Does this conflict with existing instructions? + - *No Conflict*: Add as new. + - *Conflict*: Propose a **merged instruction** that resolves the contradiction (or ask if unsure). +3. **Refine**: Draft a clear, positive instruction (e.g., "Do X" instead of "Don't do Y"). +4. **Format**: Return *only* the Markdown snippet wrapped in \`\` tags. + +**Guidelines**: +- **Clarity**: Use precise language. Avoid ambiguity. +- **Scope**: Keep it focused. Don't add unrelated rules. +- **Format**: Valid Markdown (bullets \`-\` or sections \`##\`). +- **No Header**: Do NOT include a top-level header like \`# Custom Instructions\`. +- **Conflict Handling**: If the new rule directly contradicts an existing one, rewrite the *new* one to override specific cases or ask for clarification. + +**Output Format**: +- **Success**: \`...markdown content...\` +- **Ambiguity**: Plain text question. + +${existingSection} + +**Examples**: + +Input: "typescript for code" +Output: - **Code Language**: Always use TypeScript for code examples. Include proper type annotations and interfaces. + +Input: "be concise" +Output: - **Conciseness**: Provide brief, direct responses. Omit conversational filler and unnecessary explanations. + +Input: "organize coding style rules" +Output: ## Coding Standards\n\n- **Language**: Use TypeScript.\n- **Style**: Prefer functional patterns.\n- **Review**: Keep diffs small. + +Input: "use that thing from before" +Output: I'm not sure what you're referring to. Could you please clarify?`; +} + +export function parseInstructionRefineResponse(responseText: string): { + success: boolean; + clarification?: string; + refinedInstruction?: string; + error?: string; +} { + const instructionMatch = responseText.match(/([\s\S]*?)<\/instruction>/); + if (instructionMatch) { + return { success: true, refinedInstruction: instructionMatch[1].trim() }; + } + + const trimmed = responseText.trim(); + if (trimmed) { + return { success: true, clarification: trimmed }; + } + + return { success: false, error: 'Empty response' }; +} diff --git a/src/core/prompt/mainAgent.ts b/src/core/prompt/mainAgent.ts new file mode 100644 index 0000000..85115b6 --- /dev/null +++ b/src/core/prompt/mainAgent.ts @@ -0,0 +1,213 @@ +export interface SystemPromptSettings { + mediaFolder?: string; + customPrompt?: string; + vaultPath?: string; + userName?: string; +} + +export interface SystemPromptBuildOptions { + appendices?: string[]; +} + +function getPathRules(vaultPath?: string): string { + return `## Path Conventions + +| Location | Access | Path Format | Example | +|----------|--------|-------------|---------| +| **Vault** | Read/Write | Relative from vault root | \`notes/my-note.md\`, \`.\` | +| **External contexts** | Full access | Absolute path | \`/Users/me/Workspace/file.ts\` | + +**Vault files** (default working directory): +- ✓ Correct: \`notes/my-note.md\`, \`my-note.md\`, \`folder/subfolder/file.md\`, \`.\` +- ✗ WRONG: \`/notes/my-note.md\`, \`${vaultPath || '/absolute/path'}/file.md\` +- A leading slash or absolute path will FAIL for vault operations. + +**External context paths**: When external directories are selected, use absolute paths to access files there. These directories are explicitly granted for the current session.`; +} + +function getBaseSystemPrompt( + vaultPath?: string, + userName?: string, +): string { + const vaultInfo = vaultPath ? `\n\nVault absolute path: ${vaultPath}` : ''; + const trimmedUserName = userName?.trim(); + const userContext = trimmedUserName + ? `## User Context\n\nYou are collaborating with **${trimmedUserName}**.\n\n` + : ''; + const pathRules = getPathRules(vaultPath); + + return `${userContext}## Time Context + +- **Current Date**: Use \`bash: date\` to get the current date and time. Never guess or assume. +- **Knowledge Status**: You possess extensive internal knowledge up to your training cutoff. You do not know the exact date of your cutoff, but you must assume that your internal weights are static and "past," while the Current Date is "present." + +## Identity & Role + +You are **Claudian**, an expert AI assistant specialized in Obsidian vault management, knowledge organization, and code analysis. You operate directly inside the user's Obsidian vault. + +**Core Principles:** +1. **Obsidian Native**: You understand Markdown, YAML frontmatter, Wiki-links, and the "second brain" philosophy. +2. **Safety First**: You never overwrite data without understanding context. You always use relative paths. +3. **Proactive Thinking**: You do not just execute; you *plan* and *verify*. You anticipate potential issues (like broken links or missing files). +4. **Clarity**: Your changes are precise, minimizing "noise" in the user's notes or code. + +The current working directory is the user's vault root.${vaultInfo} + +${pathRules} + +## User Message Format + +User messages have the query first, followed by optional XML context tags: + +\`\`\` +User's question or request here + + +path/to/note.md + + + +selected text content + + + +selected content from an Obsidian browser view + +\`\`\` + +- The user's query/instruction always comes first in the message. +- \`\`: The note this session is linked to. Read this to understand session context. Legacy messages may use \`\` for the same context. +- \`\`: Text currently selected in the editor, with file path and line numbers. +- \`\`: Text selected in an Obsidian browser/web view (for example Surfing), including optional source/title/url metadata. +- \`@filename.md\`: Files mentioned with @ in the query. Read these files when referenced. + +## Obsidian Context + +- **Structure**: Files are Markdown (.md). Folders organize content. +- **Frontmatter**: YAML at the top of files (metadata). Respect existing fields. +- **Links**: Internal Wiki-links \`[[note-name]]\` or \`[[folder/note-name]]\`. External links \`[text](url)\`. + - When reading a note with wikilinks, consider reading linked notes; they often contain related context that helps understand the current note. +- **Tags**: #tag-name for categorization. +- **Dataview**: You may encounter Dataview queries (in \`\`\`dataview\`\`\` blocks). Do not break them unless asked. +- **Vault Config**: \`.obsidian/\` contains internal config. Touch only if you know what you are doing. + +**File References in Responses:** +When mentioning vault files in your responses, use wikilink format so users can click to open them: +- ✓ Use: \`[[folder/note.md]]\` or \`[[note]]\` +- ✗ Avoid: plain paths like \`folder/note.md\` (not clickable) + +**Image embeds:** Use \`![[image.png]]\` to display images directly in chat. Images render visually, making it easy to show diagrams, screenshots, or visual content you're discussing. + +Examples: +- "I found your notes in [[30.areas/finance/Investment lessons/2024.Current trading lessons.md]]" +- "See [[daily notes/2024-01-15]] for more details" +- "Here's the diagram: ![[attachments/architecture.png]]" + +## Selection Context + +User messages may include an \`\` tag showing text the user selected: + +\`\`\`xml + +selected text here +possibly multiple lines + +\`\`\` + +User messages may also include a \`\` tag when selection comes from an Obsidian browser view: + +\`\`\`xml + +selected webpage content + +\`\`\` + +**When present:** The user selected this text before sending their message. Use this context to understand what they're referring to.`; +} + +function getImageInstructions(mediaFolder: string): string { + const folder = mediaFolder.trim(); + const mediaPath = folder ? `./${folder}` : '.'; + const examplePath = folder ? `${folder}/` : ''; + + return ` + +## Embedded Images in Notes + +**Proactive image reading**: When reading a note with embedded images, read them alongside text for full context. Images often contain critical information (diagrams, screenshots, charts). + +**Local images** (\`![[image.jpg]]\`): +- Located in media folder: \`${mediaPath}\` +- Read with: \`Read file_path="${examplePath}image.jpg"\` +- Formats: PNG, JPG/JPEG, GIF, WebP + +**External images** (\`![alt](url)\`): +- WebFetch does NOT support images +- Download to media folder -> Read -> Replace URL with wiki-link: + +\`\`\`bash +# Download to media folder with descriptive name +mkdir -p ${mediaPath} +img_name="downloaded_\\$(date +%s).png" +curl -sfo "${examplePath}$img_name" 'URL' +\`\`\` + +Then read with \`Read file_path="${examplePath}$img_name"\`, and replace the markdown link \`![alt](url)\` with \`![[${examplePath}$img_name]]\` in the note. + +**Benefits**: Image becomes a permanent vault asset, works offline, and uses Obsidian's native embed syntax.`; +} + +function getAppendixSections(appendices?: string[]): string { + if (!appendices || appendices.length === 0) { + return ''; + } + + const sections = appendices + .map((appendix) => appendix.trim()) + .filter(Boolean); + + if (sections.length === 0) { + return ''; + } + + return `\n\n${sections.join('\n\n')}`; +} + +export function buildSystemPrompt( + settings: SystemPromptSettings = {}, + options: SystemPromptBuildOptions = {}, +): string { + let prompt = getBaseSystemPrompt(settings.vaultPath, settings.userName); + + prompt += getImageInstructions(settings.mediaFolder || ''); + prompt += getAppendixSections(options.appendices); + + if (settings.customPrompt?.trim()) { + prompt += `\n\n## Custom Instructions\n\n${settings.customPrompt.trim()}`; + } + + return prompt; +} + +export function computeSystemPromptKey( + settings: SystemPromptSettings, + options: SystemPromptBuildOptions = {}, +): string { + const appendixKey = (options.appendices || []) + .map((appendix) => appendix.trim()) + .filter(Boolean) + .join('||'); + + const parts = [ + settings.mediaFolder || '', + settings.customPrompt || '', + settings.vaultPath || '', + (settings.userName || '').trim(), + ]; + + if (appendixKey) { + parts.push(appendixKey); + } + + return parts.join('::'); +} diff --git a/src/core/prompt/titleGeneration.ts b/src/core/prompt/titleGeneration.ts new file mode 100644 index 0000000..2e521f0 --- /dev/null +++ b/src/core/prompt/titleGeneration.ts @@ -0,0 +1,44 @@ +const MAX_TITLE_INPUT_LENGTH = 500; +const MAX_TITLE_LENGTH = 50; + +export const TITLE_GENERATION_SYSTEM_PROMPT = `You are a specialist in summarizing user intent. + +**Task**: Generate a **concise, descriptive title** (max 50 chars) summarizing the user's task/request. + +**Rules**: +1. **Format**: Sentence case. No periods/quotes. +2. **Structure**: Start with a **strong verb** (e.g., Create, Fix, Debug, Explain, Analyze). +3. **Forbidden**: "Conversation with...", "Help me...", "Question about...", "I need...". +4. **Tech Context**: Detect and include the primary language/framework if code is present (e.g., "Debug Python script", "Refactor React hook"). + +**Output**: Return ONLY the raw title text.`; + +export function buildTitleGenerationPrompt(userMessage: string): string { + const truncated = userMessage.length > MAX_TITLE_INPUT_LENGTH + ? `${userMessage.slice(0, MAX_TITLE_INPUT_LENGTH)}...` + : userMessage; + return `User's request:\n"""\n${truncated}\n"""\n\nGenerate a title for this conversation:`; +} + +export function parseTitleGenerationResponse(responseText: string): string | null { + const trimmed = responseText.trim(); + if (!trimmed) { + return null; + } + + let title = trimmed; + if ( + (title.startsWith('"') && title.endsWith('"')) + || (title.startsWith("'") && title.endsWith("'")) + ) { + title = title.slice(1, -1); + } + + title = title.replace(/[.!?:;,]+$/, ''); + + if (title.length > MAX_TITLE_LENGTH) { + title = `${title.slice(0, MAX_TITLE_LENGTH - 3)}...`; + } + + return title || null; +} diff --git a/src/core/providers/ProviderHost.ts b/src/core/providers/ProviderHost.ts new file mode 100644 index 0000000..f7b8fa4 --- /dev/null +++ b/src/core/providers/ProviderHost.ts @@ -0,0 +1,52 @@ +import type { App } from 'obsidian'; + +import type { SharedAppStorage } from '../bootstrap/storage'; +import type { ChatRuntime } from '../runtime/ChatRuntime'; +import type { ClaudianSettings } from '../types'; +import type { EnvironmentScope } from '../types/settings'; +import type { ProviderCliResolutionContext, ProviderId } from './types'; + +/** + * Application capabilities available to provider adapters. + * + * The host deliberately excludes plugin lifecycle, command registration, and + * conversation ownership. Providers receive only the settings, environment, + * path, CLI, storage, and interaction capabilities they currently consume. + */ +export interface ProviderHost { + readonly app: App; + readonly settings: ClaudianSettings; + readonly storage: SharedAppStorage; + readonly manifest?: { version?: string }; + + saveSettings(): Promise; + mutateSettings( + mutation: (settings: ClaudianSettings) => void | Promise, + ): Promise; + mutateSettingsConditionally( + mutation: (settings: ClaudianSettings) => boolean | Promise, + ): Promise; + loadData(): Promise; + saveData(data: unknown): Promise; + normalizeModelVariantSettings(): boolean; + + getActiveEnvironmentVariables(providerId: ProviderId): string; + getEnvironmentVariablesForScope(scope: EnvironmentScope): string; + applyEnvironmentVariables(scope: EnvironmentScope, envText: string): Promise; + applyEnvironmentVariablesBatch( + updates: Array<{ scope: EnvironmentScope; envText: string }>, + ): Promise; + getResolvedProviderCliPath( + providerId: ProviderId, + context?: ProviderCliResolutionContext, + ): string | null; + + refreshModelSelectors?(): void; + broadcastToActiveViewRuntimes?( + action: (runtime: ChatRuntime) => Promise | void, + ): Promise; + broadcastToAllViewRuntimes?( + action: (runtime: ChatRuntime) => Promise | void, + ): Promise; + recycleProviderRuntimes?(providerId: ProviderId): Promise; +} diff --git a/src/core/providers/ProviderRegistry.ts b/src/core/providers/ProviderRegistry.ts new file mode 100644 index 0000000..3924018 --- /dev/null +++ b/src/core/providers/ProviderRegistry.ts @@ -0,0 +1,262 @@ +import type { ChatRuntime } from '../runtime/ChatRuntime'; +import { decodeProviderModelSelectionId } from './modelSelection'; +import type { ProviderHost } from './ProviderHost'; +import { + type CreateChatRuntimeOptions, + DEFAULT_CHAT_PROVIDER_ID, + type InlineEditService, + type InstructionRefineService, + type ProviderCapabilities, + type ProviderChatUIConfig, + type ProviderConversationHistoryService, + type ProviderId, + type ProviderRegistration, + type ProviderSettingsReconciler, + type ProviderSettingsStorageAdapter, + type ProviderSubagentLifecycleAdapter, + type ProviderTaskResultInterpreter, + type TitleGenerationCallback, + type TitleGenerationService, +} from './types'; + +/** + * Registry for chat-facing provider services. + * + * Bootstrap concerns (default settings, shared storage, CLI resolution, + * workspace command/agent services) are composed explicitly in `main.ts` + * through `src/core/bootstrap/` and `src/providers//app/`. + */ +export class ProviderRegistry { + private static registrations: Partial> = {}; + + static register( + providerId: ProviderId, + registration: ProviderRegistration, + ): void { + this.registrations[providerId] = registration; + } + + private static getProviderRegistration(providerId: ProviderId): ProviderRegistration { + const registration = this.registrations[providerId]; + if (!registration) { + throw new Error(`Provider "${providerId}" is not registered.`); + } + return registration; + } + + static createChatRuntime(options: CreateChatRuntimeOptions): ChatRuntime { + const providerId = options.providerId ?? DEFAULT_CHAT_PROVIDER_ID; + return this.getProviderRegistration(providerId).createRuntime(options); + } + + static createTitleGenerationService(plugin: ProviderHost, providerId?: ProviderId): TitleGenerationService { + if (!providerId) { + return new RoutedTitleGenerationService(plugin); + } + return this.getProviderRegistration(providerId).createTitleGenerationService(plugin); + } + + static resolveTitleGenerationProviderId(settings: Record): ProviderId { + const titleModel = typeof settings.titleGenerationModel === 'string' + ? settings.titleGenerationModel.trim() + : ''; + + if (!titleModel) { + return DEFAULT_CHAT_PROVIDER_ID; + } + + return this.resolveProviderForModel(titleModel, settings, { + fallbackProviderId: DEFAULT_CHAT_PROVIDER_ID, + }); + } + + static createInstructionRefineService(plugin: ProviderHost, providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID): InstructionRefineService { + return this.getProviderRegistration(providerId).createInstructionRefineService(plugin); + } + + static createInlineEditService(plugin: ProviderHost, providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID): InlineEditService { + return this.getProviderRegistration(providerId).createInlineEditService(plugin); + } + + static getConversationHistoryService( + providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID, + ): ProviderConversationHistoryService { + return this.getProviderRegistration(providerId).historyService; + } + + static getTaskResultInterpreter( + providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID, + ): ProviderTaskResultInterpreter { + return this.getProviderRegistration(providerId).taskResultInterpreter; + } + + static getSubagentLifecycleAdapter( + providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID, + ): ProviderSubagentLifecycleAdapter | null { + return this.getProviderRegistration(providerId).subagentLifecycleAdapter ?? null; + } + + static getCapabilities(providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID): ProviderCapabilities { + return this.getProviderRegistration(providerId).capabilities; + } + + static getEnvironmentKeyPatterns(providerId: ProviderId): RegExp[] { + return this.getProviderRegistration(providerId).environmentKeyPatterns ?? []; + } + + static getChatUIConfig(providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID): ProviderChatUIConfig { + return this.getProviderRegistration(providerId).chatUIConfig; + } + + static getSettingsReconciler(providerId: ProviderId = DEFAULT_CHAT_PROVIDER_ID): ProviderSettingsReconciler { + return this.getProviderRegistration(providerId).settingsReconciler; + } + + static getSettingsStorageAdapter(providerId: ProviderId): ProviderSettingsStorageAdapter { + const registration = this.getProviderRegistration(providerId); + if (!('settingsStorage' in registration)) { + throw new Error(`Provider "${providerId}" does not own settings storage normalization.`); + } + return registration.settingsStorage as ProviderSettingsStorageAdapter; + } + + static getRegisteredProviderIds(): ProviderId[] { + return Object.keys(this.registrations); + } + + static getEnabledProviderIds(settings: Record): ProviderId[] { + return this.getRegisteredProviderIds() + .filter(providerId => this.getProviderRegistration(providerId).isEnabled(settings)) + .sort((a, b) => ( + this.getProviderRegistration(a).blankTabOrder - this.getProviderRegistration(b).blankTabOrder + )); + } + + static getProviderDisplayName(providerId: ProviderId): string { + return this.getProviderRegistration(providerId).displayName; + } + + static isEnabled(providerId: ProviderId, settings: Record): boolean { + return this.getProviderRegistration(providerId).isEnabled(settings); + } + + static resolveSettingsProviderId(settings: Record): ProviderId { + const current = settings.settingsProvider; + if (typeof current === 'string') { + const currentProvider = current; + if ( + this.getRegisteredProviderIds().includes(currentProvider) + && this.isEnabled(currentProvider, settings) + ) { + return currentProvider; + } + } + + if (this.isEnabled(DEFAULT_CHAT_PROVIDER_ID, settings)) { + return DEFAULT_CHAT_PROVIDER_ID; + } + + return this.getEnabledProviderIds(settings)[0] ?? DEFAULT_CHAT_PROVIDER_ID; + } + + static resolveProviderForModel( + model: string, + settings: Record = {}, + options: { + onlyEnabledProviders?: boolean; + fallbackProviderId?: ProviderId; + } = {}, + ): ProviderId { + const providerIds = options.onlyEnabledProviders + ? this.getEnabledProviderIds(settings) + : this.getRegisteredProviderIds(); + const fallbackProviderId = ( + options.fallbackProviderId + && (!options.onlyEnabledProviders || this.isEnabled(options.fallbackProviderId, settings)) + ) + ? options.fallbackProviderId + : (options.onlyEnabledProviders + ? this.resolveSettingsProviderId(settings) + : DEFAULT_CHAT_PROVIDER_ID); + const decodedSelection = decodeProviderModelSelectionId(model); + + if ( + decodedSelection + && providerIds.includes(decodedSelection.providerId) + && (!options.onlyEnabledProviders || this.isEnabled(decodedSelection.providerId, settings)) + ) { + return decodedSelection.providerId; + } + + for (const providerId of providerIds) { + if (providerId === fallbackProviderId) { + continue; + } + + if (this.getChatUIConfig(providerId).ownsModel(model, settings)) { + return providerId; + } + } + + return fallbackProviderId; + } + + static getCustomModelIds(envVars: Record): Set { + const ids = new Set(); + for (const providerId of this.getRegisteredProviderIds()) { + for (const modelId of this.getChatUIConfig(providerId).getCustomModelIds(envVars)) { + ids.add(modelId); + } + } + return ids; + } +} + +interface ActiveTitleGeneration { + service: TitleGenerationService; +} + +class RoutedTitleGenerationService implements TitleGenerationService { + private readonly activeGenerations = new Map(); + + constructor(private readonly plugin: ProviderHost) {} + + async generateTitle( + conversationId: string, + userMessage: string, + callback: TitleGenerationCallback, + ): Promise { + const providerId = ProviderRegistry.resolveTitleGenerationProviderId( + this.plugin.settings, + ); + const service = ProviderRegistry.createTitleGenerationService(this.plugin, providerId); + const generation = { service }; + const previous = this.activeGenerations.get(conversationId); + + this.activeGenerations.set(conversationId, generation); + previous?.service.cancel(); + + try { + await service.generateTitle(conversationId, userMessage, async (convId, result) => { + if (this.activeGenerations.get(conversationId) !== generation) { + return; + } + await callback(convId, result); + }); + } finally { + if (this.activeGenerations.get(conversationId) === generation) { + this.activeGenerations.delete(conversationId); + } + } + } + + cancel(): void { + const services = new Set( + [...this.activeGenerations.values()].map(generation => generation.service), + ); + this.activeGenerations.clear(); + for (const service of services) { + service.cancel(); + } + } +} diff --git a/src/core/providers/ProviderSettingsCoordinator.ts b/src/core/providers/ProviderSettingsCoordinator.ts new file mode 100644 index 0000000..73bbd91 --- /dev/null +++ b/src/core/providers/ProviderSettingsCoordinator.ts @@ -0,0 +1,502 @@ +import type { Conversation } from '../types'; +import { toProviderRuntimeModelId } from './modelSelection'; +import { ProviderRegistry } from './ProviderRegistry'; +import type { ProviderChatUIConfig, ProviderId } from './types'; + +export interface SettingsReconciliationResult { + changed: boolean; + invalidatedConversations: Conversation[]; +} + +const PROJECTION_KEYS = new Set([ + 'model', + 'effortLevel', + 'serviceTier', + 'thinkingBudget', + 'permissionMode', +]); + +type ProviderProjectionMap = Partial>; + +function getSettingsProviderId(settings: Record): ProviderId { + return ProviderRegistry.resolveSettingsProviderId(settings); +} + +function ensureProjectionMap( + settings: Record, + key: + | 'savedProviderModel' + | 'savedProviderEffort' + | 'savedProviderServiceTier' + | 'savedProviderThinkingBudget' + | 'savedProviderPermissionMode', +): ProviderProjectionMap { + const current = settings[key]; + if (current && typeof current === 'object') { + return current; + } + + const next: ProviderProjectionMap = {}; + settings[key] = next; + return next; +} + +function cloneProviderSettings(settings: Record): Record { + return { + ...settings, + savedProviderModel: { ...(settings.savedProviderModel as ProviderProjectionMap | undefined) }, + savedProviderEffort: { ...(settings.savedProviderEffort as ProviderProjectionMap | undefined) }, + savedProviderServiceTier: { ...(settings.savedProviderServiceTier as ProviderProjectionMap | undefined) }, + savedProviderThinkingBudget: { ...(settings.savedProviderThinkingBudget as ProviderProjectionMap | undefined) }, + savedProviderPermissionMode: { ...(settings.savedProviderPermissionMode as ProviderProjectionMap | undefined) }, + }; +} + +function normalizeToggleValue( + value: unknown, + allowedValues: Set, +): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + return allowedValues.has(value) ? value : undefined; +} + +function mergeProviderSettings( + target: Record, + source: Record, +): void { + for (const [key, value] of Object.entries(source)) { + if (PROJECTION_KEYS.has(key)) { + continue; + } + target[key] = value; + } +} + +function normalizeReasoningValue( + uiConfig: ProviderChatUIConfig, + settings: Record, + model: string, + value: unknown, +): string { + const allowedValues = new Set(uiConfig.getReasoningOptions(model, settings).map(option => option.value)); + if (typeof value === 'string' && allowedValues.has(value)) { + return value; + } + return uiConfig.getDefaultReasoningValue(model, settings); +} + +function normalizeProviderModel( + uiConfig: ProviderChatUIConfig, + settings: Record, + model: string | undefined, +): string | undefined { + if (!model) { + return undefined; + } + return uiConfig.normalizeModelVariant(model, settings); +} + +function normalizeModelDependentSettings( + uiConfig: ProviderChatUIConfig, + settings: Record, + model: string, +): void { + if (uiConfig.isAdaptiveReasoningModel(model, settings)) { + settings.effortLevel = normalizeReasoningValue( + uiConfig, + settings, + model, + settings.effortLevel, + ); + } else { + settings.thinkingBudget = normalizeReasoningValue( + uiConfig, + settings, + model, + settings.thinkingBudget, + ); + } + + const serviceTierToggle = uiConfig.getServiceTierToggle?.(settings) ?? null; + if (!serviceTierToggle) { + settings.serviceTier = 'default'; + return; + } + + const currentServiceTier = typeof settings.serviceTier === 'string' + ? settings.serviceTier + : undefined; + if (currentServiceTier === 'fast') { + settings.serviceTier = serviceTierToggle.activeValue; + return; + } + if ( + currentServiceTier !== serviceTierToggle.inactiveValue + && currentServiceTier !== serviceTierToggle.activeValue + ) { + settings.serviceTier = serviceTierToggle.inactiveValue; + } +} + +export class ProviderSettingsCoordinator { + static applyModelSelection( + settings: Record, + providerId: ProviderId, + model: string, + ): void { + const uiConfig = ProviderRegistry.getChatUIConfig(providerId); + settings.model = model; + uiConfig.applyModelDefaults(model, settings); + normalizeModelDependentSettings(uiConfig, settings, model); + } + + static projectModelSelection( + settings: Record, + providerId: ProviderId, + model: string, + ): void { + const uiConfig = ProviderRegistry.getChatUIConfig(providerId); + settings.model = model; + normalizeModelDependentSettings(uiConfig, settings, model); + } + + static handleEnvironmentChange( + settings: Record, + providerIds: ProviderId[], + ): boolean { + let anyChanged = false; + for (const providerId of providerIds) { + const reconciler = ProviderRegistry.getSettingsReconciler(providerId); + if (reconciler.handleEnvironmentChange?.(settings)) { + anyChanged = true; + } + } + return anyChanged; + } + + static reconcileTitleGenerationModelSelection(settings: Record): boolean { + const currentModel = typeof settings.titleGenerationModel === 'string' + ? settings.titleGenerationModel + : ''; + if (!currentModel) { + return false; + } + + for (const providerId of ProviderRegistry.getRegisteredProviderIds()) { + const uiConfig = ProviderRegistry.getChatUIConfig(providerId); + if (!uiConfig.ownsModel(currentModel, settings)) { + continue; + } + + const normalizedModel = normalizeProviderModel(uiConfig, settings, currentModel); + const currentRuntimeModel = toProviderRuntimeModelId(providerId, currentModel); + const isValid = normalizedModel !== undefined + && uiConfig.getModelOptions(settings).some((option) => + option.value === normalizedModel + && toProviderRuntimeModelId(providerId, option.value) === currentRuntimeModel + ); + if (!isValid) { + continue; + } + + if (normalizedModel !== currentModel) { + settings.titleGenerationModel = normalizedModel; + return true; + } + return false; + } + + settings.titleGenerationModel = ''; + return true; + } + + static normalizeProviderSelection(settings: Record): boolean { + const next = getSettingsProviderId(settings); + + if (settings.settingsProvider === next) { + return false; + } + + settings.settingsProvider = next; + return true; + } + + static getProviderSettingsSnapshot>( + settings: T, + providerId: ProviderId, + ): T { + const snapshot = cloneProviderSettings(settings) as T; + this.projectProviderState(snapshot, providerId); + return snapshot; + } + + static commitProviderSettingsSnapshot( + settings: Record, + providerId: ProviderId, + snapshot: Record, + ): void { + this.persistProjectedProviderState(snapshot, providerId); + + if (providerId === getSettingsProviderId(settings)) { + Object.assign(settings, snapshot); + return; + } + + mergeProviderSettings(settings, snapshot); + } + + static persistProjectedProviderState( + settings: Record, + providerId: ProviderId = getSettingsProviderId(settings), + ): void { + const savedModel = ensureProjectionMap(settings, 'savedProviderModel'); + const savedEffort = ensureProjectionMap(settings, 'savedProviderEffort'); + const savedServiceTier = ensureProjectionMap(settings, 'savedProviderServiceTier'); + const savedBudget = ensureProjectionMap(settings, 'savedProviderThinkingBudget'); + const savedPermissionMode = ensureProjectionMap(settings, 'savedProviderPermissionMode'); + const uiConfig = ProviderRegistry.getChatUIConfig(providerId); + const normalizedModel = normalizeProviderModel( + uiConfig, + settings, + typeof settings.model === 'string' ? settings.model : undefined, + ); + const projectedSettings = normalizedModel && normalizedModel !== settings.model + ? { ...settings, model: normalizedModel } + : settings; + + if (normalizedModel) { + savedModel[providerId] = normalizedModel; + } + if (typeof settings.effortLevel === 'string') { + savedEffort[providerId] = settings.effortLevel; + } + const serviceTierToggle = uiConfig.getServiceTierToggle?.(projectedSettings) ?? null; + if (serviceTierToggle && typeof settings.serviceTier === 'string') { + savedServiceTier[providerId] = settings.serviceTier; + } + const usesBudget = normalizedModel !== undefined + && !uiConfig.isAdaptiveReasoningModel(normalizedModel, projectedSettings); + if (usesBudget && typeof settings.thinkingBudget === 'string') { + savedBudget[providerId] = settings.thinkingBudget; + } else { + delete savedBudget[providerId]; + } + if (typeof settings.permissionMode === 'string' && uiConfig.getPermissionModeToggle?.()) { + savedPermissionMode[providerId] = settings.permissionMode; + } + } + + static projectProviderState( + settings: Record, + providerId: ProviderId, + ): void { + const uiConfig = ProviderRegistry.getChatUIConfig(providerId); + const savedModel = settings.savedProviderModel as ProviderProjectionMap | undefined; + const savedEffort = settings.savedProviderEffort as ProviderProjectionMap | undefined; + const savedServiceTier = settings.savedProviderServiceTier as ProviderProjectionMap | undefined; + const savedBudget = settings.savedProviderThinkingBudget as ProviderProjectionMap | undefined; + const savedPermissionMode = settings.savedProviderPermissionMode as ProviderProjectionMap | undefined; + + const shouldPreferCurrentProjection = providerId === getSettingsProviderId(settings); + const currentModelRaw = typeof settings.model === 'string' ? settings.model : ''; + const currentModel = shouldPreferCurrentProjection + ? (normalizeProviderModel(uiConfig, settings, currentModelRaw) ?? '') + : currentModelRaw; + const currentEffort = typeof settings.effortLevel === 'string' ? settings.effortLevel : undefined; + const currentServiceTier = typeof settings.serviceTier === 'string' ? settings.serviceTier : undefined; + const currentBudget = typeof settings.thinkingBudget === 'string' ? settings.thinkingBudget : undefined; + const modelOptions = uiConfig.getModelOptions(settings); + const isDefaultModelOfAnotherProvider = currentModel.length > 0 + && ProviderRegistry.getRegisteredProviderIds() + .filter(id => id !== providerId) + .some(id => ProviderRegistry.getChatUIConfig(id).isDefaultModel(currentModel)); + const canReuseCurrentModel = currentModel.length > 0 + && !isDefaultModelOfAnotherProvider + && ( + shouldPreferCurrentProjection + || modelOptions.some(option => option.value === currentModel) + ); + const providerDefaultModel = uiConfig.getDefaultModel?.(settings) ?? null; + const validProviderDefaultModel = providerDefaultModel + && modelOptions.some(option => option.value === providerDefaultModel) + ? providerDefaultModel + : null; + const fallbackModel = canReuseCurrentModel + ? currentModel + : (validProviderDefaultModel ?? modelOptions[0]?.value ?? currentModel); + const savedModelValue = normalizeProviderModel(uiConfig, settings, savedModel?.[providerId]); + const isSavedModelValid = savedModelValue !== undefined + && modelOptions.some(option => option.value === savedModelValue); + const model = (isSavedModelValid ? savedModelValue : undefined) ?? fallbackModel; + const canReuseCurrentProjection = canReuseCurrentModel && model === currentModel; + + if (model) { + settings.model = model; + uiConfig.applyModelDefaults(model, settings); + } + + const serviceTierToggle = uiConfig.getServiceTierToggle?.({ + ...settings, + ...(model ? { model } : {}), + }) ?? null; + + const isAdaptive = Boolean(model) && uiConfig.isAdaptiveReasoningModel(model, settings); + + if (savedEffort?.[providerId] !== undefined) { + settings.effortLevel = savedEffort[providerId]; + } else if (canReuseCurrentProjection && currentEffort !== undefined) { + settings.effortLevel = currentEffort; + } else if (isAdaptive) { + settings.effortLevel = uiConfig.getDefaultReasoningValue(model, settings); + } + + if (isAdaptive) { + settings.effortLevel = normalizeReasoningValue(uiConfig, settings, model, settings.effortLevel); + } + + if (savedServiceTier?.[providerId] !== undefined) { + settings.serviceTier = savedServiceTier[providerId]; + } else if (canReuseCurrentProjection && currentServiceTier !== undefined) { + settings.serviceTier = currentServiceTier; + } else { + settings.serviceTier = serviceTierToggle?.inactiveValue ?? 'default'; + } + + const usesBudget = Boolean(model) && !isAdaptive; + + if (usesBudget) { + if (savedBudget?.[providerId] !== undefined) { + settings.thinkingBudget = savedBudget[providerId]; + } else if (canReuseCurrentProjection && currentBudget !== undefined) { + settings.thinkingBudget = currentBudget; + } else { + settings.thinkingBudget = uiConfig.getDefaultReasoningValue(model, settings); + } + settings.thinkingBudget = normalizeReasoningValue(uiConfig, settings, model, settings.thinkingBudget); + } + + const permissionToggle = uiConfig.getPermissionModeToggle?.() ?? null; + if (!permissionToggle) { + return; + } + + const allowedPermissionModes = new Set([ + permissionToggle.inactiveValue, + permissionToggle.activeValue, + ...(permissionToggle.planValue ? [permissionToggle.planValue] : []), + ]); + const currentPermissionMode = normalizeToggleValue(settings.permissionMode, allowedPermissionModes); + const derivedPermissionMode = normalizeToggleValue( + uiConfig.resolvePermissionMode?.(settings), + allowedPermissionModes, + ); + const savedPermissionModeValue = normalizeToggleValue( + savedPermissionMode?.[providerId], + allowedPermissionModes, + ); + + const projectedPermissionMode = savedPermissionModeValue + ?? derivedPermissionMode + ?? (shouldPreferCurrentProjection ? currentPermissionMode : undefined) + ?? currentPermissionMode; + + if (projectedPermissionMode !== undefined) { + settings.permissionMode = projectedPermissionMode; + } + } + + /** Each provider's reconciler only processes its own conversations. */ + static reconcileAllProviders( + settings: Record, + conversations: Conversation[], + ): SettingsReconciliationResult { + return this.reconcileProviders( + settings, + conversations, + ProviderRegistry.getRegisteredProviderIds(), + ); + } + + static reconcileProviders( + settings: Record, + conversations: Conversation[], + providerIds: ProviderId[], + ): SettingsReconciliationResult { + let anyChanged = false; + const allInvalidated: Conversation[] = []; + const settingsProvider = getSettingsProviderId(settings); + + for (const providerId of providerIds) { + const reconciler = ProviderRegistry.getSettingsReconciler(providerId); + const providerConversations = conversations.filter(c => c.providerId === providerId); + const targetSettings = providerId === settingsProvider + ? settings + : cloneProviderSettings(settings); + + if (providerId !== settingsProvider) { + this.projectProviderState(targetSettings, providerId); + } + + const { changed, invalidatedConversations } = reconciler.reconcileModelWithEnvironment( + targetSettings, + providerConversations, + ); + + if (changed) { + anyChanged = true; + this.persistProjectedProviderState(targetSettings, providerId); + if (providerId !== settingsProvider) { + mergeProviderSettings(settings, targetSettings); + } + } + allInvalidated.push(...invalidatedConversations); + } + + if (this.reconcileTitleGenerationModelSelection(settings)) { + anyChanged = true; + } + + return { changed: anyChanged, invalidatedConversations: allInvalidated }; + } + + static normalizeAllModelVariants(settings: Record): boolean { + let anyChanged = false; + const settingsProvider = getSettingsProviderId(settings); + + for (const providerId of ProviderRegistry.getRegisteredProviderIds()) { + const reconciler = ProviderRegistry.getSettingsReconciler(providerId); + const targetSettings = providerId === settingsProvider + ? settings + : cloneProviderSettings(settings); + + if (providerId !== settingsProvider) { + this.projectProviderState(targetSettings, providerId); + } + + const changed = reconciler.normalizeModelVariantSettings(targetSettings); + if (changed) { + anyChanged = true; + this.persistProjectedProviderState(targetSettings, providerId); + if (providerId !== settingsProvider) { + mergeProviderSettings(settings, targetSettings); + } + } + } + + if (this.reconcileTitleGenerationModelSelection(settings)) { + anyChanged = true; + } + return anyChanged; + } + + /** + * Project the settings provider's saved values into the top-level + * model/effortLevel/thinkingBudget fields. + */ + static projectActiveProviderState(settings: Record): void { + this.projectProviderState(settings, getSettingsProviderId(settings)); + } +} diff --git a/src/core/providers/ProviderWorkspaceRegistry.ts b/src/core/providers/ProviderWorkspaceRegistry.ts new file mode 100644 index 0000000..4a91ba8 --- /dev/null +++ b/src/core/providers/ProviderWorkspaceRegistry.ts @@ -0,0 +1,126 @@ +import { HomeFileAdapter } from '../storage/HomeFileAdapter'; +import type { ProviderCommandCatalog } from './commands/ProviderCommandCatalog'; +import type { ProviderHost } from './ProviderHost'; +import type { + AgentMentionProvider, + ProviderCliResolver, + ProviderId, + ProviderModelCatalogRefreshResult, + ProviderRuntimeCommandLoader, + ProviderSettingsTabRenderer, + ProviderTabWarmupPolicy, + ProviderWorkspaceRegistration, + ProviderWorkspaceServices, +} from './types'; + +/** + * Registry for provider-owned workspace/bootstrap services. + * + * Unlike `ProviderRegistry`, this boundary owns app-level provider services such + * as command catalogs, mention providers, MCP/plugin/agent managers, and + * provider-specific storage adaptors. + */ +export class ProviderWorkspaceRegistry { + private static registrations: Partial> = {}; + private static services: Partial> = {}; + + static register( + providerId: ProviderId, + registration: ProviderWorkspaceRegistration, + ): void { + this.registrations[providerId] = registration; + } + + private static getWorkspaceRegistration(providerId: ProviderId): ProviderWorkspaceRegistration { + const registration = this.registrations[providerId]; + if (!registration) { + throw new Error(`Provider workspace "${providerId}" is not registered.`); + } + return registration; + } + + static async initializeAll(plugin: ProviderHost): Promise { + const providerIds = Object.keys(this.registrations); + const storage = plugin.storage; + const vaultAdapter = storage.getAdapter(); + const homeAdapter = new HomeFileAdapter(); + + for (const providerId of providerIds) { + this.services[providerId] = await this.getWorkspaceRegistration(providerId).initialize({ + plugin, + storage, + vaultAdapter, + homeAdapter, + }); + } + } + + static setServices( + providerId: ProviderId, + services: ProviderWorkspaceServices | undefined, + ): void { + if (services) { + this.services[providerId] = services; + } else { + delete this.services[providerId]; + } + } + + static clear(): void { + this.services = {}; + } + + static getServices( + providerId: ProviderId, + ): ProviderWorkspaceServices | null { + return this.services[providerId] ?? null; + } + + static requireServices( + providerId: ProviderId, + ): ProviderWorkspaceServices { + const services = this.getServices(providerId); + if (!services) { + throw new Error(`Provider workspace "${providerId}" is not initialized.`); + } + return services; + } + + static getCommandCatalog(providerId: ProviderId): ProviderCommandCatalog | null { + return this.getServices(providerId)?.commandCatalog ?? null; + } + + static getAgentMentionProvider(providerId: ProviderId): AgentMentionProvider | null { + return this.getServices(providerId)?.agentMentionProvider ?? null; + } + + static async refreshAgentMentions(providerId: ProviderId): Promise { + await this.getServices(providerId)?.refreshAgentMentions?.(); + } + + static async refreshModelCatalog( + providerId: ProviderId, + ): Promise { + return await this.getServices(providerId)?.refreshModelCatalog?.() ?? { changed: false }; + } + + static getCliResolver(providerId: ProviderId): ProviderCliResolver | null { + return this.getServices(providerId)?.cliResolver ?? null; + } + + static getRuntimeCommandLoader(providerId: ProviderId): ProviderRuntimeCommandLoader | null { + return this.getServices(providerId)?.runtimeCommandLoader ?? null; + } + + static getTabWarmupPolicy(providerId: ProviderId): ProviderTabWarmupPolicy | null { + return this.getServices(providerId)?.tabWarmupPolicy ?? null; + } + + static getMcpServerManager(providerId: ProviderId) { + return this.getServices(providerId)?.mcpServerManager ?? null; + } + + static getSettingsTabRenderer(providerId: ProviderId): ProviderSettingsTabRenderer | null { + return this.getServices(providerId)?.settingsTabRenderer ?? null; + } +} diff --git a/src/core/providers/commands/ProviderCommandCatalog.ts b/src/core/providers/commands/ProviderCommandCatalog.ts new file mode 100644 index 0000000..e11997c --- /dev/null +++ b/src/core/providers/commands/ProviderCommandCatalog.ts @@ -0,0 +1,21 @@ +import type { SlashCommand } from '../../types'; +import type { ProviderId } from '../types'; +import type { ProviderCommandEntry } from './ProviderCommandEntry'; + +export interface ProviderCommandDropdownConfig { + providerId: ProviderId; + triggerChars: string[]; + builtInPrefix: string; + skillPrefix: string; + commandPrefix: string; +} + +export interface ProviderCommandCatalog { + listDropdownEntries(context: { includeBuiltIns: boolean }): Promise; + listVaultEntries(): Promise; + saveVaultEntry(entry: ProviderCommandEntry): Promise; + deleteVaultEntry(entry: ProviderCommandEntry): Promise; + setRuntimeCommands(commands: SlashCommand[]): void; + getDropdownConfig(): ProviderCommandDropdownConfig; + refresh(): Promise; +} diff --git a/src/core/providers/commands/ProviderCommandEntry.ts b/src/core/providers/commands/ProviderCommandEntry.ts new file mode 100644 index 0000000..0b241d1 --- /dev/null +++ b/src/core/providers/commands/ProviderCommandEntry.ts @@ -0,0 +1,33 @@ +import type { SlashCommandSource } from '../../types/settings'; +import type { ProviderId } from '../types'; + +export type ProviderCommandKind = 'command' | 'skill'; +export type ProviderCommandScope = 'builtin' | 'vault' | 'user' | 'system' | 'runtime'; + +export interface ProviderCommandEntry { + id: string; + providerId: ProviderId; + kind: ProviderCommandKind; + name: string; + description?: string; + content: string; + argumentHint?: string; + allowedTools?: string[]; + model?: string; + disableModelInvocation?: boolean; + userInvocable?: boolean; + context?: 'fork'; + agent?: string; + hooks?: Record; + scope: ProviderCommandScope; + source: SlashCommandSource; + isEditable: boolean; + isDeletable: boolean; + displayPrefix: string; + insertPrefix: string; + /** + * Opaque provider-owned persistence token used to preserve storage location + * across edits, renames, and deletes in shared settings UIs. + */ + persistenceKey?: string; +} diff --git a/src/core/providers/commands/hiddenCommands.ts b/src/core/providers/commands/hiddenCommands.ts new file mode 100644 index 0000000..143f068 --- /dev/null +++ b/src/core/providers/commands/hiddenCommands.ts @@ -0,0 +1,74 @@ +import type { ClaudianSettings, HiddenProviderCommands } from '../../types/settings'; +import type { ProviderId } from '../types'; + +function normalizeHiddenCommandName(value: string): string { + return value.trim().replace(/^[/$]+/, ''); +} + +export function normalizeHiddenCommandList(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + const seen = new Set(); + const normalized: string[] = []; + + for (const item of value) { + if (typeof item !== 'string') { + continue; + } + + const commandName = normalizeHiddenCommandName(item); + if (!commandName) { + continue; + } + + const key = commandName.toLowerCase(); + if (seen.has(key)) { + continue; + } + + seen.add(key); + normalized.push(commandName); + } + + return normalized; +} + +export function getDefaultHiddenProviderCommands(): HiddenProviderCommands { + return {}; +} + +export function normalizeHiddenProviderCommands( + value: unknown, +): HiddenProviderCommands { + if (!value || typeof value !== 'object') { + return getDefaultHiddenProviderCommands(); + } + + const candidate = value as Partial>; + const normalized: HiddenProviderCommands = {}; + + for (const [providerId, commands] of Object.entries(candidate)) { + const next = normalizeHiddenCommandList(commands); + if (next.length > 0) { + normalized[providerId] = next; + } + } + + return normalized; +} + +export function getHiddenProviderCommands( + settings: Pick, + providerId: ProviderId, +): string[] { + return settings.hiddenProviderCommands?.[providerId] ?? []; +} + +export function getHiddenProviderCommandSet( + settings: Pick, + providerId: ProviderId, +): Set { + return new Set(getHiddenProviderCommands(settings, providerId).map((command) => command.toLowerCase())); +} diff --git a/src/core/providers/conversationModel.ts b/src/core/providers/conversationModel.ts new file mode 100644 index 0000000..594f0fe --- /dev/null +++ b/src/core/providers/conversationModel.ts @@ -0,0 +1,144 @@ +import type { Conversation } from '../types'; +import { toProviderRuntimeModelId } from './modelSelection'; +import { ProviderRegistry } from './ProviderRegistry'; +import { ProviderSettingsCoordinator } from './ProviderSettingsCoordinator'; +import type { ProviderId } from './types'; + +export type ConversationModelSource = 'selected' | 'usage' | 'default'; + +export interface ConversationModelResolution { + model: string; + source: ConversationModelSource; + shouldPersist: boolean; +} + +function trimModel(model: unknown): string { + return typeof model === 'string' ? model.trim() : ''; +} + +function findModelOption( + providerId: ProviderId, + model: string, + settings: Record, +): string | null { + const runtimeModel = toProviderRuntimeModelId(providerId, model); + const option = ProviderRegistry + .getChatUIConfig(providerId) + .getModelOptions(settings) + .find(candidate => + candidate.value === model + || toProviderRuntimeModelId(providerId, candidate.value) === runtimeModel + ); + return option?.value ?? null; +} + +export function normalizeProviderModelSelection( + providerId: ProviderId, + settings: Record, + model: unknown, +): string | null { + const rawModel = trimModel(model); + if (!rawModel) { + return null; + } + + const uiConfig = ProviderRegistry.getChatUIConfig(providerId); + const baseSettings = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + settings, + providerId, + ); + const rawSettings = { + ...baseSettings, + model: rawModel, + }; + + const rawOption = findModelOption(providerId, rawModel, rawSettings); + if (rawOption) { + return rawOption; + } + if (uiConfig.ownsModel(rawModel, rawSettings)) { + return rawModel; + } + + const normalizedModel = trimModel(uiConfig.normalizeModelVariant(rawModel, rawSettings)); + if (!normalizedModel) { + return null; + } + + const normalizedSettings = { + ...baseSettings, + model: normalizedModel, + }; + const normalizedOption = findModelOption(providerId, normalizedModel, normalizedSettings); + if (normalizedOption) { + return normalizedOption; + } + + return normalizedModel === rawModel && uiConfig.ownsModel(normalizedModel, normalizedSettings) + ? normalizedModel + : null; +} + +export function resolveConversationModel( + settings: Record, + providerId: ProviderId, + conversation?: Conversation | null, +): ConversationModelResolution { + const selectedModel = normalizeProviderModelSelection( + providerId, + settings, + conversation?.selectedModel, + ); + if (selectedModel) { + return { + model: selectedModel, + source: 'selected', + shouldPersist: selectedModel !== conversation?.selectedModel, + }; + } + + const usageModel = normalizeProviderModelSelection( + providerId, + settings, + conversation?.usage?.model, + ); + if (usageModel) { + return { + model: usageModel, + source: 'usage', + shouldPersist: true, + }; + } + + const providerSettings = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + settings, + providerId, + ); + const defaultModel = normalizeProviderModelSelection( + providerId, + settings, + providerSettings.model, + ) ?? trimModel(providerSettings.model); + + return { + model: defaultModel, + source: 'default', + shouldPersist: false, + }; +} + +export function getProviderSettingsSnapshotWithModel>( + settings: T, + providerId: ProviderId, + model?: string | null, +): T { + const snapshot = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + settings, + providerId, + ); + const normalizedModel = normalizeProviderModelSelection(providerId, snapshot, model); + if (normalizedModel) { + ProviderSettingsCoordinator.projectModelSelection(snapshot, providerId, normalizedModel); + } + return snapshot; +} diff --git a/src/core/providers/modelRouting.ts b/src/core/providers/modelRouting.ts new file mode 100644 index 0000000..4862945 --- /dev/null +++ b/src/core/providers/modelRouting.ts @@ -0,0 +1,17 @@ +import { ProviderRegistry } from './ProviderRegistry'; +import type { ProviderId } from './types'; + +export function getProviderForModel(model: string, settings?: Record): ProviderId { + return ProviderRegistry.resolveProviderForModel(model, settings); +} + +export function getEnabledProviderForModel( + model: string, + settings: Record, + fallbackProviderId?: ProviderId, +): ProviderId { + return ProviderRegistry.resolveProviderForModel(model, settings, { + onlyEnabledProviders: true, + fallbackProviderId, + }); +} diff --git a/src/core/providers/modelSelection.ts b/src/core/providers/modelSelection.ts new file mode 100644 index 0000000..0feacad --- /dev/null +++ b/src/core/providers/modelSelection.ts @@ -0,0 +1,72 @@ +import type { ProviderId } from './types'; + +const PROVIDER_MODEL_SELECTION_PREFIXES: Partial> = { + claude: 'claude-code/', + codex: 'openai-codex/', + opencode: 'opencode/', + pi: 'pi/', +}; + +export interface ProviderModelSelection { + modelId: string; + providerId: ProviderId; +} + +export function getProviderModelSelectionPrefix(providerId: ProviderId): string | null { + return PROVIDER_MODEL_SELECTION_PREFIXES[providerId] ?? null; +} + +export function encodeProviderModelSelectionId( + providerId: ProviderId, + modelId: string, +): string { + const normalized = modelId.trim(); + const prefix = getProviderModelSelectionPrefix(providerId); + if (!prefix || !normalized || normalized.startsWith(prefix)) { + return normalized; + } + + return `${prefix}${normalized}`; +} + +export function decodeProviderModelSelectionId( + value: string, +): ProviderModelSelection | null { + const normalized = value.trim(); + if (!normalized) { + return null; + } + + for (const [providerId, prefix] of Object.entries(PROVIDER_MODEL_SELECTION_PREFIXES)) { + if (!prefix || !normalized.startsWith(prefix)) { + continue; + } + + const modelId = normalized.slice(prefix.length).trim(); + if (!modelId) { + return null; + } + + return { + providerId, + modelId, + }; + } + + return null; +} + +export function isProviderModelSelectionId( + providerId: ProviderId, + value: string, +): boolean { + return decodeProviderModelSelectionId(value)?.providerId === providerId; +} + +export function toProviderRuntimeModelId( + providerId: ProviderId, + value: string, +): string { + const decoded = decodeProviderModelSelectionId(value); + return decoded && decoded.providerId === providerId ? decoded.modelId : value; +} diff --git a/src/core/providers/providerConfig.ts b/src/core/providers/providerConfig.ts new file mode 100644 index 0000000..58c2297 --- /dev/null +++ b/src/core/providers/providerConfig.ts @@ -0,0 +1,34 @@ +import type { ProviderId } from './types'; + +type ProviderConfigMap = Partial>>; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +export function getProviderConfig( + settings: Record, + providerId: ProviderId, +): Record { + const candidate = settings.providerConfigs; + if (!isRecord(candidate)) { + return {}; + } + + const config = candidate[providerId]; + return isRecord(config) ? { ...config } : {}; +} + +export function setProviderConfig( + settings: Record, + providerId: ProviderId, + config: Record, +): void { + const current = settings.providerConfigs; + const nextConfigs: ProviderConfigMap = isRecord(current) + ? { ...(current as ProviderConfigMap) } + : {}; + + nextConfigs[providerId] = { ...config }; + settings.providerConfigs = nextConfigs; +} diff --git a/src/core/providers/providerEnvironment.ts b/src/core/providers/providerEnvironment.ts new file mode 100644 index 0000000..038c9c8 --- /dev/null +++ b/src/core/providers/providerEnvironment.ts @@ -0,0 +1,364 @@ +import { parseEnvironmentVariables } from '../../utils/env'; +import { getProviderConfig, setProviderConfig } from './providerConfig'; +import { ProviderRegistry } from './ProviderRegistry'; +import type { ProviderId } from './types'; + +export type EnvironmentScope = 'shared' | `provider:${string}`; +export interface EnvironmentScopeUpdate { + scope: EnvironmentScope; + envText: string; +} + +type EnvironmentKeyOwnership = + | { type: 'shared-known' } + | { type: 'shared-unknown' } + | { type: 'provider'; providerId: ProviderId }; + +interface ClassifiedEnvironmentLines { + shared: string[]; + providers: Partial>; + reviewKeys: Set; +} + +const SHARED_ENVIRONMENT_KEYS = new Set([ + 'PATH', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'ALL_PROXY', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'REQUESTS_CA_BUNDLE', + 'CURL_CA_BUNDLE', + 'NODE_EXTRA_CA_CERTS', + 'TMPDIR', + 'TMP', + 'TEMP', +]); + +function resolveScopeProviderId(scope: EnvironmentScope): ProviderId | null { + return scope.startsWith('provider:') ? scope.slice('provider:'.length) : null; +} + +function classifyEnvironmentKey(key: string): EnvironmentKeyOwnership { + const normalized = key.trim().toUpperCase(); + if (!normalized) { + return { type: 'shared-unknown' }; + } + + if (SHARED_ENVIRONMENT_KEYS.has(normalized)) { + return { type: 'shared-known' }; + } + + for (const providerId of ProviderRegistry.getRegisteredProviderIds()) { + const patterns = ProviderRegistry.getEnvironmentKeyPatterns(providerId); + if (patterns.some((pattern) => pattern.test(normalized))) { + return { type: 'provider', providerId }; + } + } + + return { type: 'shared-unknown' }; +} + +function extractEnvironmentKey(line: string): string | null { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + return null; + } + + const normalized = trimmed.startsWith('export ') ? trimmed.slice(7) : trimmed; + const eqIndex = normalized.indexOf('='); + if (eqIndex <= 0) { + return null; + } + + const key = normalized.slice(0, eqIndex).trim(); + return key || null; +} + +function appendLines(target: string[], pendingDecorators: string[], line: string): void { + target.push(...pendingDecorators, line); +} + +function createClassifiedEnvironmentLines(): ClassifiedEnvironmentLines { + return { + shared: [], + providers: {}, + reviewKeys: new Set(), + }; +} + +function joinEnvironmentLines(lines: string[]): string { + return lines.join('\n'); +} + +function hasMeaningfulEnvironmentContent(envText: string): boolean { + return envText + .split(/\r?\n/) + .some((line) => { + const trimmed = line.trim(); + return trimmed.length > 0 && !trimmed.startsWith('#'); + }); +} + +function getLegacyEnvironmentClassification( + settings: Record, +): ReturnType { + const legacyEnvironmentVariables = settings.environmentVariables; + if (typeof legacyEnvironmentVariables !== 'string' || legacyEnvironmentVariables.length === 0) { + return { + shared: '', + providers: {}, + reviewKeys: [], + }; + } + + return classifyEnvironmentVariablesByOwnership(legacyEnvironmentVariables); +} + +export function classifyEnvironmentVariablesByOwnership(input: string): { + shared: string; + providers: Partial>; + reviewKeys: string[]; +} { + const result = createClassifiedEnvironmentLines(); + let pendingDecorators: string[] = []; + + for (const line of input.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + pendingDecorators.push(line); + continue; + } + + const key = extractEnvironmentKey(line); + if (!key) { + appendLines(result.shared, pendingDecorators, line); + pendingDecorators = []; + continue; + } + + const ownership = classifyEnvironmentKey(key); + if (ownership.type === 'provider') { + const target = result.providers[ownership.providerId] ?? []; + appendLines(target, pendingDecorators, line); + result.providers[ownership.providerId] = target; + } else { + appendLines(result.shared, pendingDecorators, line); + if (ownership.type === 'shared-unknown') { + result.reviewKeys.add(key); + } + } + pendingDecorators = []; + } + + if (pendingDecorators.length > 0) { + result.shared.push(...pendingDecorators); + } + + return { + shared: joinEnvironmentLines(result.shared), + providers: Object.fromEntries( + Object.entries(result.providers).map(([providerId, lines]) => [ + providerId, + joinEnvironmentLines(lines ?? []), + ]), + ), + reviewKeys: Array.from(result.reviewKeys), + }; +} + +export function getSharedEnvironmentVariables(settings: Record): string { + const sharedEnvironmentVariables = settings.sharedEnvironmentVariables; + if (typeof sharedEnvironmentVariables === 'string') { + return sharedEnvironmentVariables; + } + + return getLegacyEnvironmentClassification(settings).shared; +} + +export function setSharedEnvironmentVariables( + settings: Record, + envText: string, +): void { + settings.sharedEnvironmentVariables = envText; + delete settings.environmentVariables; +} + +export function getProviderEnvironmentVariables( + settings: Record, + providerId: ProviderId, +): string { + const providerConfig = getProviderConfig(settings, providerId); + if (typeof providerConfig.environmentVariables === 'string') { + return providerConfig.environmentVariables; + } + + return getLegacyEnvironmentClassification(settings).providers[providerId] ?? ''; +} + +export function setProviderEnvironmentVariables( + settings: Record, + providerId: ProviderId, + envText: string, +): void { + setProviderConfig(settings, providerId, { + ...getProviderConfig(settings, providerId), + environmentVariables: envText, + }); + delete settings.environmentVariables; +} + +export function joinEnvironmentTexts(...parts: Array): string { + const filtered = parts.filter((part): part is string => typeof part === 'string' && part.length > 0); + if (filtered.length === 0) { + return ''; + } + + return filtered.reduce((combined, part) => { + if (!combined) { + return part; + } + + return combined.endsWith('\n') ? `${combined}${part}` : `${combined}\n${part}`; + }, ''); +} + +export function getRuntimeEnvironmentText( + settings: Record, + providerId: ProviderId, +): string { + return joinEnvironmentTexts( + getSharedEnvironmentVariables(settings), + getProviderEnvironmentVariables(settings, providerId), + ); +} + +export function getRuntimeEnvironmentVariables( + settings: Record, + providerId: ProviderId, +): Record { + return parseEnvironmentVariables(getRuntimeEnvironmentText(settings, providerId)); +} + +export function getEnvironmentVariablesForScope( + settings: Record, + scope: EnvironmentScope, +): string { + if (scope === 'shared') { + return getSharedEnvironmentVariables(settings); + } + + return getProviderEnvironmentVariables(settings, resolveScopeProviderId(scope) ?? ''); +} + +export function setEnvironmentVariablesForScope( + settings: Record, + scope: EnvironmentScope, + envText: string, +): void { + if (scope === 'shared') { + setSharedEnvironmentVariables(settings, envText); + return; + } + + const providerId = resolveScopeProviderId(scope); + if (!providerId) { + return; + } + + setProviderEnvironmentVariables(settings, providerId, envText); +} + +export function getEnvironmentReviewKeysForScope( + envText: string, + scope: EnvironmentScope, +): string[] { + const reviewKeys = new Set(); + const expectedProviderId = resolveScopeProviderId(scope); + + for (const line of envText.split(/\r?\n/)) { + const key = extractEnvironmentKey(line); + if (!key || reviewKeys.has(key)) { + continue; + } + + const ownership = classifyEnvironmentKey(key); + if (scope === 'shared') { + if (ownership.type !== 'shared-known') { + reviewKeys.add(key); + } + continue; + } + + if (ownership.type !== 'provider' || ownership.providerId !== expectedProviderId) { + reviewKeys.add(key); + } + } + + return Array.from(reviewKeys); +} + +export function inferEnvironmentSnippetScope( + envText: string, +): EnvironmentScope | undefined { + const classified = classifyEnvironmentVariablesByOwnership(envText); + const nonEmptyScopes: EnvironmentScope[] = []; + + if (hasMeaningfulEnvironmentContent(classified.shared)) { + nonEmptyScopes.push('shared'); + } + + for (const [providerId, providerEnv] of Object.entries(classified.providers)) { + if (providerEnv && hasMeaningfulEnvironmentContent(providerEnv)) { + nonEmptyScopes.push(`provider:${providerId}`); + } + } + + return nonEmptyScopes.length === 1 ? nonEmptyScopes[0] : undefined; +} + +export function resolveEnvironmentSnippetScope( + envText: string, + fallbackScope?: EnvironmentScope, +): EnvironmentScope | undefined { + const inferredScope = inferEnvironmentSnippetScope(envText); + if (inferredScope) { + return inferredScope; + } + + return hasMeaningfulEnvironmentContent(envText) ? undefined : fallbackScope; +} + +export function getEnvironmentScopeUpdates( + envText: string, + fallbackScope?: EnvironmentScope, +): EnvironmentScopeUpdate[] { + const classified = classifyEnvironmentVariablesByOwnership(envText); + const updates: EnvironmentScopeUpdate[] = []; + + if (classified.shared.trim()) { + updates.push({ scope: 'shared', envText: classified.shared }); + } + + for (const [providerId, providerEnv] of Object.entries(classified.providers)) { + if (!providerEnv || !providerEnv.trim()) { + continue; + } + + updates.push({ + scope: `provider:${providerId}`, + envText: providerEnv, + }); + } + + if (updates.length > 0) { + return updates; + } + + if (fallbackScope) { + return [{ scope: fallbackScope, envText }]; + } + + return []; +} diff --git a/src/core/providers/reasoning.ts b/src/core/providers/reasoning.ts new file mode 100644 index 0000000..3a5fd7c --- /dev/null +++ b/src/core/providers/reasoning.ts @@ -0,0 +1,14 @@ +export const DEFAULT_REASONING_VALUE = 'high'; + +export function resolvePreferredReasoningDefault( + availableValues: readonly string[], + fallbackValue: string, +): string { + if (availableValues.includes(DEFAULT_REASONING_VALUE)) { + return DEFAULT_REASONING_VALUE; + } + if (availableValues.includes(fallbackValue)) { + return fallbackValue; + } + return availableValues[0] ?? fallbackValue; +} diff --git a/src/core/providers/types.ts b/src/core/providers/types.ts new file mode 100644 index 0000000..f39249e --- /dev/null +++ b/src/core/providers/types.ts @@ -0,0 +1,618 @@ +import type { CursorContext } from '../../utils/editor'; +import type { SharedAppStorage } from '../bootstrap/storage'; +import type { McpServerManager } from '../mcp/McpServerManager'; +import type { ChatRuntime } from '../runtime/ChatRuntime'; +import type { HomeFileAdapter } from '../storage/HomeFileAdapter'; +import type { VaultFileAdapter } from '../storage/VaultFileAdapter'; +import type { + AgentDefinition, + Conversation, + InstructionRefineResult, + ManagedMcpServer, + PluginInfo, + SessionMetadata, + SlashCommand, + SubagentInfo, + ToolCallInfo, +} from '../types'; +import type { ProviderId } from '../types/provider'; +import type { ProviderCommandCatalog } from './commands/ProviderCommandCatalog'; +import type { ProviderHost } from './ProviderHost'; + +export type { ProviderId } from '../types/provider'; + +export interface ProviderCapabilities { + providerId: ProviderId; + supportsPersistentRuntime: boolean; + supportsNativeHistory: boolean; + supportsPlanMode: boolean; + supportsRewind: boolean; + supportsFork: boolean; + supportsProviderCommands: boolean; + supportsImageAttachments: boolean; + supportsInstructionMode: boolean; + supportsMcpTools: boolean; + supportsTurnSteer?: boolean; + reasoningControl: 'effort' | 'token-budget' | 'none'; + planPathPrefix?: string; +} + +export const DEFAULT_CHAT_PROVIDER_ID = 'claude' as const satisfies ProviderId; + +export interface CreateChatRuntimeOptions { + plugin: ProviderHost; + providerId?: ProviderId; +} + +/** + * Chat-facing provider registration. + * + * This is intentionally limited to chat-facing services. + * Shared bootstrap (defaults, storage) is in `src/core/bootstrap/`. + * Provider-owned workspace services (CLI resolution, commands, agents, + * MCP, settings tabs) live behind `src/providers//app/`. + */ +export interface ProviderRegistration { + displayName: string; + blankTabOrder: number; + isEnabled: (settings: Record) => boolean; + capabilities: ProviderCapabilities; + environmentKeyPatterns?: RegExp[]; + chatUIConfig: ProviderChatUIConfig; + settingsReconciler: ProviderSettingsReconciler; + createRuntime: (options: Omit) => ChatRuntime; + createTitleGenerationService: (plugin: ProviderHost) => TitleGenerationService; + createInstructionRefineService: (plugin: ProviderHost) => InstructionRefineService; + createInlineEditService: (plugin: ProviderHost) => InlineEditService; + historyService: ProviderConversationHistoryService; + taskResultInterpreter: ProviderTaskResultInterpreter; + subagentLifecycleAdapter?: ProviderSubagentLifecycleAdapter; +} + +export interface ProviderModule extends ProviderRegistration { + id: ProviderId; + settingsStorage: ProviderSettingsStorageAdapter; + workspace: ProviderWorkspaceRegistration; +} + +export interface ProviderSettingsStorageAdapter { + hostScopedFields?: string[]; + legacyTopLevelFields?: string[]; + runtimeOnlyFields?: string[]; + normalizeStored( + target: Record, + stored: Record, + ): boolean; +} + +export interface ProviderSettingsReconciler { + handleEnvironmentChange?(settings: Record): boolean; + + reconcileModelWithEnvironment( + settings: Record, + conversations: Conversation[], + ): { changed: boolean; invalidatedConversations: Conversation[] }; + + normalizeModelVariantSettings(settings: Record): boolean; +} + +// --------------------------------------------------------------------------- +// App-level service interfaces +// --------------------------------------------------------------------------- + +/** Tab manager state persisted across restarts. */ +export interface AppTabManagerState { + openTabs: Array<{ tabId: string; conversationId: string | null; draftModel?: string | null }>; + activeTabId: string | null; + expandedTitleTabIds?: string[]; +} + +/** Provider-neutral session metadata storage. */ +export interface AppSessionStorage { + listMetadata(): Promise; + saveMetadata(meta: SessionMetadata): Promise; + deleteMetadata(id: string): Promise; + toSessionMetadata(conv: Conversation): SessionMetadata; +} + +// --------------------------------------------------------------------------- +// Provider-owned workspace sub-interfaces +// +// These remain here as standalone types so app-level settings/chat code can +// depend on stable provider workspace contracts without importing concrete +// provider implementations. They are NOT part of the shared bootstrap storage +// contract (`SharedAppStorage`). +// --------------------------------------------------------------------------- + +export interface AppMcpStorage { + load(): Promise; + save(servers: ManagedMcpServer[]): Promise; + tryParseClipboardConfig?(text: string): unknown; +} + +export interface AppCommandStorage { + save(command: SlashCommand): Promise; + delete(name: string): Promise; +} + +export interface AppSkillStorage { + save(skill: SlashCommand): Promise; + delete(name: string): Promise; +} + +export interface AppAgentStorage { + load(agent: AgentDefinition): Promise; + save(agent: AgentDefinition): Promise; + delete(agent: AgentDefinition): Promise; +} + +export type AgentMentionSource = AgentDefinition['source']; + +export interface AgentMentionProvider { + searchAgents(query: string): Array<{ + id: string; + name: string; + description?: string; + source: AgentMentionSource; + }>; +} + +/** Provider plugin manager interface consumed by the app layer. */ +export interface AppPluginManager { + loadPlugins(): Promise; + getPlugins(): PluginInfo[]; + hasPlugins(): boolean; + hasEnabledPlugins(): boolean; + getEnabledCount(): number; + getPluginsKey(): string; + togglePlugin(pluginId: string): Promise; + enablePlugin(pluginId: string): Promise; + disablePlugin(pluginId: string): Promise; +} + +/** Provider agent manager interface consumed by the app layer. */ +export interface AppAgentManager extends AgentMentionProvider { + loadAgents(): Promise; + getAvailableAgents(): AgentDefinition[]; + getAgentById(id: string): AgentDefinition | undefined; + searchAgents(query: string): AgentDefinition[]; + setBuiltinAgentNames(names: string[]): void; +} + +// --------------------------------------------------------------------------- +// Provider-owned chat UI configuration +// --------------------------------------------------------------------------- + +/** Option for model, reasoning, or other UI selectors. */ +export interface ProviderUIOption { + value: string; + label: string; + description?: string; + /** Optional group label for visual separators in dropdowns. */ + group?: string; + /** Per-option icon override (e.g. when mixing providers in a single dropdown). */ + providerIcon?: ProviderIconSvg; +} + +export interface ProviderPathIconSvg { + kind?: 'path'; + viewBox: string; + path: string; +} + +export interface ProviderSvgPathChild { + tag: 'path'; + attributes: Record; +} + +export interface ProviderSvgGroupChild { + tag: 'g'; + attributes: Record; + children: ProviderSvgPathChild[]; +} + +export type ProviderSvgChild = ProviderSvgGroupChild | ProviderSvgPathChild; + +export interface ProviderCompositeIconSvg { + kind: 'composite'; + viewBox: string; + children: ProviderSvgChild[]; +} + +/** SVG icon descriptor for provider branding in selectors and headers. */ +export type ProviderIconSvg = ProviderPathIconSvg | ProviderCompositeIconSvg; + +/** Extended option with token count for budget-based reasoning controls. */ +export interface ProviderReasoningOption extends ProviderUIOption { + tokens?: number; +} + +/** Compact permission-mode toggle descriptor for providers that expose the current toolbar control. */ +export interface ProviderPermissionModeToggleConfig { + inactiveValue: string; + inactiveLabel: string; + activeValue: string; + activeLabel: string; + planValue?: string; + planLabel?: string; +} + +/** Compact service-tier toggle descriptor for providers that expose a fast/standard toolbar control. */ +export interface ProviderServiceTierToggleConfig { + inactiveValue: string; + inactiveLabel: string; + activeValue: string; + activeLabel: string; + description?: string; +} + +export interface ProviderModeSelectorConfig { + activeValue?: string; + label: string; + options: ProviderUIOption[]; + value: string; +} + +/** Synchronous UI projection owned by the provider and backed by provider-owned metadata. */ +export interface ProviderChatUIConfig { + /** Model options for the selector dropdown. Provider extracts what it needs from the settings bag. */ + getModelOptions(settings: Record): ProviderUIOption[]; + + /** Semantic default model, independent from selector display order. */ + getDefaultModel?(settings: Record): string | null; + + /** Whether this provider owns the given model id. */ + ownsModel(model: string, settings: Record): boolean; + + /** Whether the model uses adaptive reasoning (effort levels vs token budgets). */ + isAdaptiveReasoningModel(model: string, settings: Record): boolean; + + /** Reasoning options for the current model (effort levels if adaptive, budgets otherwise). */ + getReasoningOptions(model: string, settings: Record): ProviderReasoningOption[]; + + /** Default reasoning value for the model. */ + getDefaultReasoningValue(model: string, settings: Record): string; + + /** Context window size in tokens. */ + getContextWindowSize( + model: string, + customLimits?: Record, + settings?: Record, + ): number; + + /** Whether this is a built-in (default) model vs custom/env model. */ + isDefaultModel(model: string): boolean; + + /** Apply model change side effects to settings (defaults, tracking). */ + applyModelDefaults(model: string, settings: unknown): void; + + /** Optional provider hook to discover model-scoped metadata after a model is selected. */ + prepareModelMetadata?( + model: string, + settings: Record, + context: { plugin: ProviderHost }, + ): Promise; + + /** Optional hook when the toolbar changes a reasoning selection. */ + applyReasoningSelection?(model: string, value: string, settings: unknown): void; + + /** Normalize model variant based on visibility flags. Provider extracts what it needs from the settings bag. */ + normalizeModelVariant(model: string, settings: Record): string; + + /** Extract custom model IDs from parsed environment variables. Used for per-model context limit UI. */ + getCustomModelIds(envVars: Record): Set; + + /** Optional permission-mode toggle descriptor. Return null when the provider exposes no permission toggle UI. */ + getPermissionModeToggle?(): ProviderPermissionModeToggleConfig | null; + + /** Optional provider-owned mapping back into the shared permission-mode contract. */ + resolvePermissionMode?(settings: Record): string | null; + + /** Optional hook when the toolbar changes permission mode. */ + applyPermissionMode?(value: string, settings: unknown): void; + + /** Optional service-tier toggle descriptor. Return null when the provider exposes no fast/standard UI. */ + getServiceTierToggle?(settings: Record): ProviderServiceTierToggleConfig | null; + + /** Optional provider-owned mode selector descriptor. */ + getModeSelector?(settings: Record): ProviderModeSelectorConfig | null; + + /** Optional hook when the toolbar changes a provider-owned mode selection. */ + applyModeSelection?(value: string, settings: unknown): void; + + /** Whether the provider enables the shared bang-bash input mode. */ + isBangBashEnabled?(settings: Record): boolean; + + /** SVG icon for the provider (shown next to model names in selectors). */ + getProviderIcon?(): ProviderIconSvg | null; +} + +// --------------------------------------------------------------------------- +// Provider-owned boundary services +// --------------------------------------------------------------------------- + +export interface ProviderCliResolutionContext { + executionTarget?: unknown; +} + +export interface ProviderCliResolver { + resolveFromSettings( + settings: Record, + context?: ProviderCliResolutionContext, + ): string | null; + reset(): void; +} + +export interface ProviderRuntimeCommandLoaderContext { + // Shared command discovery may need a short-lived provider session; the tab + // manager decides when that is allowed for the active tab. + allowSessionCreation?: boolean; + conversation: Conversation | null; + externalContextPaths: string[]; + plugin: ProviderHost; + runtime: ChatRuntime | null; +} + +export interface ProviderRuntimeCommandLoader { + isAvailable(settings: Record): boolean; + loadCommands(context: ProviderRuntimeCommandLoaderContext): Promise; +} + +// `commands` warms provider-owned command discovery without fully priming the +// bound tab runtime. `runtime` primes the real tab runtime itself. +export type ProviderTabWarmupMode = 'none' | 'commands' | 'runtime'; + +export type ProviderTabWarmupLifecycleState = 'blank' | 'bound_cold' | 'bound_active' | 'closing'; + +export interface ProviderTabWarmupContext { + conversation: Conversation | null; + externalContextPaths: string[]; + plugin: ProviderHost; + runtime: ChatRuntime | null; + tab: { + conversationId: string | null; + draftModel: string | null; + lifecycleState: ProviderTabWarmupLifecycleState; + providerId: ProviderId; + }; +} + +export interface ProviderTabWarmupPolicy { + resolveMode(context: ProviderTabWarmupContext): ProviderTabWarmupMode; +} + +export interface ProviderWorkspaceServices { + commandCatalog?: ProviderCommandCatalog | null; + agentMentionProvider?: AgentMentionProvider | null; + cliResolver?: ProviderCliResolver | null; + runtimeCommandLoader?: ProviderRuntimeCommandLoader | null; + tabWarmupPolicy?: ProviderTabWarmupPolicy | null; + mcpServerManager?: McpServerManager | null; + settingsTabRenderer?: ProviderSettingsTabRenderer | null; + refreshAgentMentions?(): Promise; + refreshModelCatalog?(): Promise; +} + +export interface ProviderModelCatalogRefreshResult { + /** Whether runtime catalog or persisted selection state changed. */ + changed: boolean; + diagnostics?: string; + /** Whether the provider-owned refresh persisted selection settings. */ + persistedSettingsChanged?: boolean; +} + +export interface ProviderSettingsTabRendererContext { + plugin: ProviderHost; + renderHiddenProviderCommandSetting( + container: HTMLElement, + providerId: ProviderId, + copy: { name: string; desc: string; placeholder: string }, + ): void; + refreshModelSelectors(): void; + renderCustomContextLimits(container: HTMLElement, providerId?: ProviderId): void; +} + +export interface ProviderSettingsTabRenderer { + render(container: HTMLElement, context: ProviderSettingsTabRendererContext): void; +} + +export interface ProviderWorkspaceInitContext { + plugin: ProviderHost; + storage: SharedAppStorage; + vaultAdapter: VaultFileAdapter; + homeAdapter: HomeFileAdapter; +} + +export interface ProviderWorkspaceRegistration< + TServices extends ProviderWorkspaceServices = ProviderWorkspaceServices, +> { + initialize(context: ProviderWorkspaceInitContext): Promise; +} + +export interface ProviderConversationHistoryService { + /** + * Reports whether the provider-native session needed to resume a persisted + * conversation is still available. Providers that cannot distinguish a + * missing session from an inaccessible history store should return unknown. + */ + getConversationSessionAvailability?( + conversation: Conversation, + vaultPath: string | null, + pathContext?: ProviderHistoryPathContext, + ): Promise; + /** Clears stale resume state so relocated provider history can rebuild natively. */ + prepareRelocatedConversationSession?( + conversation: Conversation, + vaultPath: string | null, + pathContext?: ProviderHistoryPathContext, + ): Promise; + /** Decides whether a confirmed missing resume session makes the whole record disposable. */ + resolveMissingConversationSession?( + conversation: Conversation, + vaultPath: string | null, + missingProviderSessionId?: string, + pathContext?: ProviderHistoryPathContext, + ): Promise<'delete' | 'reset' | 'preserve'>; + hydrateConversationHistory( + conversation: Conversation, + vaultPath: string | null, + pathContext?: ProviderHistoryPathContext, + ): Promise; + deleteConversationSession( + conversation: Conversation, + vaultPath: string | null, + pathContext?: ProviderHistoryPathContext, + ): Promise; + resolveSessionIdForConversation(conversation: Conversation | null): string | null; + isPendingForkConversation(conversation: Conversation): boolean; + /** Builds opaque provider state for a forked conversation. */ + buildForkProviderState( + sourceSessionId: string, + resumeAt: string, + sourceProviderState?: Record, + ): Record; + /** Adds provider-owned persisted metadata to Conversation.providerState before session save. */ + buildPersistedProviderState?(conversation: Conversation): Record | undefined; +} + +export interface ProviderHistoryPathContext { + environment: NodeJS.ProcessEnv; + hostPlatform?: NodeJS.Platform; + settings?: Record; + vaultPath?: string | null; +} + +export type ProviderConversationSessionAvailability = + | 'available' + | 'relocated' + | 'missing' + | 'unknown'; + +export type ProviderTaskTerminalStatus = Extract; + +export interface ProviderTaskResultInterpreter { + hasAsyncLaunchMarker(toolUseResult: unknown): boolean; + extractAgentId(toolUseResult: unknown): string | null; + extractStructuredResult(toolUseResult: unknown): string | null; + resolveTerminalStatus( + toolUseResult: unknown, + fallbackStatus: ProviderTaskTerminalStatus, + ): ProviderTaskTerminalStatus; + extractTagValue(payload: string, tagName: string): string | null; +} + +export interface ProviderSubagentLaunchResult { + agentId?: string; + nickname?: string; +} + +export interface ProviderSubagentWaitStatus { + completed?: string; + error?: string; + failed?: string; +} + +export interface ProviderSubagentWaitResult { + statuses: Record; + timedOut: boolean; +} + +export interface ProviderSubagentLifecycleAdapter { + isHiddenTool(name: string): boolean; + isSpawnTool(name: string): boolean; + isWaitTool(name: string): boolean; + isCloseTool(name: string): boolean; + resolveSpawnToolIds( + waitToolCall: ToolCallInfo, + agentIdToSpawnId: ReadonlyMap, + ): string[]; + buildSubagentInfo( + spawnToolCall: ToolCallInfo, + siblingToolCalls?: ToolCallInfo[], + ): SubagentInfo; + extractSpawnResult(raw: string | undefined): ProviderSubagentLaunchResult; + extractWaitResult(raw: string | undefined): ProviderSubagentWaitResult; +} + +// --------------------------------------------------------------------------- +// Auxiliary service contracts +// --------------------------------------------------------------------------- + +// -- Title generation -- + +export type TitleGenerationResult = + | { success: true; title: string } + | { success: false; error: string }; + +export type TitleGenerationCallback = ( + conversationId: string, + result: TitleGenerationResult +) => Promise; + +export interface TitleGenerationService { + generateTitle( + conversationId: string, + userMessage: string, + callback: TitleGenerationCallback + ): Promise; + cancel(): void; +} + +// -- Instruction refinement -- + +export type RefineProgressCallback = (update: InstructionRefineResult) => void; + +export interface InstructionRefineService { + setModelOverride?(model?: string): void; + resetConversation(): void; + refineInstruction( + rawInstruction: string, + existingInstructions: string, + onProgress?: RefineProgressCallback + ): Promise; + continueConversation( + message: string, + onProgress?: RefineProgressCallback + ): Promise; + cancel(): void; +} + +// -- Inline edit -- + +export type InlineEditMode = 'selection' | 'cursor'; + +export interface InlineEditSelectionRequest { + mode: 'selection'; + instruction: string; + notePath: string; + selectedText: string; + startLine?: number; + lineCount?: number; + contextFiles?: string[]; +} + +export interface InlineEditCursorRequest { + mode: 'cursor'; + instruction: string; + notePath: string; + cursorContext: CursorContext; + contextFiles?: string[]; +} + +export type InlineEditRequest = InlineEditSelectionRequest | InlineEditCursorRequest; + +export interface InlineEditResult { + success: boolean; + editedText?: string; + insertedText?: string; + clarification?: string; + error?: string; +} + +export interface InlineEditService { + setModelOverride?(model?: string): void; + resetConversation(): void; + editText(request: InlineEditRequest): Promise; + continueConversation(message: string, contextFiles?: string[]): Promise; + cancel(): void; +} diff --git a/src/core/runtime/ChatRuntime.ts b/src/core/runtime/ChatRuntime.ts new file mode 100644 index 0000000..46c545c --- /dev/null +++ b/src/core/runtime/ChatRuntime.ts @@ -0,0 +1,66 @@ +import type { ProviderCapabilities, ProviderId } from '../providers/types'; +import type { ChatMessage, Conversation, SlashCommand, StreamChunk, ToolCallInfo } from '../types'; +import type { + ApprovalCallback, + AskUserQuestionCallback, + AutoTurnCallback, + ChatRewindMode, + ChatRewindResult, + ChatRuntimeConversationState, + ChatRuntimeEnsureReadyOptions, + ChatRuntimeQueryOptions, + ChatTurnMetadata, + ChatTurnRequest, + ExitPlanModeCallback, + PreparedChatTurn, + SessionUpdateResult, + SubagentRuntimeState, +} from './types'; + +export interface ChatRuntime { + readonly providerId: ProviderId; + + getCapabilities(): Readonly; + prepareTurn(request: ChatTurnRequest): PreparedChatTurn; + onReadyStateChange(listener: (ready: boolean) => void): () => void; + setResumeCheckpoint(checkpointId: string | undefined): void; + syncConversationState( + conversation: ChatRuntimeConversationState | null, + externalContextPaths?: string[], + ): void; + reloadMcpServers(): Promise; + ensureReady(options?: ChatRuntimeEnsureReadyOptions): Promise; + query( + turn: PreparedChatTurn, + conversationHistory?: ChatMessage[], + queryOptions?: ChatRuntimeQueryOptions, + ): AsyncGenerator; + steer?(turn: PreparedChatTurn): Promise; + cancel(): void; + resetSession(): void; + getSessionId(): string | null; + consumeSessionInvalidation(): boolean; + isReady(): boolean; + getSupportedCommands(): Promise; + getAuxiliaryModel?(): string | null; + cleanup(): void; + rewind(userMessageId: string, assistantMessageId: string | undefined, mode?: ChatRewindMode): Promise; + setApprovalCallback(callback: ApprovalCallback | null): void; + setApprovalDismisser(dismisser: (() => void) | null): void; + setAskUserQuestionCallback(callback: AskUserQuestionCallback | null): void; + setExitPlanModeCallback(callback: ExitPlanModeCallback | null): void; + setPermissionModeSyncCallback(callback: ((sdkMode: string) => void) | null): void; + setSubagentHookProvider(getState: () => SubagentRuntimeState): void; + setAutoTurnCallback(callback: AutoTurnCallback | null): void; + consumeTurnMetadata(): ChatTurnMetadata; + + buildSessionUpdates(params: { + conversation: Conversation | null; + sessionInvalidated: boolean; + }): SessionUpdateResult; + + resolveSessionIdForFork(conversation: Conversation | null): string | null; + + loadSubagentToolCalls?(agentId: string): Promise; + loadSubagentFinalResult?(agentId: string): Promise; +} diff --git a/src/core/runtime/QueuedTurn.ts b/src/core/runtime/QueuedTurn.ts new file mode 100644 index 0000000..b44e465 --- /dev/null +++ b/src/core/runtime/QueuedTurn.ts @@ -0,0 +1,97 @@ +import type { ImageAttachment } from '../types'; +import type { ChatTurnRequest } from './types'; + +export interface QueuedChatTurn { + displayContent: string; + request: ChatTurnRequest; +} + +export function cloneChatTurnRequest(request: ChatTurnRequest): ChatTurnRequest { + return { + ...request, + images: cloneImages(request.images), + externalContextPaths: request.externalContextPaths + ? [...request.externalContextPaths] + : undefined, + enabledMcpServers: request.enabledMcpServers + ? new Set(request.enabledMcpServers) + : undefined, + }; +} + +export function cloneQueuedChatTurn(turn: QueuedChatTurn): QueuedChatTurn { + return { + displayContent: turn.displayContent, + request: cloneChatTurnRequest(turn.request), + }; +} + +export function mergeQueuedChatTurns( + existing: QueuedChatTurn, + incoming: QueuedChatTurn, +): QueuedChatTurn { + const existingRequest = existing.request; + const incomingRequest = incoming.request; + + return { + displayContent: mergeText(existing.displayContent, incoming.displayContent), + request: { + ...cloneChatTurnRequest(incomingRequest), + text: mergeText(existingRequest.text, incomingRequest.text), + images: mergeImages(existingRequest.images, incomingRequest.images), + currentNotePath: incomingRequest.currentNotePath ?? existingRequest.currentNotePath, + externalContextPaths: mergeStringLists( + existingRequest.externalContextPaths, + incomingRequest.externalContextPaths, + ), + enabledMcpServers: mergeSets( + existingRequest.enabledMcpServers, + incomingRequest.enabledMcpServers, + ), + }, + }; +} + +function mergeText(first: string, second: string): string { + return [first, second] + .map(part => part.trim()) + .filter(part => part.length > 0) + .join('\n\n'); +} + +function cloneImages(images: ImageAttachment[] | undefined): ImageAttachment[] | undefined { + return images && images.length > 0 ? [...images] : undefined; +} + +function mergeImages( + first: ImageAttachment[] | undefined, + second: ImageAttachment[] | undefined, +): ImageAttachment[] | undefined { + const merged = [...(first ?? []), ...(second ?? [])]; + return merged.length > 0 ? merged : undefined; +} + +function mergeStringLists( + first: string[] | undefined, + second: string[] | undefined, +): string[] | undefined { + const merged = [...(first ?? []), ...(second ?? [])]; + if (merged.length === 0) { + return undefined; + } + return Array.from(new Set(merged)); +} + +function mergeSets( + first: Set | undefined, + second: Set | undefined, +): Set | undefined { + const merged = new Set(); + for (const value of first ?? []) { + merged.add(value); + } + for (const value of second ?? []) { + merged.add(value); + } + return merged.size > 0 ? merged : undefined; +} diff --git a/src/core/runtime/types.ts b/src/core/runtime/types.ts new file mode 100644 index 0000000..3273a27 --- /dev/null +++ b/src/core/runtime/types.ts @@ -0,0 +1,118 @@ +import type { BrowserSelectionContext } from '../../utils/browser'; +import type { CanvasSelectionContext } from '../../utils/canvas'; +import type { EditorSelectionContext } from '../../utils/editor'; +import type { + ApprovalDecision, + Conversation, + ExitPlanModeCallback, + ImageAttachment, + StreamChunk, +} from '../types'; + +export interface ApprovalDecisionOption { + label: string; + description?: string; + value: string; + decision?: ApprovalDecision; +} + +export interface ApprovalNetworkContext { + host: string; + protocol: string; +} + +export interface ApprovalCallbackOptions { + decisionReason?: string; + blockedPath?: string; + agentID?: string; + decisionOptions?: ApprovalDecisionOption[]; + networkApprovalContext?: ApprovalNetworkContext; + additionalPermissions?: unknown; +} + +export type ApprovalCallback = ( + toolName: string, + input: Record, + description: string, + options?: ApprovalCallbackOptions, +) => Promise; + +export type AskUserQuestionCallback = ( + input: Record, + signal?: AbortSignal, +) => Promise | null>; + +export interface ChatTurnRequest { + text: string; + images?: ImageAttachment[]; + currentNotePath?: string; + editorSelection?: EditorSelectionContext | null; + browserSelection?: BrowserSelectionContext | null; + canvasSelection?: CanvasSelectionContext | null; + externalContextPaths?: string[]; + enabledMcpServers?: Set; +} + +export interface PreparedChatTurn { + request: ChatTurnRequest; + persistedContent: string; + prompt: string; + isCompact: boolean; + mcpMentions: Set; +} + +export interface ChatRuntimeQueryOptions { + allowedTools?: string[]; + model?: string; + mcpMentions?: Set; + enabledMcpServers?: Set; + forceColdStart?: boolean; + externalContextPaths?: string[]; +} + +export interface ChatRuntimeEnsureReadyOptions { + allowSessionCreation?: boolean; + force?: boolean; +} + +export type ChatRuntimeConversationState = Pick< + Conversation, + 'sessionId' | 'providerState' | 'selectedModel' +> & Partial>; + +export interface SessionUpdateResult { + updates: Partial; +} + +export interface ChatRewindResult { + canRewind: boolean; + error?: string; + filesChanged?: string[]; + insertions?: number; + deletions?: number; +} + +export type ChatRewindMode = 'conversation' | 'code-and-conversation'; + +export interface SubagentRuntimeState { + hasRunning: boolean; +} + +export interface ChatTurnMetadata { + userMessageId?: string; + assistantMessageId?: string; + wasSent?: boolean; + planCompleted?: boolean; +} + +export interface AutoTurnResult { + chunks: StreamChunk[]; + metadata: ChatTurnMetadata; +} + +export type AutoTurnCallback = (result: AutoTurnResult) => void | Promise; + +export type { + ApprovalDecision, + ExitPlanModeCallback, +}; diff --git a/src/core/security/ApprovalManager.ts b/src/core/security/ApprovalManager.ts new file mode 100644 index 0000000..ce16665 --- /dev/null +++ b/src/core/security/ApprovalManager.ts @@ -0,0 +1,142 @@ +/** Permission utilities for tool action approval. */ + +import { + TOOL_BASH, + TOOL_EDIT, + TOOL_GLOB, + TOOL_GREP, + TOOL_NOTEBOOK_EDIT, + TOOL_READ, + TOOL_WRITE, +} from '../tools/toolNames'; + +export function getActionPattern(toolName: string, input: Record): string | null { + switch (toolName) { + case TOOL_BASH: + return typeof input.command === 'string' ? input.command.trim() : ''; + case TOOL_READ: + case TOOL_WRITE: + case TOOL_EDIT: + return typeof input.file_path === 'string' && input.file_path ? input.file_path : null; + case TOOL_NOTEBOOK_EDIT: + if (typeof input.notebook_path === 'string' && input.notebook_path) { + return input.notebook_path; + } + return typeof input.file_path === 'string' && input.file_path ? input.file_path : null; + case TOOL_GLOB: + return typeof input.pattern === 'string' && input.pattern ? input.pattern : null; + case TOOL_GREP: + return typeof input.pattern === 'string' && input.pattern ? input.pattern : null; + default: + return JSON.stringify(input); + } +} + +export function getActionDescription(toolName: string, input: Record): string { + const pattern = getActionPattern(toolName, input) ?? '(unknown)'; + switch (toolName) { + case TOOL_BASH: + return `Run command: ${pattern}`; + case TOOL_READ: + return `Read file: ${pattern}`; + case TOOL_WRITE: + return `Write to file: ${pattern}`; + case TOOL_EDIT: + return `Edit file: ${pattern}`; + case TOOL_GLOB: + return `Search files matching: ${pattern}`; + case TOOL_GREP: + return `Search content matching: ${pattern}`; + default: + return `${toolName}: ${pattern}`; + } +} + +/** + * Bash: exact or explicit wildcard ("git *", "npm:*"). + * File tools: path-prefix matching with segment boundaries. + * Other tools: simple prefix matching. + */ +export function matchesRulePattern( + toolName: string, + actionPattern: string | null, + rulePattern: string | undefined +): boolean { + // No rule pattern means match all + if (!rulePattern) return true; + + // Null action pattern means we can't determine the action - don't match + if (actionPattern === null) return false; + + const normalizedAction = actionPattern.replace(/\\/g, '/'); + const normalizedRule = rulePattern.replace(/\\/g, '/'); + + // Wildcard matches everything + if (normalizedRule === '*') return true; + + // Exact match + if (normalizedAction === normalizedRule) return true; + + // Bash: Only exact match (handled above) or explicit wildcard patterns are allowed. + // This is intentional - Bash commands require explicit wildcards for security. + // Supported formats: + // - "git *" matches "git status", "git commit", etc. + // - "npm:*" matches "npm install", "npm run", etc. (CC format) + if (toolName === TOOL_BASH) { + // CC format "npm:*" — colon is a separator, not part of the prefix + if (normalizedRule.endsWith(':*')) { + const prefix = normalizedRule.slice(0, -2); + return matchesBashPrefix(normalizedAction, prefix); + } + // Space wildcard "git *" + if (normalizedRule.endsWith('*')) { + const prefix = normalizedRule.slice(0, -1); + return matchesBashPrefix(normalizedAction, prefix); + } + // No wildcard present and exact match failed above - reject + return false; + } + + // File tools: prefix match with path-segment boundary awareness + if ( + toolName === TOOL_READ || + toolName === TOOL_WRITE || + toolName === TOOL_EDIT || + toolName === TOOL_NOTEBOOK_EDIT + ) { + return isPathPrefixMatch(normalizedAction, normalizedRule); + } + + // Other tools: allow simple prefix matching + if (normalizedAction.startsWith(normalizedRule)) return true; + + return false; +} + +function isPathPrefixMatch(actionPath: string, approvedPath: string): boolean { + if (!actionPath.startsWith(approvedPath)) { + return false; + } + + if (approvedPath.endsWith('/')) { + return true; + } + + if (actionPath.length === approvedPath.length) { + return true; + } + + return actionPath.charAt(approvedPath.length) === '/'; +} + +function matchesBashPrefix(action: string, prefix: string): boolean { + if (action === prefix) { + return true; + } + + if (prefix.endsWith(' ')) { + return action.startsWith(prefix); + } + + return action.startsWith(`${prefix} `); +} diff --git a/src/core/storage/HomeFileAdapter.ts b/src/core/storage/HomeFileAdapter.ts new file mode 100644 index 0000000..3efce38 --- /dev/null +++ b/src/core/storage/HomeFileAdapter.ts @@ -0,0 +1,75 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import type { VaultFileAdapter } from './VaultFileAdapter'; + +/** + * Filesystem adapter rooted at the user's home directory. + * Implements the same interface as VaultFileAdapter so storage + * classes (like CodexSkillStorage) can scan home-level paths. + */ +export class HomeFileAdapter implements Pick { + private readonly root: string; + + constructor(root: string = os.homedir()) { + this.root = root; + } + + private resolve(relativePath: string): string { + return path.join(this.root, relativePath); + } + + async exists(p: string): Promise { + try { + await fs.promises.access(this.resolve(p)); + return true; + } catch { + return false; + } + } + + async read(p: string): Promise { + return fs.promises.readFile(this.resolve(p), 'utf-8'); + } + + async write(p: string, content: string): Promise { + const full = this.resolve(p); + await fs.promises.mkdir(path.dirname(full), { recursive: true }); + await fs.promises.writeFile(full, content, 'utf-8'); + } + + async delete(p: string): Promise { + try { + await fs.promises.unlink(this.resolve(p)); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + } + + async deleteFolder(p: string): Promise { + try { + await fs.promises.rmdir(this.resolve(p)); + } catch { + // Non-critical + } + } + + async listFolders(folder: string): Promise { + const full = this.resolve(folder); + try { + const entries = await fs.promises.readdir(full, { withFileTypes: true }); + return entries + .filter(e => e.isDirectory()) + .map(e => `${folder}/${e.name}`); + } catch { + return []; + } + } + + async ensureFolder(p: string): Promise { + await fs.promises.mkdir(this.resolve(p), { recursive: true }); + } +} diff --git a/src/core/storage/NotifiedMutationError.ts b/src/core/storage/NotifiedMutationError.ts new file mode 100644 index 0000000..1f0b3ff --- /dev/null +++ b/src/core/storage/NotifiedMutationError.ts @@ -0,0 +1,11 @@ +/** A mutation failure that has already been presented to the user. */ +export class NotifiedMutationError extends Error { + constructor(message: string) { + super(message); + this.name = 'NotifiedMutationError'; + } +} + +export function isNotifiedMutationError(error: unknown): error is NotifiedMutationError { + return error instanceof NotifiedMutationError; +} diff --git a/src/core/storage/VaultFileAdapter.ts b/src/core/storage/VaultFileAdapter.ts new file mode 100644 index 0000000..16f4c57 --- /dev/null +++ b/src/core/storage/VaultFileAdapter.ts @@ -0,0 +1,132 @@ +/** + * VaultFileAdapter - Wrapper around Obsidian Vault API for file operations. + * + * Provides a consistent interface for file operations using Obsidian's + * vault adapter instead of Node's fs module. + */ + +import type { App } from 'obsidian'; + +export class VaultFileAdapter { + private writeQueue: Promise = Promise.resolve(); + + constructor(private app: App) {} + + async exists(path: string): Promise { + return this.app.vault.adapter.exists(path); + } + + async read(path: string): Promise { + return this.app.vault.adapter.read(path); + } + + async write(path: string, content: string): Promise { + await this.ensureParentFolder(path); + await this.app.vault.adapter.write(path, content); + } + + async append(path: string, content: string): Promise { + await this.ensureParentFolder(path); + this.writeQueue = this.writeQueue.then(async () => { + if (await this.exists(path)) { + const existing = await this.read(path); + await this.app.vault.adapter.write(path, existing + content); + } else { + await this.app.vault.adapter.write(path, content); + } + }).catch(() => { + // prevent queue from getting stuck + }); + await this.writeQueue; + } + + async delete(path: string): Promise { + if (await this.exists(path)) { + await this.app.vault.adapter.remove(path); + } + } + + /** Fails silently if non-empty or missing. */ + async deleteFolder(path: string): Promise { + try { + if (await this.exists(path)) { + await this.app.vault.adapter.rmdir(path, false); + } + } catch { + // Non-critical: directory may not be empty + } + } + + async listFiles(folder: string): Promise { + if (!(await this.exists(folder))) { + return []; + } + const listing = await this.app.vault.adapter.list(folder); + return listing.files; + } + + /** List subfolders in a folder. Returns relative paths from the folder. */ + async listFolders(folder: string): Promise { + if (!(await this.exists(folder))) { + return []; + } + const listing = await this.app.vault.adapter.list(folder); + return listing.folders; + } + + /** Recursively list all files in a folder and subfolders. */ + async listFilesRecursive(folder: string): Promise { + const allFiles: string[] = []; + + const processFolder = async (currentFolder: string) => { + if (!(await this.exists(currentFolder))) return; + + const listing = await this.app.vault.adapter.list(currentFolder); + allFiles.push(...listing.files); + + for (const subfolder of listing.folders) { + await processFolder(subfolder); + } + }; + + await processFolder(folder); + return allFiles; + } + + private async ensureParentFolder(filePath: string): Promise { + const folder = filePath.substring(0, filePath.lastIndexOf('/')); + if (folder && !(await this.exists(folder))) { + await this.ensureFolder(folder); + } + } + + /** Ensure a folder exists, creating it and parent folders if needed. */ + async ensureFolder(path: string): Promise { + if (await this.exists(path)) return; + + // Create parent folders recursively + const parts = path.split('/').filter(Boolean); + let current = ''; + for (const part of parts) { + current = current ? `${current}/${part}` : part; + if (!(await this.exists(current))) { + await this.app.vault.adapter.mkdir(current); + } + } + } + + /** Rename/move a file. */ + async rename(oldPath: string, newPath: string): Promise { + await this.app.vault.adapter.rename(oldPath, newPath); + } + + async stat(path: string): Promise<{ mtime: number; size: number } | null> { + try { + const stat = await this.app.vault.adapter.stat(path); + if (!stat) return null; + return { mtime: stat.mtime, size: stat.size }; + } catch { + return null; + } + } +} diff --git a/src/core/storage/pathContainment.ts b/src/core/storage/pathContainment.ts new file mode 100644 index 0000000..90196ae --- /dev/null +++ b/src/core/storage/pathContainment.ts @@ -0,0 +1,51 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +function getPathModule(value: string): typeof path.posix { + return value.includes('\\') || /^[A-Za-z]:/.test(value) + ? path.win32 + : path.posix; +} + +function isHostPath(pathModule: typeof path.posix): boolean { + return process.platform === 'win32' + ? pathModule === path.win32 + : pathModule === path.posix; +} + +function resolveExistingPath(value: string, pathModule: typeof path.posix): string { + if (!isHostPath(pathModule)) { + return pathModule.resolve(value); + } + + try { + return fs.realpathSync.native(value); + } catch { + return pathModule.resolve(value); + } +} + +export function isPathWithinRoot(candidate: string, root: string): boolean { + if (!candidate.trim() || !root.trim()) { + return false; + } + + const pathModule = getPathModule(root); + if (getPathModule(candidate) !== pathModule) { + return false; + } + + const normalizedRoot = resolveExistingPath(root, pathModule); + const normalizedCandidate = resolveExistingPath(candidate, pathModule); + const relative = pathModule.relative(normalizedRoot, normalizedCandidate); + + return relative === '' || ( + relative !== '..' + && !relative.startsWith(`..${pathModule.sep}`) + && !pathModule.isAbsolute(relative) + ); +} + +export function isSamePath(left: string, right: string): boolean { + return isPathWithinRoot(left, right) && isPathWithinRoot(right, left); +} diff --git a/src/core/tools/todo.ts b/src/core/tools/todo.ts new file mode 100644 index 0000000..0808218 --- /dev/null +++ b/src/core/tools/todo.ts @@ -0,0 +1,65 @@ +/** + * Todo tool helpers. + * + * Parses TodoWrite tool input into typed todo items. + */ + +import { TOOL_TODO_WRITE } from './toolNames'; + +export interface TodoItem { + /** Imperative description (e.g., "Run tests") */ + content: string; + status: 'pending' | 'in_progress' | 'completed'; + /** Present continuous form (e.g., "Running tests") */ + activeForm: string; +} + +function isValidTodoItem(item: unknown): item is TodoItem { + if (typeof item !== 'object' || item === null) return false; + const record = item as Record; + return ( + typeof record.content === 'string' && + record.content.length > 0 && + typeof record.activeForm === 'string' && + record.activeForm.length > 0 && + typeof record.status === 'string' && + ['pending', 'in_progress', 'completed'].includes(record.status) + ); +} + +export function parseTodoInput(input: Record): TodoItem[] | null { + if (!input.todos || !Array.isArray(input.todos)) { + return null; + } + + const validTodos: TodoItem[] = []; + for (const item of input.todos) { + if (isValidTodoItem(item)) { + validTodos.push(item); + } + } + + return validTodos.length > 0 ? validTodos : null; +} + +/** + * Extract the last TodoWrite todos from a list of messages. + * Used to restore the todo panel when loading a saved conversation. + */ +export function extractLastTodosFromMessages( + messages: Array<{ role: string; toolCalls?: Array<{ name: string; input: Record }> }> +): TodoItem[] | null { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === 'assistant' && msg.toolCalls) { + for (let j = msg.toolCalls.length - 1; j >= 0; j--) { + const toolCall = msg.toolCalls[j]; + if (toolCall.name === TOOL_TODO_WRITE) { + const todos = parseTodoInput(toolCall.input); + return todos; + } + } + } + } + return null; +} diff --git a/src/core/tools/toolIcons.ts b/src/core/tools/toolIcons.ts new file mode 100644 index 0000000..1fa3902 --- /dev/null +++ b/src/core/tools/toolIcons.ts @@ -0,0 +1,80 @@ +import { + TOOL_AGENT_OUTPUT, + TOOL_APPLY_PATCH, + TOOL_ASK_USER_QUESTION, + TOOL_BASH, + TOOL_BASH_OUTPUT, + TOOL_CLOSE_AGENT, + TOOL_EDIT, + TOOL_ENTER_PLAN_MODE, + TOOL_EXIT_PLAN_MODE, + TOOL_GLOB, + TOOL_GREP, + TOOL_KILL_SHELL, + TOOL_LIST_MCP_RESOURCES, + TOOL_LS, + TOOL_MCP, + TOOL_NOTEBOOK_EDIT, + TOOL_READ, + TOOL_READ_MCP_RESOURCE, + TOOL_RESUME_AGENT, + TOOL_SEND_INPUT, + TOOL_SKILL, + TOOL_SPAWN_AGENT, + TOOL_SUBAGENT_LEGACY, + TOOL_TASK, + TOOL_TODO_WRITE, + TOOL_TOOL_SEARCH, + TOOL_WAIT, + TOOL_WAIT_AGENT, + TOOL_WEB_FETCH, + TOOL_WEB_SEARCH, + TOOL_WRITE, + TOOL_WRITE_STDIN, +} from './toolNames'; + +const TOOL_ICONS: Record = { + [TOOL_READ]: 'file-text', + [TOOL_WRITE]: 'file-plus', + [TOOL_EDIT]: 'file-pen', + [TOOL_NOTEBOOK_EDIT]: 'file-pen', + [TOOL_BASH]: 'terminal', + [TOOL_BASH_OUTPUT]: 'terminal', + [TOOL_KILL_SHELL]: 'terminal', + [TOOL_GLOB]: 'folder-search', + [TOOL_GREP]: 'search', + [TOOL_LS]: 'list', + [TOOL_TODO_WRITE]: 'list-checks', + [TOOL_TASK]: 'bot', + [TOOL_SUBAGENT_LEGACY]: 'bot', + [TOOL_LIST_MCP_RESOURCES]: 'list', + [TOOL_READ_MCP_RESOURCE]: 'file-text', + [TOOL_MCP]: 'wrench', + [TOOL_WEB_SEARCH]: 'globe', + [TOOL_WEB_FETCH]: 'download', + [TOOL_AGENT_OUTPUT]: 'bot', + [TOOL_ASK_USER_QUESTION]: 'help-circle', + [TOOL_SKILL]: 'zap', + [TOOL_TOOL_SEARCH]: 'search-check', + [TOOL_ENTER_PLAN_MODE]: 'map', + [TOOL_EXIT_PLAN_MODE]: 'check-circle', + // Runtime-managed tools + [TOOL_APPLY_PATCH]: 'file-pen', + [TOOL_WRITE_STDIN]: 'terminal', + [TOOL_SPAWN_AGENT]: 'bot', + [TOOL_SEND_INPUT]: 'bot', + [TOOL_WAIT]: 'clock', + [TOOL_WAIT_AGENT]: 'clock', + [TOOL_RESUME_AGENT]: 'bot', + [TOOL_CLOSE_AGENT]: 'bot', +}; + +/** Special marker for MCP tools - signals to use custom SVG. */ +export const MCP_ICON_MARKER = '__mcp_icon__'; + +export function getToolIcon(toolName: string): string { + if (toolName.startsWith('mcp__')) { + return MCP_ICON_MARKER; + } + return TOOL_ICONS[toolName] || 'wrench'; +} diff --git a/src/core/tools/toolInput.ts b/src/core/tools/toolInput.ts new file mode 100644 index 0000000..2593a92 --- /dev/null +++ b/src/core/tools/toolInput.ts @@ -0,0 +1,119 @@ +/** + * Tool input helpers. + * + * Keeps parsing of common tool inputs consistent across services. + */ + +import type { AskUserAnswers } from '../types/tools'; +import { + TOOL_EDIT, + TOOL_GLOB, + TOOL_GREP, + TOOL_LS, + TOOL_NOTEBOOK_EDIT, + TOOL_READ, + TOOL_WRITE, +} from './toolNames'; + +export function extractResolvedAnswers(toolUseResult: unknown): AskUserAnswers | undefined { + if (typeof toolUseResult !== 'object' || toolUseResult === null) return undefined; + const r = toolUseResult as Record; + return normalizeAnswersObject(r.answers); +} + +function normalizeAnswerValue(value: unknown): string | string[] | undefined { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + const normalized = value + .map((item) => (typeof item === 'string' ? item : String(item))) + .filter(Boolean) + .filter((item) => item.length > 0); + if (normalized.length === 0) return undefined; + return normalized.length === 1 ? normalized[0] : normalized; + } + if (typeof value === 'object' && value !== null) { + const record = value as Record; + if ('answers' in record) return normalizeAnswerValue(record.answers); + if ('answer' in record) return normalizeAnswerValue(record.answer); + if ('value' in record) return normalizeAnswerValue(record.value); + } + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + return undefined; +} + +function normalizeAnswersObject(value: unknown): AskUserAnswers | undefined { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + + const answers: AskUserAnswers = {}; + for (const [question, rawValue] of Object.entries(value as Record)) { + const normalized = normalizeAnswerValue(rawValue); + if (normalized) { + answers[question] = normalized; + } + } + + return Object.keys(answers).length > 0 ? answers : undefined; +} + +function parseAnswersFromJsonObject(resultText: string): AskUserAnswers | undefined { + const start = resultText.indexOf('{'); + const end = resultText.lastIndexOf('}'); + if (start < 0 || end <= start) return undefined; + + try { + const parsed = JSON.parse(resultText.slice(start, end + 1)) as unknown; + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + const record = parsed as Record; + return normalizeAnswersObject(record.answers) ?? normalizeAnswersObject(parsed); + } + return normalizeAnswersObject(parsed); + } catch { + return undefined; + } +} + +function parseAnswersFromQuotedPairs(resultText: string): AskUserAnswers | undefined { + const answers: AskUserAnswers = {}; + const pattern = /"([^"]+)"="([^"]*)"/g; + + for (const match of resultText.matchAll(pattern)) { + const question = match[1]?.trim(); + if (!question) continue; + answers[question] = match[2] ?? ''; + } + + return Object.keys(answers).length > 0 ? answers : undefined; +} + +/** + * Fallback extractor for AskUserQuestion results when structured `toolUseResult.answers` + * is unavailable (for example after reload from JSONL history). + */ +export function extractResolvedAnswersFromResultText(result: unknown): AskUserAnswers | undefined { + if (typeof result !== 'string') return undefined; + const trimmed = result.trim(); + if (!trimmed) return undefined; + + return parseAnswersFromJsonObject(trimmed) ?? parseAnswersFromQuotedPairs(trimmed); +} + +export function getPathFromToolInput( + toolName: string, + toolInput: Record +): string | null { + switch (toolName) { + case TOOL_READ: + case TOOL_WRITE: + case TOOL_EDIT: + case TOOL_NOTEBOOK_EDIT: + return (toolInput.file_path as string) || (toolInput.notebook_path as string) || null; + case TOOL_GLOB: + return (toolInput.path as string) || (toolInput.pattern as string) || null; + case TOOL_GREP: + return (toolInput.path as string) || null; + case TOOL_LS: + return (toolInput.path as string) || null; + default: + return null; + } +} diff --git a/src/core/tools/toolNames.ts b/src/core/tools/toolNames.ts new file mode 100644 index 0000000..d3ca739 --- /dev/null +++ b/src/core/tools/toolNames.ts @@ -0,0 +1,149 @@ +export const TOOL_AGENT_OUTPUT = 'TaskOutput' as const; +export const TOOL_ASK_USER_QUESTION = 'AskUserQuestion' as const; +export const TOOL_BASH = 'Bash' as const; +export const TOOL_BASH_OUTPUT = 'BashOutput' as const; +export const TOOL_EDIT = 'Edit' as const; +export const TOOL_GLOB = 'Glob' as const; +export const TOOL_GREP = 'Grep' as const; +export const TOOL_KILL_SHELL = 'KillShell' as const; +export const TOOL_LS = 'LS' as const; +export const TOOL_LIST_MCP_RESOURCES = 'ListMcpResources' as const; +export const TOOL_MCP = 'Mcp' as const; +export const TOOL_NOTEBOOK_EDIT = 'NotebookEdit' as const; +export const TOOL_READ = 'Read' as const; +export const TOOL_READ_MCP_RESOURCE = 'ReadMcpResource' as const; +export const TOOL_SKILL = 'Skill' as const; +export const TOOL_SUBAGENT = 'Agent' as const; +export const TOOL_SUBAGENT_LEGACY = 'Task' as const; +// Kept as an alias while the internal codebase is still named around "Task". +export const TOOL_TASK = TOOL_SUBAGENT; +export const TOOL_TODO_WRITE = 'TodoWrite' as const; +export const TOOL_TOOL_SEARCH = 'ToolSearch' as const; +export const TOOL_WEB_FETCH = 'WebFetch' as const; +export const TOOL_WEB_SEARCH = 'WebSearch' as const; +export const TOOL_WRITE = 'Write' as const; + +export const TOOL_ENTER_PLAN_MODE = 'EnterPlanMode' as const; +export const TOOL_EXIT_PLAN_MODE = 'ExitPlanMode' as const; + +// Runtime-managed tools exposed through provider adapters. +export const TOOL_APPLY_PATCH = 'apply_patch' as const; +export const TOOL_WRITE_STDIN = 'write_stdin' as const; +export const TOOL_SPAWN_AGENT = 'spawn_agent' as const; +export const TOOL_SEND_INPUT = 'send_input' as const; +export const TOOL_WAIT = 'wait' as const; +export const TOOL_WAIT_AGENT = 'wait_agent' as const; +export const TOOL_RESUME_AGENT = 'resume_agent' as const; +export const TOOL_CLOSE_AGENT = 'close_agent' as const; + +export const AGENT_LIFECYCLE_TOOLS = [ + TOOL_SPAWN_AGENT, + TOOL_SEND_INPUT, + TOOL_WAIT, + TOOL_WAIT_AGENT, + TOOL_RESUME_AGENT, + TOOL_CLOSE_AGENT, +] as const; + +export function isAgentLifecycleTool(name: string): boolean { + return (AGENT_LIFECYCLE_TOOLS as readonly string[]).includes(name); +} + +/** Tools that should be hidden from rendering when a provider subagent block is shown. */ +export const SUBAGENT_HIDDEN_TOOLS = [ + TOOL_WAIT, + TOOL_WAIT_AGENT, + TOOL_CLOSE_AGENT, +] as const; + +export function isSubagentSpawnTool(name: string): boolean { + return name === TOOL_SPAWN_AGENT; +} + +export function isSubagentHiddenTool(name: string): boolean { + return (SUBAGENT_HIDDEN_TOOLS as readonly string[]).includes(name); +} + +// These tools resolve via dedicated callbacks (not content-based), so their +// tool_result should never be marked "blocked" based on result text. +export const TOOLS_SKIP_BLOCKED_DETECTION = [ + TOOL_ENTER_PLAN_MODE, + TOOL_EXIT_PLAN_MODE, + TOOL_ASK_USER_QUESTION, +] as const; + +export const SUBAGENT_TOOL_NAMES = [ + TOOL_SUBAGENT, + TOOL_SUBAGENT_LEGACY, +] as const; +export type SubagentToolName = (typeof SUBAGENT_TOOL_NAMES)[number]; + +export function skipsBlockedDetection(name: string): boolean { + return (TOOLS_SKIP_BLOCKED_DETECTION as readonly string[]).includes(name); +} + +export function isSubagentToolName(name: string): name is SubagentToolName { + return (SUBAGENT_TOOL_NAMES as readonly string[]).includes(name); +} + +export const EDIT_TOOLS = [TOOL_WRITE, TOOL_EDIT, TOOL_NOTEBOOK_EDIT] as const; +export type EditToolName = (typeof EDIT_TOOLS)[number]; + +export const WRITE_EDIT_TOOLS = [TOOL_WRITE, TOOL_EDIT] as const; +export type WriteEditToolName = (typeof WRITE_EDIT_TOOLS)[number]; + +export const BASH_TOOLS = [TOOL_BASH, TOOL_BASH_OUTPUT, TOOL_KILL_SHELL] as const; +export type BashToolName = (typeof BASH_TOOLS)[number]; + +export const FILE_TOOLS = [ + TOOL_READ, + TOOL_WRITE, + TOOL_EDIT, + TOOL_GLOB, + TOOL_GREP, + TOOL_LS, + TOOL_NOTEBOOK_EDIT, + TOOL_BASH, +] as const; +export type FileToolName = (typeof FILE_TOOLS)[number]; + +export const MCP_TOOLS = [ + TOOL_LIST_MCP_RESOURCES, + TOOL_READ_MCP_RESOURCE, + TOOL_MCP, +] as const; +export type McpToolName = (typeof MCP_TOOLS)[number]; + +export const READ_ONLY_TOOLS = [ + TOOL_READ, + TOOL_GREP, + TOOL_GLOB, + TOOL_LS, + TOOL_WEB_SEARCH, + TOOL_WEB_FETCH, +] as const; +export type ReadOnlyToolName = (typeof READ_ONLY_TOOLS)[number]; + +export function isEditTool(toolName: string): toolName is EditToolName { + return (EDIT_TOOLS as readonly string[]).includes(toolName); +} + +export function isWriteEditTool(toolName: string): toolName is WriteEditToolName { + return (WRITE_EDIT_TOOLS as readonly string[]).includes(toolName); +} + +export function isFileTool(toolName: string): toolName is FileToolName { + return (FILE_TOOLS as readonly string[]).includes(toolName); +} + +export function isBashTool(toolName: string): toolName is BashToolName { + return (BASH_TOOLS as readonly string[]).includes(toolName); +} + +export function isMcpTool(toolName: string): toolName is McpToolName { + return (MCP_TOOLS as readonly string[]).includes(toolName); +} + +export function isReadOnlyTool(toolName: string): toolName is ReadOnlyToolName { + return (READ_ONLY_TOOLS as readonly string[]).includes(toolName); +} diff --git a/src/core/tools/toolResultContent.ts b/src/core/tools/toolResultContent.ts new file mode 100644 index 0000000..12f3630 --- /dev/null +++ b/src/core/tools/toolResultContent.ts @@ -0,0 +1,26 @@ +export interface ToolResultContentOptions { + fallbackIndent?: number; +} + +export function extractToolResultContent( + content: unknown, + options?: ToolResultContentOptions, +): string { + if (typeof content === 'string') return content; + if (content == null) return ''; + + if (Array.isArray(content)) { + const textParts = content.filter(isTextBlock).map((block) => block.text); + if (textParts.length > 0) return textParts.join('\n'); + if (content.length > 0) return JSON.stringify(content, null, options?.fallbackIndent); + return ''; + } + + return JSON.stringify(content, null, options?.fallbackIndent); +} + +function isTextBlock(block: unknown): block is { type: 'text'; text: string } { + if (!block || typeof block !== 'object') return false; + const record = block as Record; + return record.type === 'text' && typeof record.text === 'string'; +} diff --git a/src/core/types/agent.ts b/src/core/types/agent.ts new file mode 100644 index 0000000..5c650d3 --- /dev/null +++ b/src/core/types/agent.ts @@ -0,0 +1,28 @@ +export interface AgentDefinition { + id: string; + name: string; + description: string; + prompt: string; + tools?: string[]; + disallowedTools?: string[]; + model?: string; + source: 'plugin' | 'vault' | 'global' | 'builtin'; + pluginName?: string; + filePath?: string; + skills?: string[]; + permissionMode?: string; + hooks?: Record; + extraFrontmatter?: Record; +} + +export interface AgentFrontmatter { + name: string; + description: string; + tools?: string | string[]; + disallowedTools?: string | string[]; + model?: string; + skills?: string[]; + permissionMode?: string; + hooks?: Record; + extraFrontmatter?: Record; +} diff --git a/src/core/types/chat.ts b/src/core/types/chat.ts new file mode 100644 index 0000000..3f3de0b --- /dev/null +++ b/src/core/types/chat.ts @@ -0,0 +1,186 @@ +import type { SDKToolUseResult } from './diff'; +import type { ProviderId } from './provider'; +import type { SubagentMode, ToolCallInfo } from './tools'; + +/** Fork origin reference: identifies the source session and checkpoint. */ +export interface ForkSource { + sessionId: string; + resumeAt: string; +} + +/** View type identifier for Obsidian. */ +export const VIEW_TYPE_CLAUDIAN = 'claudian-view'; + +/** Supported image media types for attachments. */ +export type ImageMediaType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'; + +/** Image attachment metadata. */ +export interface ImageAttachment { + id: string; + name: string; + mediaType: ImageMediaType; + /** Base64 encoded image data - single source of truth. */ + data: string; + width?: number; + height?: number; + size: number; + source: 'file' | 'paste' | 'drop'; +} + +/** Content block for preserving streaming order in messages. */ +export type ContentBlock = + | { type: 'text'; content: string } + | { type: 'tool_use'; toolId: string } + | { type: 'thinking'; content: string; durationSeconds?: number } + | { type: 'subagent'; subagentId: string; mode?: SubagentMode } + | { type: 'context_compacted' }; + +/** Chat message with content, tool calls, and attachments. */ +export interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + /** Display-only content (e.g., "/tests" when content is the expanded prompt). */ + displayContent?: string; + timestamp: number; + toolCalls?: ToolCallInfo[]; + contentBlocks?: ContentBlock[]; + currentNote?: string; + images?: ImageAttachment[]; + /** True if this message represents a user interrupt (from SDK storage). */ + isInterrupt?: boolean; + /** True if this message is rebuilt context sent to SDK on session reset (should be hidden). */ + isRebuiltContext?: boolean; + /** Duration in seconds from user send to response completion. */ + durationSeconds?: number; + /** Flavor word used for duration display (e.g., "Baked", "Cooked"). */ + durationFlavorWord?: string; + /** Provider-native user message identifier used for rewind. */ + userMessageId?: string; + /** Provider-native assistant message identifier used for rewind/fork checkpoints. */ + assistantMessageId?: string; +} + +/** Persisted conversation with messages and session state. */ +export interface Conversation { + id: string; + providerId: ProviderId; + title: string; + createdAt: number; + updatedAt: number; + /** Timestamp when the last agent response completed. */ + lastResponseAt?: number; + sessionId: string | null; + /** Conversation-owned model selection. Missing values are migrated lazily. */ + selectedModel?: string; + /** Opaque provider-owned state bag (session tracking, fork metadata, etc.). */ + providerState?: Record; + messages: ChatMessage[]; + currentNote?: string; + /** Session-specific external context paths (directories with full access). Resets on new session. */ + externalContextPaths?: string[]; + /** Context window usage information. */ + usage?: UsageInfo; + /** Status of AI title generation. */ + titleGenerationStatus?: 'pending' | 'success' | 'failed'; + /** UI-enabled MCP servers for this session (context-saving servers activated via selector). */ + enabledMcpServers?: string[]; + /** Assistant checkpoint identifier for resumeAtMessageId after rewind. */ + resumeAtMessageId?: string; +} + +/** Lightweight conversation metadata for the history dropdown. */ +export interface ConversationMeta { + id: string; + providerId: ProviderId; + title: string; + createdAt: number; + updatedAt: number; + /** Timestamp when the last agent response completed. */ + lastResponseAt?: number; + messageCount: number; + preview: string; + /** Status of AI title generation. */ + titleGenerationStatus?: 'pending' | 'success' | 'failed'; +} + +/** + * Session metadata overlay for provider-native storage. + * The provider handles message storage; this stores UI-only state. + */ +export interface SessionMetadata { + id: string; + providerId?: ProviderId; + title: string; + titleGenerationStatus?: 'pending' | 'success' | 'failed'; + createdAt: number; + updatedAt: number; + lastResponseAt?: number; + /** Session ID used for provider resume (may be cleared when invalidated). */ + sessionId?: string | null; + /** Conversation-owned model selection. */ + selectedModel?: string; + /** Opaque provider-owned state bag. */ + providerState?: Record; + currentNote?: string; + externalContextPaths?: string[]; + enabledMcpServers?: string[]; + usage?: UsageInfo; + /** Assistant checkpoint identifier for resumeAtMessageId after rewind. */ + resumeAtMessageId?: string; +} + +/** + * Normalized stream chunk emitted by the active provider runtime. + * + * All providers must emit: text, tool_use, tool_result, error, done, usage. + * Provider-specific behavior must be normalized before reaching this contract. + * Providers may keep provider-native turn metadata internally and expose it via + * runtime methods instead of encoding it as stream-control chunks. + */ +export type StreamChunk = + | { type: 'user_message_start'; content: string; itemId?: string } + | { type: 'assistant_message_start'; itemId?: string } + | { type: 'text'; content: string } + | { type: 'thinking'; content: string } + | { type: 'tool_use'; id: string; name: string; input: Record } + | { type: 'tool_result'; id: string; content: string; isError?: boolean; toolUseResult?: SDKToolUseResult } + | { type: 'tool_output'; id: string; content: string } + | { + type: 'error'; + content: string; + code?: 'provider_session_missing'; + providerSessionId?: string; + } + | { type: 'notice'; content: string; level?: 'info' | 'warning' } + | { type: 'done' } + | { type: 'usage'; usage: UsageInfo; sessionId?: string | null } + | { type: 'context_compacted' } + | { type: 'async_subagent_result'; agentId: string; status: 'completed' | 'error'; result?: string } + | { type: 'subagent_tool_use'; subagentId: string; id: string; name: string; input: Record } + | { type: 'subagent_tool_result'; subagentId: string; id: string; content: string; isError?: boolean; toolUseResult?: SDKToolUseResult }; + +/** + * Context window usage information. + * + * `contextTokens` is the provider-computed total token count in the context window. + * Claude sets it to `inputTokens + cacheCreationInputTokens + cacheReadInputTokens`; + * other providers should set it to their equivalent total. + * + * Cache token fields are optional — only providers with prompt caching (Claude) + * populate them. Feature code should use `contextTokens` for display, not recompute + * from the cache breakdown. + */ +export interface UsageInfo { + model?: string; + inputTokens: number; + /** Prompt caching: tokens used to create cache entries. Claude-specific; 0 if omitted. */ + cacheCreationInputTokens?: number; + /** Prompt caching: tokens read from cache. Claude-specific; 0 if omitted. */ + cacheReadInputTokens?: number; + contextWindow: number; + /** True when `contextWindow` came from provider runtime data instead of a local heuristic. */ + contextWindowIsAuthoritative?: boolean; + contextTokens: number; + percentage: number; +} diff --git a/src/core/types/diff.ts b/src/core/types/diff.ts new file mode 100644 index 0000000..b425cc9 --- /dev/null +++ b/src/core/types/diff.ts @@ -0,0 +1,31 @@ +/** + * Diff-related type definitions. + */ + +export interface DiffLine { + type: 'equal' | 'insert' | 'delete'; + text: string; + oldLineNum?: number; + newLineNum?: number; +} + +export interface DiffStats { + added: number; + removed: number; +} + +/** A single hunk from the SDK's structuredPatch format. */ +export interface StructuredPatchHunk { + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: string[]; +} + +/** Shape of the SDK's toolUseResult object for Write/Edit tools. */ +export interface SDKToolUseResult { + structuredPatch?: StructuredPatchHunk[]; + filePath?: string; + [key: string]: unknown; +} diff --git a/src/core/types/index.ts b/src/core/types/index.ts new file mode 100644 index 0000000..7d7cea4 --- /dev/null +++ b/src/core/types/index.ts @@ -0,0 +1,78 @@ +// Chat types +export { + type ChatMessage, + type ContentBlock, + type Conversation, + type ConversationMeta, + type ForkSource, + type ImageAttachment, + type ImageMediaType, + type SessionMetadata, + type StreamChunk, + type UsageInfo, + VIEW_TYPE_CLAUDIAN, +} from './chat'; +export { type ProviderId } from './provider'; + +// Settings and command types +export { + type ApprovalDecision, + type ClaudianSettings, + type EnvironmentScope, + type EnvSnippet, + type HostnameCliPaths, + type InstructionRefineResult, + type KeyboardNavigationSettings, + type PermissionMode, + type SlashCommand, +} from './settings'; + +// Diff types +export { + type DiffLine, + type DiffStats, + type SDKToolUseResult, + type StructuredPatchHunk, +} from './diff'; + +// Tool types +export { + type AskUserAnswers, + type AskUserQuestionItem, + type AskUserQuestionOption, + type AsyncSubagentStatus, + type ExitPlanModeCallback, + type ExitPlanModeDecision, + type SubagentInfo, + type SubagentMode, + type ToolCallInfo, + type ToolDiffData, +} from './tools'; + +// Agent types +export { + type AgentDefinition, + type AgentFrontmatter, +} from './agent'; + +// Plugin types +export { + type PluginInfo, + type PluginScope, +} from './plugins'; + +// MCP types +export { + DEFAULT_MCP_SERVER, + getMcpServerType, + isValidMcpServerConfig, + type ManagedMcpConfigFile, + type ManagedMcpServer, + type McpConfigFile, + type McpHttpServerConfig, + type McpServerConfig, + type McpServerType, + type McpSSEServerConfig, + type McpStdioServerConfig, + type ParsedMcpConfig, +} from './mcp'; diff --git a/src/core/types/mcp.ts b/src/core/types/mcp.ts new file mode 100644 index 0000000..b57f942 --- /dev/null +++ b/src/core/types/mcp.ts @@ -0,0 +1,97 @@ +/** MCP (Model Context Protocol) type definitions used by the shared manager/UI. */ + +/** Stdio server configuration (local command-line programs). */ +export interface McpStdioServerConfig { + type?: 'stdio'; + command: string; + args?: string[]; + env?: Record; +} + +/** Server-Sent Events remote server configuration. */ +export interface McpSSEServerConfig { + type: 'sse'; + url: string; + headers?: Record; +} + +/** HTTP remote server configuration. */ +export interface McpHttpServerConfig { + type: 'http'; + url: string; + headers?: Record; +} + +/** Union type for all MCP server configurations. */ +export type McpServerConfig = + | McpStdioServerConfig + | McpSSEServerConfig + | McpHttpServerConfig; + +/** Server type identifier. */ +export type McpServerType = 'stdio' | 'sse' | 'http'; + +/** Managed MCP server configuration with UI/runtime metadata. */ +export interface ManagedMcpServer { + /** Unique server name (key in mcpServers record). */ + name: string; + config: McpServerConfig; + enabled: boolean; + /** Context-saving mode: hide tools unless @-mentioned. */ + contextSaving: boolean; + /** Tool names disabled for this server. */ + disabledTools?: string[]; + description?: string; +} + +/** MCP configuration file format used by the current CLI integrations. */ +export interface McpConfigFile { + mcpServers: Record; +} + +/** Extended config file with app-owned server metadata. */ +export interface ManagedMcpConfigFile extends McpConfigFile { + _claudian?: { + /** Per-server UI/runtime settings. */ + servers: Record< + string, + { + enabled?: boolean; + contextSaving?: boolean; + disabledTools?: string[]; + description?: string; + } + >; + }; +} + +/** Result of parsing clipboard config. */ +export interface ParsedMcpConfig { + servers: Array<{ name: string; config: McpServerConfig }>; + needsName: boolean; +} + +export function getMcpServerType(config: McpServerConfig): McpServerType { + if (config.type === 'sse') return 'sse'; + if (config.type === 'http') return 'http'; + if ('url' in config) return 'http'; // URL without explicit type defaults to http + return 'stdio'; +} + +export function isValidMcpServerConfig(obj: unknown): obj is McpServerConfig { + if (!obj || typeof obj !== 'object') return false; + const config = obj as Record; + + // Check for stdio (command required) + if (config.command && typeof config.command === 'string') return true; + + // Check for sse/http (url required, type is optional - defaults to http) + if (config.url && typeof config.url === 'string') return true; + + return false; +} + +export const DEFAULT_MCP_SERVER: Omit = { + enabled: true, + contextSaving: true, +}; diff --git a/src/core/types/plugins.ts b/src/core/types/plugins.ts new file mode 100644 index 0000000..806a77a --- /dev/null +++ b/src/core/types/plugins.ts @@ -0,0 +1,9 @@ +export type PluginScope = 'user' | 'project'; + +export interface PluginInfo { + id: string; + name: string; + enabled: boolean; + scope: PluginScope; + installPath: string; +} diff --git a/src/core/types/provider.ts b/src/core/types/provider.ts new file mode 100644 index 0000000..42c5c76 --- /dev/null +++ b/src/core/types/provider.ts @@ -0,0 +1 @@ +export type ProviderId = string; diff --git a/src/core/types/settings.ts b/src/core/types/settings.ts new file mode 100644 index 0000000..7fb1cd8 --- /dev/null +++ b/src/core/types/settings.ts @@ -0,0 +1,152 @@ +export type HiddenProviderCommands = Record; + +export interface ApprovalSelectionDecision { + type: 'select-option'; + value: string; +} + +/** User decision from the approval modal. */ +export type ApprovalDecision = + | 'allow' + | 'allow-always' + | 'deny' + | 'cancel' + | ApprovalSelectionDecision; + +/** Saved environment variable configuration. */ +export interface EnvSnippet { + id: string; + name: string; + description: string; + envVars: string; + scope?: EnvironmentScope; + contextLimits?: Record; // Optional: context limits for custom models + modelAliases?: Record; // Optional: display aliases for custom models +} + +/** Source of a slash command. */ +export type SlashCommandSource = 'builtin' | 'user' | 'plugin' | 'sdk'; + +/** Slash command configuration shared by the UI, storage, and runtime boundary. */ +export interface SlashCommand { + id: string; + name: string; // Command name used after / (e.g., "review-code") + description?: string; // Optional description shown in dropdown + argumentHint?: string; // Placeholder text for arguments (e.g., "[file] [focus]") + allowedTools?: string[]; // Restrict tools when command is used + model?: string; // Optional provider-specific model override + content: string; // Prompt template with placeholders + source?: SlashCommandSource; // Origin of the command (builtin, user, plugin, sdk) + kind?: 'command' | 'skill'; // Explicit type — replaces id-prefix heuristic + // Provider-owned command metadata that the UI preserves and round-trips. + disableModelInvocation?: boolean; // Disable model invocation for this skill + userInvocable?: boolean; // Whether user can invoke this skill directly + context?: 'fork'; // Subagent execution mode + agent?: string; // Subagent type when context='fork' + hooks?: Record; // Pass-through to SDK +} + +/** Keyboard navigation settings for vim-style scrolling. */ +export interface KeyboardNavigationSettings { + scrollUpKey: string; // Key to scroll up when focused on messages (default: 'w') + scrollDownKey: string; // Key to scroll down when focused on messages (default: 's') + focusInputKey: string; // Key to focus input (default: 'i', like vim insert mode) +} + +export const CHAT_VIEW_PLACEMENTS = [ + 'right-sidebar', + 'left-sidebar', + 'main-tab', +] as const; + +/** Workspace location used when opening the Claudian chat view. */ +export type ChatViewPlacement = typeof CHAT_VIEW_PLACEMENTS[number]; + +/** Result from instruction refinement agent query. */ +export interface InstructionRefineResult { + success: boolean; + refinedInstruction?: string; // The refined instruction text + clarification?: string; // Agent's clarifying question (if any) + error?: string; // Error message (if failed) +} + +/** Permission mode for tool execution. */ +export type PermissionMode = 'yolo' | 'plan' | 'normal'; + +/** Scope for environment variable storage and snippets. */ +export type EnvironmentScope = 'shared' | `provider:${string}`; + +/** Opaque device-keyed CLI paths for per-device configuration. */ +export type HostnameCliPaths = Record; + +/** Opaque provider-owned settings bags keyed by provider id. */ +export type ProviderConfigMap = Partial>>; + +/** + * Application settings stored in .claudian/claudian-settings.json. + * + * Provider-specific fields (model, thinkingBudget, effortLevel, serviceTier, etc.) use + * `string` here. The active provider casts internally when it needs + * narrower types. + */ +export interface ClaudianSettings { + // User preferences + userName: string; + + // Security + permissionMode: PermissionMode; + + // Model & thinking (provider interprets values) + model: string; + thinkingBudget: string; + effortLevel: string; + serviceTier: string; + enableAutoTitleGeneration: boolean; + titleGenerationModel: string; + + // Content settings + excludedTags: string[]; + mediaFolder: string; + systemPrompt: string; + persistentExternalContextPaths: string[]; + + // Environment + sharedEnvironmentVariables: string; + envSnippets: EnvSnippet[]; + customContextLimits: Record; + customModelAliases: Record; + + // UI settings + keyboardNavigation: KeyboardNavigationSettings; + requireCommandOrControlEnterToSend: boolean; + + // Internationalization + locale: string; + + // Provider-owned settings + providerConfigs: ProviderConfigMap; + + // Provider selection + settingsProvider: string; // ProviderId — which provider's model/effort/budget is projected to top-level fields + savedProviderModel: Partial>; + savedProviderEffort: Partial>; + savedProviderServiceTier: Partial>; + savedProviderThinkingBudget: Partial>; + savedProviderPermissionMode: Partial>; + + // State (provider-specific, round-tripped opaquely) + lastCustomModel?: string; + + // UI preferences + maxTabs: number; + enableAutoScroll: boolean; + deferMathRenderingDuringStreaming: boolean; + expandFileEditsByDefault: boolean; + chatViewPlacement: ChatViewPlacement; + + // Provider command visibility + hiddenProviderCommands: HiddenProviderCommands; + + // Allow provider-specific extension fields + [key: string]: unknown; +} diff --git a/src/core/types/tools.ts b/src/core/types/tools.ts new file mode 100644 index 0000000..e3ebb41 --- /dev/null +++ b/src/core/types/tools.ts @@ -0,0 +1,80 @@ +import type { DiffLine, DiffStats } from './diff'; + +/** Diff data for Write/Edit tool operations (pre-computed from SDK structuredPatch). */ +export interface ToolDiffData { + filePath: string; + diffLines: DiffLine[]; + stats: DiffStats; +} + +/** Parsed option for AskUserQuestion tool. */ +export interface AskUserQuestionOption { + label: string; + description: string; + value?: string; +} + +/** Parsed question for AskUserQuestion tool. */ +export interface AskUserQuestionItem { + question: string; + id?: string; + header: string; + options: AskUserQuestionOption[]; + multiSelect: boolean; + isOther?: boolean; + isSecret?: boolean; +} + +/** User-provided answers keyed by question text or stable question id. */ +export type AskUserAnswers = Record; + +/** Tool call tracking with status and result. */ +export interface ToolCallInfo { + id: string; + name: string; + input: Record; + status: 'running' | 'completed' | 'error' | 'blocked'; + result?: string; + isExpanded?: boolean; + diffData?: ToolDiffData; + resolvedAnswers?: AskUserAnswers; + subagent?: SubagentInfo; +} + +export type ExitPlanModeDecision = + | { type: 'approve' } + | { type: 'approve-new-session'; planContent: string } + | { type: 'feedback'; text: string }; + +export type ExitPlanModeCallback = ( + input: Record, + signal?: AbortSignal, +) => Promise; + +/** Subagent execution mode: sync (nested tools) or async (background). */ +export type SubagentMode = 'sync' | 'async'; + +/** Async subagent lifecycle states. */ +export type AsyncSubagentStatus = + | 'pending' + | 'running' + | 'completed' + | 'error' + | 'orphaned'; + +/** Subagent (Agent tool, legacy Task) tracking for sync and async modes. */ +export interface SubagentInfo { + id: string; + description: string; + prompt?: string; + mode?: SubagentMode; + isExpanded: boolean; + result?: string; + status: 'running' | 'completed' | 'error'; + toolCalls: ToolCallInfo[]; + asyncStatus?: AsyncSubagentStatus; + agentId?: string; + outputToolId?: string; + startedAt?: number; + completedAt?: number; +} diff --git a/src/features/FeatureHost.ts b/src/features/FeatureHost.ts new file mode 100644 index 0000000..7d89ef6 --- /dev/null +++ b/src/features/FeatureHost.ts @@ -0,0 +1,64 @@ +import type { App } from 'obsidian'; + +import type { SharedAppStorage } from '../core/bootstrap/storage'; +import type { ProviderHost } from '../core/providers/ProviderHost'; +import type { AppTabManagerState, ProviderId } from '../core/providers/types'; +import type { ChatRuntime } from '../core/runtime/ChatRuntime'; +import type { ClaudianSettings, Conversation, ConversationMeta } from '../core/types'; +import type { TabData, TabId, TabManagerViewHost } from './chat/tabs/types'; + +export interface FeatureTabManagerHost { + getAllTabs(): TabData[]; + getTab(tabId: TabId): TabData | null; + switchToTab(tabId: TabId): Promise; + broadcastToAllTabs(action: (runtime: ChatRuntime) => Promise): Promise; + recycleProviderRuntimes(providerIds: ProviderId | ProviderId[]): Promise; +} + +export interface FeatureViewHost extends TabManagerViewHost { + getActiveTab(): TabData | null; + getTabManager(): FeatureTabManagerHost | null; + refreshModelSelector(): void; + refreshTabControls(): void; + updateHiddenProviderCommands(): void; +} + +/** Application capabilities consumed by user-facing features. */ +export interface FeatureHost { + readonly app: App; + readonly providerHost: ProviderHost; + readonly settings: ClaudianSettings; + readonly storage: SharedAppStorage; + + mutateSettings( + mutation: (settings: ClaudianSettings) => void | Promise, + ): Promise; + getActiveEnvironmentVariables(providerId?: ProviderId): string; + + createConversation(options?: { + providerId?: ProviderId; + sessionId?: string; + selectedModel?: string; + }): Promise; + switchConversation(id: string): Promise; + deleteConversation( + id: string, + options?: { deleteProviderSession?: boolean }, + ): Promise; + handleMissingProviderSession( + id: string, + missingProviderSessionId?: string, + ): Promise<'deleted' | 'reset' | 'preserved' | 'not_found'>; + renameConversation(id: string, title: string): Promise; + updateConversation(id: string, updates: Partial): Promise; + getConversationById(id: string): Promise; + getConversationSync(id: string): Conversation | null; + getConversationList(): ConversationMeta[]; + + persistTabManagerState(state: AppTabManagerState): Promise; + getView(): FeatureViewHost | null; + getAllViews(): FeatureViewHost[]; + findConversationAcrossViews( + conversationId: string, + ): { view: FeatureViewHost; tabId: TabId } | null; +} diff --git a/src/features/chat/AGENTS.md b/src/features/chat/AGENTS.md new file mode 100644 index 0000000..65f2e64 --- /dev/null +++ b/src/features/chat/AGENTS.md @@ -0,0 +1,53 @@ +# Chat Feature + +`src/features/chat/` owns the main sidebar chat interface. It assembles tabs, controllers, renderers, and provider-backed services around the shared `ChatRuntime` contract. + +## Provider Boundary + +- Feature code depends on `ChatRuntime`, `ProviderCapabilities`, provider-neutral `Conversation`, and provider-neutral `StreamChunk` values. +- `InputController` builds `ChatTurnRequest`; providers own prompt encoding through `prepareTurn()`. +- Do not read provider-specific fields from `Conversation.providerState` in feature code. Use runtime methods, provider history services, or typed provider helpers. +- Resolve provider-owned services through registries: + - `ProviderRegistry`: runtime, title generation, instruction refinement, inline edit, task-result interpretation. + - `ProviderWorkspaceRegistry`: command catalogs, agent mentions, MCP managers, CLI resolution, settings tabs. + +## State Flow + +```text +User input + -> InputController + -> ensure runtime for active provider + -> ChatRuntime.prepareTurn() + -> ChatRuntime.query() + -> StreamController + -> renderers + ChatState persistence +``` + +Tabs stay cold until the first send. Keep runtime warmup explicit and provider-owned so command discovery does not accidentally create real sessions for history-backed conversations. + +## Main Parts + +| Area | Owns | +| --- | --- | +| `ClaudianView` | Lifecycle, assembly, active-tab orchestration | +| `ChatState` | Per-tab state and persistence inputs | +| Controllers | Conversation, stream, input, selection, browser/canvas selection, navigation | +| Renderers | Messages, tools, thinking, diffs, todos, subagents, plan approval, ask-user UI | +| Tabs | Tab manager, tab bar, tab state | +| UI components | Input toolbar, context managers, status panel, navigation sidebar, mode managers | + +## Gotchas + +- `ClaudianView.onClose()` must abort active tabs and dispose runtimes. +- `ChatState` is per-tab. `TabManager` coordinates tab-level operations such as forks and provider-aware command catalogs. +- Title generation runs concurrently per conversation and routes by the global title-generation model, not the active chat tab provider. +- `/compact` is provider-specific: + - Claude skips context injection so the provider handles the built-in command. + - Codex routes compact turns to `thread/compact/start` and persists `context_compacted`. + - Pi sends a `compact` RPC request. +- Plan mode is provider-specific: + - Claude uses provider/runtime events for enter and exit. + - Codex uses `collaborationMode` plus post-stream metadata. + - OpenCode maps managed modes to shared permission modes. +- Bang-bash mode bypasses provider runtimes and executes a local shell command directly. It is available only when the enabled provider exposes it in `ProviderChatUIConfig`. +- Forking is provider-owned under the hood. Use runtime and provider history contracts instead of reconstructing provider session IDs in feature code. diff --git a/src/features/chat/CLAUDE.md b/src/features/chat/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/src/features/chat/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/features/chat/ClaudianView.ts b/src/features/chat/ClaudianView.ts new file mode 100644 index 0000000..de13f45 --- /dev/null +++ b/src/features/chat/ClaudianView.ts @@ -0,0 +1,813 @@ +import type { EventRef, WorkspaceLeaf } from 'obsidian'; +import { ItemView, Notice, Scope, setIcon } from 'obsidian'; + +import { getHiddenProviderCommandSet } from '../../core/providers/commands/hiddenCommands'; +import { + getProviderSettingsSnapshotWithModel, + resolveConversationModel, +} from '../../core/providers/conversationModel'; +import { ProviderRegistry } from '../../core/providers/ProviderRegistry'; +import { ProviderSettingsCoordinator } from '../../core/providers/ProviderSettingsCoordinator'; +import { type AppTabManagerState, DEFAULT_CHAT_PROVIDER_ID, type ProviderId } from '../../core/providers/types'; +import { VIEW_TYPE_CLAUDIAN } from '../../core/types'; +import { createProviderIconSvg } from '../../shared/icons'; +import { + cancelScheduledAnimationFrame, + scheduleAnimationFrame, + type ScheduledAnimationFrame, +} from '../../utils/animationFrame'; +import type { FeatureHost } from '../FeatureHost'; +import type { HistoryConversationStatus } from './controllers/ConversationController'; +import { MentionCacheCoordinator } from './services/MentionCacheCoordinator'; +import { + getTabProviderId, + onProviderAvailabilityChanged, + sendTabInputMessageFromExplicitEnterShortcut, + updatePlanModeUI, +} from './tabs/Tab'; +import { TabBar } from './tabs/TabBar'; +import { TabManager } from './tabs/TabManager'; +import type { TabData, TabId } from './tabs/types'; +import { recalculateUsageForModel } from './utils/usageInfo'; + +type LoadableView = { + containerEl?: HTMLElement; + load: () => Promise | void; +}; + +export class ClaudianView extends ItemView { + private plugin: FeatureHost; + + // Tab management + private tabManager: TabManager | null = null; + private mentionCacheCoordinator: MentionCacheCoordinator | null = null; + private tabBar: TabBar | null = null; + private tabBarContainerEl: HTMLElement | null = null; + private tabContentEl: HTMLElement | null = null; + private navRowContent: HTMLElement | null = null; + private inputFooterEl: HTMLElement | null = null; + private inputNavRowHostEl: HTMLElement | null = null; + private activeInputSlotEl: HTMLElement | null = null; + private activeInputTabId: TabId | null = null; + + // DOM Elements + private viewContainerEl: HTMLElement | null = null; + private logoEl: HTMLElement | null = null; + private newTabButtonEl: HTMLElement | null = null; + + // Header elements + private historyDropdown: HTMLElement | null = null; + + // Event refs for cleanup + private eventRefs: EventRef[] = []; + + // Debouncing for tab bar updates + private pendingTabBarUpdate: ScheduledAnimationFrame | null = null; + + // Debouncing for tab state persistence + private pendingPersist: number | null = null; + + constructor(leaf: WorkspaceLeaf, plugin: FeatureHost) { + super(leaf); + this.plugin = plugin; + + // Hover Editor compatibility: Define load as an instance method that can't be + // overwritten by prototype patching. Hover Editor patches ClaudianView.prototype.load + // after our class is defined, but instance methods take precedence over prototype methods. + const prototype = Object.getPrototypeOf(this) as LoadableView; + const originalLoad = prototype.load.bind(this); + Object.defineProperty(this, 'load', { + value: async () => { + // Ensure containerEl exists before any patched load code tries to use it + if (!this.containerEl) { + (this as LoadableView).containerEl = createDiv({ cls: 'view-content' }); + } + // Wrap in try-catch to prevent Hover Editor errors from breaking our view + try { + return await originalLoad(); + } catch { + // Hover Editor may throw if its DOM setup fails - continue anyway + } + }, + writable: false, + configurable: false, + }); + } + + getViewType(): string { + return VIEW_TYPE_CLAUDIAN; + } + + getDisplayText(): string { + return 'Claudian'; + } + + getIcon(): string { + return 'bot'; + } + + /** Refreshes model-dependent UI across all tabs (used after settings/env changes). */ + refreshModelSelector(): void { + for (const tab of this.tabManager?.getAllTabs() ?? []) { + onProviderAvailabilityChanged(tab, this.plugin); + const providerId = getTabProviderId(tab, this.plugin); + const conversation = tab.conversationId + ? this.plugin.getConversationSync(tab.conversationId) + : null; + const modelOverride = conversation + ? resolveConversationModel(this.plugin.settings, providerId, conversation).model + : tab.lifecycleState === 'blank' + ? tab.draftModel + : tab.service?.getAuxiliaryModel?.() ?? null; + const providerSettings = getProviderSettingsSnapshotWithModel( + this.plugin.settings, + providerId, + modelOverride, + ); + const model = providerSettings.model; + const uiConfig = ProviderRegistry.getChatUIConfig(providerId); + const capabilities = ProviderRegistry.getCapabilities(providerId); + const contextWindow = uiConfig.getContextWindowSize( + model, + providerSettings.customContextLimits, + providerSettings, + ); + + if (tab.state.usage) { + tab.state.usage = recalculateUsageForModel(tab.state.usage, model, contextWindow); + } + + tab.ui.modelSelector?.updateDisplay(); + tab.ui.modelSelector?.renderOptions(); + tab.ui.modeSelector?.updateDisplay(); + tab.ui.modeSelector?.renderOptions(); + tab.ui.thinkingBudgetSelector?.updateDisplay(); + tab.ui.permissionToggle?.updateDisplay(); + tab.ui.serviceTierToggle?.updateDisplay(); + tab.dom.inputWrapper.toggleClass( + 'claudian-input-plan-mode', + providerSettings.permissionMode === 'plan' && capabilities.supportsPlanMode, + ); + } + + this.tabManager?.primeProviderRuntime(); + } + + invalidateProviderCommandCaches(providerIds?: ProviderId[]): void { + this.tabManager?.invalidateProviderCommandCaches(providerIds); + } + + /** Updates provider-scoped hidden commands on all tabs after settings changes. */ + updateHiddenProviderCommands(): void { + for (const tab of this.tabManager?.getAllTabs() ?? []) { + tab.ui.slashCommandDropdown?.setHiddenCommands( + getHiddenProviderCommandSet(this.plugin.settings, getTabProviderId(tab, this.plugin)), + ); + } + } + + async onOpen() { + // Guard: Hover Editor and similar plugins may call onOpen before DOM is ready. + // containerEl must exist before we can access contentEl or create elements. + if (!this.containerEl) { + return; + } + + // Use contentEl (standard Obsidian API) as primary target. + // Hover Editor and other plugins may modify the DOM structure, + // so we need fallbacks to handle non-standard scenarios. + let container: HTMLElement | null = + this.contentEl ?? (this.containerEl.children[1] as HTMLElement | null); + + if (!container) { + // Last resort: create our own container inside containerEl + container = this.containerEl.createDiv(); + } + + this.viewContainerEl = container; + this.viewContainerEl.empty(); + this.viewContainerEl.addClass('claudian-container'); + + const header = this.viewContainerEl.createDiv({ cls: 'claudian-header' }); + this.buildHeader(header); + + this.navRowContent = this.buildNavRowContent(); + this.tabContentEl = this.viewContainerEl.createDiv({ cls: 'claudian-tab-content-container' }); + this.buildInputFooter(); + + this.tabManager = new TabManager( + this.plugin, + this.tabContentEl, + this, + { + onTabCreated: () => { + this.updateTabBar(); + this.updateHistoryDropdown(); + this.updateInputLocation(); + this.persistTabState(); + this.syncProviderBrandColor(); + }, + onActiveTabChanged: () => { + this.updateTabBar(); + this.updateHistoryDropdown(); + this.updateInputLocation(); + this.syncProviderBrandColor(); + }, + onTabSwitched: () => { + this.updateTabBar(); + this.updateHistoryDropdown(); + this.updateInputLocation(); + this.persistTabState(); + this.syncProviderBrandColor(); + }, + onTabClosed: () => { + this.updateTabBar(); + this.updateHistoryDropdown(); + this.updateInputLocation(); + this.persistTabState(); + }, + onTabStreamingChanged: () => { + this.updateTabBar(); + this.updateHistoryDropdown(); + }, + onTabTitleChanged: () => this.updateTabBar(), + onTabAttentionChanged: () => this.updateTabBar(), + onTabConversationChanged: () => { + this.updateTabBar(); + this.updateHistoryDropdown(); + this.persistTabState(); + this.syncProviderBrandColor(); + }, + onTabProviderChanged: () => { + this.updateTabBar(); + this.syncProviderBrandColor(); + }, + } + ); + this.mentionCacheCoordinator = new MentionCacheCoordinator( + () => (this.tabManager?.getAllTabs() ?? []).map(tab => ({ + fileContextManager: tab.ui.fileContextManager, + })), + ); + + this.wireEventHandlers(); + await this.restoreOrCreateTabs(); + this.syncProviderBrandColor(); + this.attachNavRowContentToInputFooter(); + this.updateInputLocation(); + this.updateTabBarVisibility(); + this.tabManager?.primeProviderRuntime(); + } + + async onClose() { + if (this.pendingTabBarUpdate !== null) { + cancelScheduledAnimationFrame(this.pendingTabBarUpdate); + this.pendingTabBarUpdate = null; + } + + for (const ref of this.eventRefs) { + this.plugin.app.vault.offref(ref); + } + this.eventRefs = []; + + await this.persistTabStateImmediate(); + + this.restoreActiveInputToTabContent(); + await this.tabManager?.destroy(); + this.tabManager = null; + this.mentionCacheCoordinator = null; + + this.tabBar?.destroy(); + this.tabBar = null; + this.scope = null; + } + + // ============================================ + // UI Building + // ============================================ + + private buildHeader(header: HTMLElement): void { + const titleEl = header.createDiv({ cls: 'claudian-title' }); + + this.logoEl = titleEl.createSpan({ cls: 'claudian-logo' }); + this.syncHeaderLogo(DEFAULT_CHAT_PROVIDER_ID); + + titleEl.createEl('h4', { text: 'Claudian', cls: 'claudian-title-text' }); + } + + /** + * Builds the active tab nav row content. + * The wrapper is moved to the active tab's nav row on tab switches. + */ + private buildNavRowContent(): HTMLElement { + const activeDocument = this.containerEl.ownerDocument; + + const fragment = activeDocument.createDocumentFragment(); + + this.tabBarContainerEl = activeDocument.createElement('div'); + this.tabBarContainerEl.className = 'claudian-tab-bar-container'; + this.tabBar = new TabBar(this.tabBarContainerEl, { + onTabClick: (tabId) => this.handleTabClick(tabId), + onTabClose: (tabId) => { + void this.handleTabClose(tabId); + }, + onNewTab: () => { + void this.createNewTab().catch(() => new Notice('Failed to create tab')); + }, + onTitleExpansionChanged: () => this.persistTabState(), + }); + fragment.appendChild(this.tabBarContainerEl); + + const navActionsEl = activeDocument.createElement('div'); + navActionsEl.className = 'claudian-input-nav-actions'; + + this.newTabButtonEl = navActionsEl.createDiv({ cls: 'claudian-input-nav-btn claudian-new-tab-btn' }); + setIcon(this.newTabButtonEl, 'square-plus'); + this.newTabButtonEl.setAttribute('aria-label', 'New tab'); + this.newTabButtonEl.addEventListener('click', () => { + void this.createNewTab().catch(() => new Notice('Failed to create tab')); + }); + + const newBtn = navActionsEl.createDiv({ cls: 'claudian-input-nav-btn' }); + setIcon(newBtn, 'square-pen'); + newBtn.setAttribute('aria-label', 'New conversation'); + newBtn.addEventListener('click', () => { + void (async () => { + await this.tabManager?.createNewConversation(); + this.updateHistoryDropdown(); + })().catch(() => new Notice('Failed to create conversation')); + }); + + // History dropdown + const historyContainer = navActionsEl.createDiv({ cls: 'claudian-history-container' }); + const historyBtn = historyContainer.createDiv({ cls: 'claudian-input-nav-btn' }); + setIcon(historyBtn, 'history'); + historyBtn.setAttribute('aria-label', 'Chat history'); + + this.historyDropdown = historyContainer.createDiv({ cls: 'claudian-history-menu' }); + + historyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.toggleHistoryDropdown(); + }); + + fragment.appendChild(navActionsEl); + + const wrapper = activeDocument.createElement('div'); + wrapper.className = 'claudian-input-nav-content'; + wrapper.appendChild(fragment); + return wrapper; + } + + private buildInputFooter(): void { + if (!this.viewContainerEl) return; + + this.inputFooterEl = this.viewContainerEl.createDiv({ cls: 'claudian-input-footer' }); + this.inputNavRowHostEl = this.inputFooterEl.createDiv({ + cls: 'claudian-input-nav-row claudian-view-input-nav-row', + }); + this.activeInputSlotEl = this.inputFooterEl.createDiv({ cls: 'claudian-active-input-slot' }); + } + + private attachNavRowContentToInputFooter(): void { + if (!this.inputNavRowHostEl || !this.navRowContent) return; + + this.tabBar?.captureScrollPosition(); + this.inputNavRowHostEl.appendChild(this.navRowContent); + this.tabBar?.restoreScrollPosition(); + } + + private updateInputLocation(): void { + const activeTab = this.tabManager?.getActiveTab(); + if (!this.activeInputSlotEl) return; + + if (!activeTab) { + this.activeInputSlotEl.empty(); + this.activeInputTabId = null; + return; + } + + if (this.activeInputTabId && this.activeInputTabId !== activeTab.id) { + const previousTab = this.tabManager?.getTab(this.activeInputTabId); + if (previousTab) { + previousTab.dom.contentEl.appendChild(previousTab.dom.inputComposerEl); + } + } + + if (this.activeInputTabId === activeTab.id) { + if (activeTab.dom.inputComposerEl.parentElement !== this.activeInputSlotEl) { + this.activeInputSlotEl.appendChild(activeTab.dom.inputComposerEl); + } + return; + } + + this.activeInputSlotEl.empty(); + this.activeInputSlotEl.appendChild(activeTab.dom.inputComposerEl); + this.activeInputTabId = activeTab.id; + } + + private restoreActiveInputToTabContent(): void { + if (!this.activeInputTabId) return; + + const activeInputTab = this.tabManager?.getTab(this.activeInputTabId); + if (activeInputTab) { + activeInputTab.dom.contentEl.appendChild(activeInputTab.dom.inputComposerEl); + } + this.activeInputSlotEl?.empty(); + this.activeInputTabId = null; + } + + /** Refreshes tab controls after settings that affect tab availability change. */ + refreshTabControls(): void { + this.updateTabBarVisibility(); + } + + // ============================================ + // Tab Management + // ============================================ + + private handleTabClick(tabId: TabId): void { + const switched = this.tabManager?.switchToTab(tabId); + if (switched) { + void switched.catch(() => new Notice('Failed to switch tab')); + } + } + + private async handleTabClose(tabId: TabId): Promise { + try { + const tab = this.tabManager?.getTab(tabId); + // If streaming, treat close like user interrupt (force close cancels the stream) + const force = tab?.state.isStreaming ?? false; + await this.tabManager?.closeTab(tabId, force); + this.updateTabBarVisibility(); + } catch { + new Notice('Failed to close tab'); + } + } + + async createNewTab(): Promise { + const tab = await this.tabManager?.createTab(); + if (!tab) { + const maxTabs = this.plugin.settings.maxTabs ?? 3; + new Notice(`Maximum ${maxTabs} tabs allowed`); + this.updateTabBarVisibility(); + return; + } + this.updateTabBarVisibility(); + } + + private updateTabBar(): void { + if (!this.tabManager || !this.tabBar) return; + + // Debounce tab bar updates using requestAnimationFrame + if (this.pendingTabBarUpdate !== null) { + cancelScheduledAnimationFrame(this.pendingTabBarUpdate); + } + + this.pendingTabBarUpdate = scheduleAnimationFrame(() => { + this.pendingTabBarUpdate = null; + if (!this.tabManager || !this.tabBar) return; + + const items = this.tabManager.getTabBarItems(); + this.tabBar.update(items); + this.updateTabBarVisibility(); + }, this.containerEl.ownerDocument.defaultView ?? null); + } + + private updateTabBarVisibility(): void { + if (!this.tabBarContainerEl || !this.tabManager) return; + + const tabCount = this.tabManager.getTabCount(); + const showTabBar = tabCount >= 2; + + this.tabBarContainerEl.toggleClass('claudian-hidden', !showTabBar); + + this.updateNewTabButtonVisibility(); + } + + private updateNewTabButtonVisibility(): void { + if (!this.newTabButtonEl || !this.tabManager) return; + + const canCreateTab = this.tabManager.canCreateTab(); + this.newTabButtonEl.toggleClass('claudian-hidden', !canCreateTab); + if (canCreateTab) { + this.newTabButtonEl.removeAttribute('aria-disabled'); + this.newTabButtonEl.removeAttribute('aria-hidden'); + return; + } + + this.newTabButtonEl.setAttribute('aria-disabled', 'true'); + this.newTabButtonEl.setAttribute('aria-hidden', 'true'); + } + + /** Sets `data-provider` on the root container so CSS brand color follows the active provider. */ + private syncProviderBrandColor(): void { + if (!this.viewContainerEl) return; + const activeTab = this.tabManager?.getActiveTab(); + const providerId = activeTab ? getTabProviderId(activeTab, this.plugin) : DEFAULT_CHAT_PROVIDER_ID; + this.viewContainerEl.dataset.provider = providerId; + this.syncHeaderLogo(providerId); + } + + /** Rebuilds the header logo SVG to match the given provider. */ + private syncHeaderLogo(providerId: ProviderId): void { + if (!this.logoEl) return; + const icon = ProviderRegistry.getChatUIConfig(providerId).getProviderIcon?.(); + if (!icon) return; + const existing = this.logoEl.querySelector('svg'); + if (existing?.getAttribute('data-provider') === providerId) return; + this.logoEl.empty(); + const svg = createProviderIconSvg(icon, { + dataProvider: providerId, + height: 18, + ownerDocument: this.logoEl.ownerDocument, + width: 18, + }); + this.logoEl.appendChild(svg); + } + + // ============================================ + // History Dropdown + // ============================================ + + private toggleHistoryDropdown(): void { + if (!this.historyDropdown) return; + + const isVisible = this.historyDropdown.hasClass('visible'); + if (isVisible) { + this.historyDropdown.removeClass('visible'); + } else { + this.updateHistoryDropdown(); + this.historyDropdown.addClass('visible'); + } + } + + private updateHistoryDropdown(): void { + if (!this.historyDropdown) return; + this.historyDropdown.empty(); + + const activeTab = this.tabManager?.getActiveTab(); + const conversationController = activeTab?.controllers.conversationController; + + if (conversationController) { + conversationController.renderHistoryDropdown(this.historyDropdown, { + onSelectConversation: (id) => this.openHistoryConversation(id), + onOpenConversationInNewTab: (id, activate) => + this.openHistoryConversationInNewTab(id, activate), + getConversationStatus: (id) => this.getHistoryConversationStatus(id), + }); + } + } + + private async openHistoryConversation(conversationId: string): Promise { + await this.tabManager?.openConversation(conversationId); + this.historyDropdown?.removeClass('visible'); + } + + private async openHistoryConversationInNewTab( + conversationId: string, + activate = true, + ): Promise { + await this.tabManager?.openConversation(conversationId, { + preferNewTab: true, + activate, + }); + this.historyDropdown?.removeClass('visible'); + } + + private getHistoryConversationStatus(conversationId: string): HistoryConversationStatus { + const activeTab = this.tabManager?.getActiveTab(); + if (activeTab?.conversationId === conversationId) { + return { + openState: 'current', + isRunning: activeTab.state.isStreaming, + location: 'current-view', + tabIndex: this.getHistoryTabIndex(activeTab), + }; + } + + const localTab = this.findTabWithConversation(conversationId); + if (localTab) { + return { + openState: 'open', + isRunning: localTab.state.isStreaming, + location: 'current-view', + tabIndex: this.getHistoryTabIndex(localTab), + }; + } + + const crossViewResult = this.plugin.findConversationAcrossViews(conversationId); + if (crossViewResult && crossViewResult.view !== this) { + const crossViewTab = crossViewResult.view.getTabManager()?.getTab(crossViewResult.tabId); + return { + openState: 'open', + isRunning: crossViewTab?.state.isStreaming ?? false, + location: 'other-view', + }; + } + + return { + openState: 'closed', + isRunning: false, + location: 'current-view', + }; + } + + private findTabWithConversation(conversationId: string): TabData | null { + const tabs = this.tabManager?.getAllTabs() ?? []; + return tabs.find(tab => tab.conversationId === conversationId) ?? null; + } + + private getHistoryTabIndex(tab: TabData): number | undefined { + const index = this.tabManager?.getAllTabs().findIndex(candidate => candidate.id === tab.id) ?? -1; + return index >= 0 ? index + 1 : undefined; + } + + // ============================================ + // Event Wiring + // ============================================ + + private wireEventHandlers(): void { + const activeDocument = this.containerEl.ownerDocument; + + // Document-level click to close dropdowns + this.registerDomEvent(activeDocument, 'click', () => { + this.historyDropdown?.removeClass('visible'); + }); + + // View-level Shift+Tab to toggle plan mode (works from any focused element) + this.registerDomEvent(this.containerEl, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'Tab' && e.shiftKey && !e.isComposing) { + e.preventDefault(); + const activeTab = this.tabManager?.getActiveTab(); + if (!activeTab) return; + const providerId = getTabProviderId(activeTab, this.plugin); + if (!ProviderRegistry.getCapabilities(providerId).supportsPlanMode) return; + const current = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + this.plugin.settings, + providerId, + ).permissionMode as string; + if (current === 'plan') { + const restoreMode = activeTab.state.prePlanPermissionMode ?? 'normal'; + void updatePlanModeUI(activeTab, this.plugin, restoreMode) + .finally(() => { + const activeMode = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + this.plugin.settings, + providerId, + ).permissionMode; + if (activeMode !== 'plan') { + activeTab.state.prePlanPermissionMode = null; + } + }) + .catch((error: unknown) => { + new Notice(error instanceof Error ? error.message : 'Failed to change permission mode.'); + }); + } else { + activeTab.state.prePlanPermissionMode = current; + void updatePlanModeUI(activeTab, this.plugin, 'plan').catch((error: unknown) => { + const activeMode = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + this.plugin.settings, + providerId, + ).permissionMode; + if (activeMode !== 'plan') { + activeTab.state.prePlanPermissionMode = null; + } + new Notice(error instanceof Error ? error.message : 'Failed to change permission mode.'); + }); + } + } + }); + + // View scopes are the Obsidian-owned boundary for main-area tab hotkeys. + // Returning false consumes Escape before Obsidian uses it for pane navigation. + this.scope = new Scope(this.app.scope); + this.scope.register([], 'Escape', (e: KeyboardEvent) => { + if (e.isComposing) return; + if (!e.defaultPrevented) { + const activeTab = this.tabManager?.getActiveTab(); + if (activeTab?.state.isStreaming) { + activeTab.controllers.inputController?.cancelStreaming(); + } + } + return false; + }); + this.scope.register(['Mod'], 'Enter', (e: KeyboardEvent) => { + if (e.isComposing || e.defaultPrevented) return; + const activeTab = this.tabManager?.getActiveTab(); + if (!activeTab) return; + if (sendTabInputMessageFromExplicitEnterShortcut(activeTab, e, { requireInputFocus: true })) { + return false; + } + }); + + this.eventRefs.push( + this.plugin.app.vault.on('create', () => this.mentionCacheCoordinator?.markStructureDirty()), + this.plugin.app.vault.on('delete', () => this.mentionCacheCoordinator?.markStructureDirty()), + this.plugin.app.vault.on('rename', () => this.mentionCacheCoordinator?.markStructureDirty()), + this.plugin.app.vault.on('modify', () => this.mentionCacheCoordinator?.markFilesDirty()) + ); + + // File open event + this.registerEvent( + this.plugin.app.workspace.on('file-open', (file) => { + if (file) { + this.tabManager?.getActiveTab()?.ui.fileContextManager?.handleFileOpen(file); + } + }) + ); + + // Click outside to close mention dropdown + this.registerDomEvent(activeDocument, 'click', (e) => { + const activeTab = this.tabManager?.getActiveTab(); + if (activeTab) { + const fcm = activeTab.ui.fileContextManager; + if (fcm && !fcm.containsElement(e.target as Node) && e.target !== activeTab.dom.inputEl) { + fcm.hideMentionDropdown(); + } + } + }); + } + + // ============================================ + // Persistence + // ============================================ + + private async restoreOrCreateTabs(): Promise { + if (!this.tabManager) return; + + // Try to restore from persisted state + const persistedState = await this.plugin.storage.getTabManagerState(); + if (persistedState && persistedState.openTabs.length > 0) { + await this.tabManager.restoreState(persistedState); + this.tabBar?.setExpandedTitleTabIds(persistedState.expandedTitleTabIds ?? []); + this.updateTabBar(); + return; + } + + // Fallback: create a new empty tab + await this.tabManager.createTab(); + } + + private persistTabState(): void { + + // Debounce persistence to avoid rapid writes (300ms delay) + if (this.pendingPersist !== null) { + window.clearTimeout(this.pendingPersist); + } + this.pendingPersist = window.setTimeout(() => { + this.pendingPersist = null; + const state = this.getPersistedTabState(); + if (!state) return; + this.plugin.persistTabManagerState(state).catch(() => { + // Silently ignore persistence errors + }); + }, 300); + } + + /** Force immediate persistence (for onClose/onunload). */ + private async persistTabStateImmediate(): Promise { + // Cancel any pending debounced persist + if (this.pendingPersist !== null) { + window.clearTimeout(this.pendingPersist); + this.pendingPersist = null; + } + const state = this.getPersistedTabState(); + if (!state) return; + await this.plugin.persistTabManagerState(state); + } + + private getPersistedTabState(): AppTabManagerState | null { + if (!this.tabManager) return null; + + const state = this.tabManager.getPersistedState(); + const openTabIds = new Set(state.openTabs.map(tab => tab.tabId)); + const expandedTitleTabIds = (this.tabBar?.getExpandedTitleTabIds() ?? []) + .filter(tabId => openTabIds.has(tabId)); + + return { + ...state, + ...(expandedTitleTabIds.length > 0 ? { expandedTitleTabIds } : {}), + }; + } + + // ============================================ + // Public API + // ============================================ + + /** Gets the currently active tab. */ + getActiveTab(): TabData | null { + return this.tabManager?.getActiveTab() ?? null; + } + + /** Gets the tab manager. */ + getTabManager(): TabManager | null { + return this.tabManager; + } + + /** Gets shared view controls that should preserve active tab selection context. */ + getSharedSelectionFocusScopeEls(): HTMLElement[] { + return [ + this.inputNavRowHostEl, + ].filter((el): el is HTMLElement => el !== null); + } +} diff --git a/src/features/chat/constants.ts b/src/features/chat/constants.ts new file mode 100644 index 0000000..f258cb4 --- /dev/null +++ b/src/features/chat/constants.ts @@ -0,0 +1,114 @@ +/** Random flavor words shown when response completes (e.g., "Baked for 1:23"). */ +export const COMPLETION_FLAVOR_WORDS = [ + 'Baked', + 'Cooked', + 'Crunched', + 'Brewed', + 'Crafted', + 'Forged', + 'Conjured', + 'Whipped up', + 'Stirred', + 'Simmered', + 'Toasted', + 'Sautéed', + 'Finagled', + 'Marinated', + 'Distilled', + 'Fermented', + 'Percolated', + 'Steeped', + 'Roasted', + 'Cured', + 'Smoked', + 'Cogitated', +] as const; + +/** Random flavor texts shown while Claude is thinking. */ +export const FLAVOR_TEXTS = [ + // Classic + 'Thinking...', + 'Pondering...', + 'Processing...', + 'Analyzing...', + 'Considering...', + 'Working on it...', + 'Vibing...', + 'One moment...', + 'On it...', + // Thoughtful + 'Ruminating...', + 'Contemplating...', + 'Reflecting...', + 'Mulling it over...', + 'Let me think...', + 'Hmm...', + 'Cogitating...', + 'Deliberating...', + 'Weighing options...', + 'Gathering thoughts...', + // Playful + 'Brewing ideas...', + 'Connecting dots...', + 'Assembling thoughts...', + 'Spinning up neurons...', + 'Loading brilliance...', + 'Consulting the oracle...', + 'Summoning knowledge...', + 'Crunching thoughts...', + 'Dusting off neurons...', + 'Wrangling ideas...', + 'Herding thoughts...', + 'Juggling concepts...', + 'Untangling this...', + 'Piecing it together...', + // Cozy + 'Sipping coffee...', + 'Warming up...', + 'Getting cozy with this...', + 'Settling in...', + 'Making tea...', + 'Grabbing a snack...', + // Technical + 'Parsing...', + 'Compiling thoughts...', + 'Running inference...', + 'Querying the void...', + 'Defragmenting brain...', + 'Allocating memory...', + 'Optimizing...', + 'Indexing...', + 'Syncing neurons...', + // Zen + 'Breathing...', + 'Finding clarity...', + 'Channeling focus...', + 'Centering...', + 'Aligning chakras...', + 'Meditating on this...', + // Whimsical + 'Asking the stars...', + 'Reading tea leaves...', + 'Shaking the magic 8-ball...', + 'Consulting ancient scrolls...', + 'Decoding the matrix...', + 'Communing with the ether...', + 'Peering into the abyss...', + 'Channeling the cosmos...', + // Action + 'Diving in...', + 'Rolling up sleeves...', + 'Getting to work...', + 'Tackling this...', + 'On the case...', + 'Investigating...', + 'Exploring...', + 'Digging deeper...', + // Casual + 'Bear with me...', + 'Hang tight...', + 'Just a sec...', + 'Working my magic...', + 'Almost there...', + 'Give me a moment...', +]; diff --git a/src/features/chat/controllers/BrowserSelectionController.ts b/src/features/chat/controllers/BrowserSelectionController.ts new file mode 100644 index 0000000..5bf8896 --- /dev/null +++ b/src/features/chat/controllers/BrowserSelectionController.ts @@ -0,0 +1,277 @@ +import type { App, ItemView } from 'obsidian'; + +import type { BrowserSelectionContext } from '../../../utils/browser'; +import type { ComposerContextTray } from '../ui/ComposerContextTray'; + +const BROWSER_SELECTION_POLL_INTERVAL = 250; + +type BrowserLikeWebview = HTMLElement & { + executeJavaScript?: (code: string, userGesture?: boolean) => Promise; +}; + +export class BrowserSelectionController { + private app: App; + private contextTray: ComposerContextTray; + private inputEl: HTMLElement; + private onVisibilityChange: (() => void) | null; + private storedSelection: BrowserSelectionContext | null = null; + private pollInterval: number | null = null; + private pollInFlight = false; + + constructor( + app: App, + contextTray: ComposerContextTray, + inputEl: HTMLElement, + onVisibilityChange?: () => void + ) { + this.app = app; + this.contextTray = contextTray; + this.inputEl = inputEl; + this.onVisibilityChange = onVisibilityChange ?? null; + } + + start(): void { + if (this.pollInterval) return; + this.pollInterval = window.setInterval(() => { + void this.poll(); + }, BROWSER_SELECTION_POLL_INTERVAL); + } + + stop(): void { + if (this.pollInterval) { + window.clearInterval(this.pollInterval); + this.pollInterval = null; + } + this.clear(); + } + + private async poll(): Promise { + if (this.pollInFlight) return; + this.pollInFlight = true; + try { + const browserView = this.getActiveBrowserView(); + if (!browserView) { + this.clearWhenInputIsNotFocused(); + return; + } + + const selectedText = await this.extractSelectedText(browserView.containerEl); + if (selectedText) { + const nextContext = this.buildContext(browserView.view, browserView.viewType, browserView.containerEl, selectedText); + if (!this.isSameSelection(nextContext, this.storedSelection)) { + this.storedSelection = nextContext; + this.updateIndicator(); + } + } else { + this.clearWhenInputIsNotFocused(); + } + } catch { + // Ignore transient polling errors to keep selection tracking resilient. + } finally { + this.pollInFlight = false; + } + } + + private getActiveBrowserView(): { view: ItemView; viewType: string; containerEl: HTMLElement } | null { + const activeLeaf = this.app.workspace.getMostRecentLeaf?.(); + const activeView = activeLeaf?.view as ItemView | undefined; + const containerEl = (activeView as unknown as { containerEl?: HTMLElement }).containerEl; + if (!activeView || !containerEl) return null; + + const viewType = activeView.getViewType?.() ?? ''; + if (!this.isBrowserLikeView(viewType, containerEl)) return null; + + return { view: activeView, viewType, containerEl }; + } + + private isBrowserLikeView(viewType: string, containerEl: HTMLElement): boolean { + const normalized = viewType.toLowerCase(); + if ( + normalized.includes('surfing') + || normalized.includes('browser') + || normalized.includes('webview') + ) { + return true; + } + + return Boolean(containerEl.querySelector('iframe, webview')); + } + + private async extractSelectedText(containerEl: HTMLElement): Promise { + const ownerDoc = containerEl.ownerDocument; + const docSelection = this.extractSelectionFromDocument(ownerDoc, containerEl); + if (docSelection) return docSelection; + + const frameSelection = this.extractSelectionFromIframes(containerEl); + if (frameSelection) return frameSelection; + + return await this.extractSelectionFromWebviews(containerEl); + } + + private extractSelectionFromDocument(doc: Document, scopeEl: HTMLElement): string | null { + const selection = doc.getSelection(); + const selectedText = selection?.toString().trim(); + if (selectedText) { + const anchorNode = selection?.anchorNode; + const focusNode = selection?.focusNode; + if ((anchorNode && scopeEl.contains(anchorNode)) || (focusNode && scopeEl.contains(focusNode))) { + return selectedText; + } + } + + return this.extractSelectionFromActiveInput(doc, scopeEl); + } + + private extractSelectionFromActiveInput(doc: Document, scopeEl: HTMLElement): string | null { + const activeEl = doc.activeElement; + if (!activeEl || !scopeEl.contains(activeEl)) return null; + + if (activeEl.instanceOf(HTMLTextAreaElement) || activeEl.instanceOf(HTMLInputElement)) { + const { value, selectionStart, selectionEnd } = activeEl; + if (typeof selectionStart !== 'number' || typeof selectionEnd !== 'number' || selectionStart === selectionEnd) return null; + return value.slice(selectionStart, selectionEnd).trim() || null; + } + + return null; + } + + private extractSelectionFromIframes(containerEl: HTMLElement): string | null { + const iframes = Array.from(containerEl.querySelectorAll('iframe')); + for (const iframe of iframes) { + try { + const frameDoc = iframe.contentDocument ?? iframe.contentWindow?.document; + if (!frameDoc || !frameDoc.body) continue; + + const frameSelection = this.extractSelectionFromDocument(frameDoc, frameDoc.body); + if (frameSelection) return frameSelection; + } catch { + // Ignore inaccessible iframe contexts (cross-origin restrictions). + } + } + return null; + } + + private async extractSelectionFromWebviews(containerEl: HTMLElement): Promise { + const webviews = Array.from(containerEl.querySelectorAll('webview')); + for (const webview of webviews) { + if (typeof webview.executeJavaScript !== 'function') continue; + try { + const result = await webview.executeJavaScript( + 'window.getSelection ? window.getSelection().toString() : ""', + true + ); + if (typeof result === 'string' && result.trim()) { + return result.trim(); + } + } catch { + // Ignore inaccessible webview contexts. + } + } + return null; + } + + private buildContext( + view: ItemView, + viewType: string, + containerEl: HTMLElement, + selectedText: string + ): BrowserSelectionContext { + const title = this.extractViewTitle(view); + const url = this.extractViewUrl(view, containerEl); + const source = url ? `browser:${url}` : `browser:${viewType || 'unknown'}`; + + return { + source, + selectedText, + title, + url, + }; + } + + private extractViewTitle(view: ItemView): string | undefined { + const displayText = view.getDisplayText?.(); + if (displayText?.trim()) return displayText.trim(); + + const title = (view as unknown as { title?: unknown }).title; + return typeof title === 'string' && title.trim() ? title.trim() : undefined; + } + + private extractViewUrl(view: ItemView, containerEl: HTMLElement): string | undefined { + const rawView = view as unknown as Record; + const directCandidates = [ + rawView.url, + rawView.currentUrl, + rawView.currentURL, + rawView.src, + ]; + + for (const candidate of directCandidates) { + if (typeof candidate === 'string' && candidate.trim()) { + return candidate.trim(); + } + } + + const embeddableEl = containerEl.querySelector('iframe[src], webview[src]'); + const embeddedSrc = embeddableEl?.getAttribute('src'); + if (embeddedSrc?.trim()) { + return embeddedSrc.trim(); + } + + return undefined; + } + + private isSameSelection( + left: BrowserSelectionContext | null, + right: BrowserSelectionContext | null + ): boolean { + if (!left || !right) return false; + return left.source === right.source + && left.selectedText === right.selectedText + && left.title === right.title + && left.url === right.url; + } + + private clearWhenInputIsNotFocused(): void { + if (this.inputEl.ownerDocument.activeElement === this.inputEl) return; + if (this.storedSelection) { + this.storedSelection = null; + this.updateIndicator(); + } + } + + private updateIndicator(): void { + if (this.storedSelection) { + const lineCount = this.storedSelection.selectedText.split(/\r?\n/).length; + const lineLabel = lineCount === 1 ? 'line' : 'lines'; + const label = `${lineCount} ${lineLabel} selected`; + this.contextTray.setItems('browser-selection', [{ + id: 'browser-selection', + kind: 'selection', + label, + icon: 'globe', + ariaLabel: label, + onRemove: () => this.clear(), + }]); + } else { + this.contextTray.clearItems('browser-selection'); + } + this.updateContextRowVisibility(); + } + + updateContextRowVisibility(): void { + this.onVisibilityChange?.(); + } + + getContext(): BrowserSelectionContext | null { + return this.storedSelection; + } + + hasSelection(): boolean { + return this.storedSelection !== null; + } + + clear(): void { + this.storedSelection = null; + this.updateIndicator(); + } +} diff --git a/src/features/chat/controllers/CanvasSelectionController.ts b/src/features/chat/controllers/CanvasSelectionController.ts new file mode 100644 index 0000000..9f67092 --- /dev/null +++ b/src/features/chat/controllers/CanvasSelectionController.ts @@ -0,0 +1,141 @@ +import type { App, ItemView } from 'obsidian'; + +import type { CanvasSelectionContext } from '../../../utils/canvas'; +import type { ComposerContextTray } from '../ui/ComposerContextTray'; + +const CANVAS_POLL_INTERVAL = 250; + +type CanvasSelectionNode = { id?: unknown }; + +type CanvasViewLike = ItemView & { + canvas?: { + selection?: Set; + }; + file?: { + path?: unknown; + }; +}; + +export class CanvasSelectionController { + private app: App; + private contextTray: ComposerContextTray; + private inputEl: HTMLElement; + private onVisibilityChange: (() => void) | null; + private storedSelection: CanvasSelectionContext | null = null; + private pollInterval: number | null = null; + + constructor( + app: App, + contextTray: ComposerContextTray, + inputEl: HTMLElement, + onVisibilityChange?: () => void + ) { + this.app = app; + this.contextTray = contextTray; + this.inputEl = inputEl; + this.onVisibilityChange = onVisibilityChange ?? null; + } + + start(): void { + if (this.pollInterval) return; + this.pollInterval = window.setInterval(() => this.poll(), CANVAS_POLL_INTERVAL); + } + + stop(): void { + if (this.pollInterval) { + window.clearInterval(this.pollInterval); + this.pollInterval = null; + } + this.clear(); + } + + private poll(): void { + const canvasView = this.getCanvasView(); + if (!canvasView) return; + + const canvas = canvasView.canvas; + if (!canvas?.selection) return; + + const selection = canvas.selection; + const canvasPath = canvasView.file?.path; + if (typeof canvasPath !== 'string' || !canvasPath) return; + + const nodeIds = [...selection] + .map(node => node.id) + .filter((id): id is string => typeof id === 'string' && id.length > 0); + + if (nodeIds.length > 0) { + const sameSelection = this.storedSelection + && this.storedSelection.canvasPath === canvasPath + && this.storedSelection.nodeIds.length === nodeIds.length + && this.storedSelection.nodeIds.every(id => nodeIds.includes(id)); + + if (!sameSelection) { + this.storedSelection = { canvasPath, nodeIds }; + this.updateIndicator(); + } + } else if (this.getActiveElement() !== this.inputEl) { + if (this.storedSelection) { + this.storedSelection = null; + this.updateIndicator(); + } + } + } + + private getActiveElement(): Element | null { + return this.inputEl.ownerDocument?.activeElement ?? null; + } + + private getCanvasView(): CanvasViewLike | null { + const activeLeaf = this.app.workspace.getMostRecentLeaf?.(); + const activeView = activeLeaf?.view as CanvasViewLike | undefined; + if (activeView?.getViewType?.() === 'canvas' && activeView.file) { + return activeView; + } + + const leaves = this.app.workspace.getLeavesOfType('canvas'); + if (leaves.length === 0) return null; + const leaf = leaves.find(l => (l.view as CanvasViewLike).file); + return leaf ? (leaf.view as CanvasViewLike) : null; + } + + private updateIndicator(): void { + if (this.storedSelection) { + const { nodeIds } = this.storedSelection; + const nodeLabel = nodeIds.length === 1 ? '1 node' : `${nodeIds.length} nodes`; + const label = `${nodeLabel} selected`; + this.contextTray.setItems('canvas-selection', [{ + id: 'canvas-selection', + kind: 'selection', + label, + icon: 'network', + ariaLabel: label, + onRemove: () => this.clear(), + }]); + } else { + this.contextTray.clearItems('canvas-selection'); + } + this.updateContextRowVisibility(); + } + + updateContextRowVisibility(): void { + this.onVisibilityChange?.(); + } + + getContext(): CanvasSelectionContext | null { + if (!this.storedSelection) return null; + return { + canvasPath: this.storedSelection.canvasPath, + nodeIds: [...this.storedSelection.nodeIds], + }; + } + + hasSelection(): boolean { + return this.storedSelection !== null; + } + + clear(): void { + this.storedSelection = null; + this.updateIndicator(); + } +} diff --git a/src/features/chat/controllers/ConversationController.ts b/src/features/chat/controllers/ConversationController.ts new file mode 100644 index 0000000..e85189e --- /dev/null +++ b/src/features/chat/controllers/ConversationController.ts @@ -0,0 +1,1114 @@ +import { Menu, Notice, setIcon } from 'obsidian'; + +import type { TitleGenerationService } from '../../../core/providers/types'; +import type { ChatRuntime } from '../../../core/runtime/ChatRuntime'; +import type { ChatRewindMode } from '../../../core/runtime/types'; +import type { Conversation } from '../../../core/types'; +import { t } from '../../../i18n/i18n'; +import { confirm } from '../../../shared/modals/ConfirmModal'; +import { extractUserDisplayContent } from '../../../utils/context'; +import type { FeatureHost } from '../../FeatureHost'; +import type { MessageRenderer } from '../rendering/MessageRenderer'; +import { cleanupThinkingBlock } from '../rendering/ThinkingBlockRenderer'; +import { findRewindContext } from '../rewind'; +import type { SubagentManager } from '../services/SubagentManager'; +import type { ChatState } from '../state/ChatState'; +import type { FileContextManager } from '../ui/FileContext'; +import type { ImageContextManager } from '../ui/ImageContext'; +import type { ExternalContextSelector, McpServerSelector } from '../ui/InputToolbar'; +import type { StatusPanel } from '../ui/StatusPanel'; + +function runConversationAction(action: () => Promise, failureMessage: string): void { + void action().catch(() => { + new Notice(failureMessage); + }); +} + +export interface ConversationCallbacks { + onNewConversation?: () => void; + onConversationLoaded?: () => void; + onConversationSwitched?: () => void; +} + +export interface ConversationControllerDeps { + plugin: FeatureHost; + state: ChatState; + renderer: MessageRenderer; + subagentManager: SubagentManager; + getHistoryDropdown: () => HTMLElement | null; + getWelcomeEl: () => HTMLElement | null; + setWelcomeEl: (el: HTMLElement | null) => void; + getMessagesEl: () => HTMLElement; + getInputEl: () => HTMLTextAreaElement; + getFileContextManager: () => FileContextManager | null; + getImageContextManager: () => ImageContextManager | null; + getMcpServerSelector: () => McpServerSelector | null; + getExternalContextSelector: () => ExternalContextSelector | null; + clearQueuedMessage: () => void; + getTitleGenerationService: () => TitleGenerationService | null; + getStatusPanel: () => StatusPanel | null; + getAgentService?: () => ChatRuntime | null; + getSelectedModel?: () => string | null; + ensureServiceForConversation?: (conversation: Conversation | null) => Promise; + dismissPendingInlinePrompts?: () => void; +} + +type SaveOptions = { + resumeAtMessageId?: string; + resetProviderSession?: boolean; +}; + +export type HistoryConversationOpenState = 'closed' | 'open' | 'current'; + +export type HistoryConversationStatus = { + openState: HistoryConversationOpenState; + isRunning: boolean; + location?: 'current-view' | 'other-view'; + tabIndex?: number; +}; + +type HistoryRenderOptions = { + onSelectConversation: (id: string) => Promise; + onOpenConversationInNewTab?: (id: string, activate?: boolean) => Promise; + getConversationOpenState?: (id: string) => HistoryConversationOpenState; + getConversationStatus?: (id: string) => HistoryConversationStatus; + onRerender: () => void; +}; + +export class ConversationController { + private deps: ConversationControllerDeps; + private callbacks: ConversationCallbacks; + + constructor(deps: ConversationControllerDeps, callbacks: ConversationCallbacks = {}) { + this.deps = deps; + this.callbacks = callbacks; + } + + private getAgentService(): ChatRuntime | null { + return this.deps.getAgentService?.() ?? null; + } + + // ============================================ + // Conversation Lifecycle + // ============================================ + + /** + * Resets to entry point state (New Chat). + * + * Entry point is a blank UI state - no conversation is created until the + * first message is sent. This prevents empty conversations cluttering history. + */ + async createNew(options: { force?: boolean } = {}): Promise { + const { plugin, state, subagentManager } = this.deps; + const force = !!options.force; + if (state.isStreaming && !force) return; + if (state.isCreatingConversation) return; + if (state.isSwitchingConversation) return; + + // Set flag to block message sending during reset + state.isCreatingConversation = true; + + try { + this.deps.dismissPendingInlinePrompts?.(); + + if (force && state.isStreaming) { + state.cancelRequested = true; + state.bumpStreamGeneration(); + this.getAgentService()?.cancel(); + } + + // Save current conversation if it has messages + if (state.currentConversationId && state.messages.length > 0) { + await this.save(); + } + + subagentManager.orphanAllActive(); + subagentManager.clear(); + + // Clear streaming state and related DOM references + cleanupThinkingBlock(state.currentThinkingState); + state.currentContentEl = null; + state.currentTextEl = null; + state.currentTextContent = ''; + state.currentThinkingState = null; + state.toolCallElements.clear(); + state.writeEditStates.clear(); + state.isStreaming = false; + + // Reset to entry point state - no conversation created yet + state.currentConversationId = null; + state.clearMessages(); + state.usage = null; + state.currentTodos = null; + state.pendingNewSessionPlan = null; + state.planFilePath = null; + state.prePlanPermissionMode = null; + state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true; + state.hasPendingConversationSave = false; + + // Reset agent service session (no session ID for entry point) + // Pass persistent paths to prevent stale external contexts + this.getAgentService()?.syncConversationState( + null, + plugin.settings.persistentExternalContextPaths || [] + ); + + const messagesEl = this.deps.getMessagesEl(); + messagesEl.empty(); + + // Recreate welcome element first (before StatusPanel for consistent ordering) + const welcomeEl = messagesEl.createDiv({ cls: 'claudian-welcome' }); + welcomeEl.createDiv({ cls: 'claudian-welcome-greeting', text: this.getGreeting() }); + this.deps.setWelcomeEl(welcomeEl); + + // Remount StatusPanel to restore state for new conversation + this.deps.getStatusPanel()?.remount(); + + this.deps.getInputEl().value = ''; + + const fileCtx = this.deps.getFileContextManager(); + fileCtx?.resetForNewConversation(); + fileCtx?.autoAttachActiveFile(); + + this.deps.getImageContextManager()?.clearImages(); + this.deps.getMcpServerSelector()?.clearEnabled(); + // Pass current settings to ensure we have the most up-to-date persistent paths + this.deps.getExternalContextSelector()?.clearExternalContexts( + plugin.settings.persistentExternalContextPaths || [] + ); + this.deps.clearQueuedMessage(); + + this.callbacks.onNewConversation?.(); + } finally { + state.isCreatingConversation = false; + } + } + + /** + * Loads the current tab conversation, or starts at entry point if none. + * + * Entry point (no conversation) shows welcome screen without + * creating a conversation. Conversation is created lazily on first message. + */ + async loadActive(): Promise { + const { plugin, state, renderer } = this.deps; + + const conversationId = state.currentConversationId; + const conversation = conversationId ? await plugin.getConversationById(conversationId) : null; + + // No active conversation - start at entry point + if (!conversation) { + state.currentConversationId = null; + state.clearMessages(); + state.usage = null; + state.currentTodos = null; + state.pendingNewSessionPlan = null; + state.planFilePath = null; + state.prePlanPermissionMode = null; + state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true; + state.hasPendingConversationSave = false; + + // Pass persistent paths to prevent stale external contexts + this.getAgentService()?.syncConversationState( + null, + plugin.settings.persistentExternalContextPaths || [] + ); + + const fileCtx = this.deps.getFileContextManager(); + fileCtx?.resetForNewConversation(); + fileCtx?.autoAttachActiveFile(); + + // Initialize external contexts with persistent paths from settings + this.deps.getExternalContextSelector()?.clearExternalContexts( + plugin.settings.persistentExternalContextPaths || [] + ); + + this.deps.getMcpServerSelector()?.clearEnabled(); + + const welcomeEl = renderer.renderMessages( + [], + () => this.getGreeting() + ); + this.deps.setWelcomeEl(welcomeEl); + this.updateWelcomeVisibility(); + + this.callbacks.onConversationLoaded?.(); + return; + } + + await this.deps.ensureServiceForConversation?.(conversation); + this.restoreConversation(conversation, { autoAttachFile: true }); + this.updateWelcomeVisibility(); + + this.callbacks.onConversationLoaded?.(); + } + + /** Switches to a different conversation. */ + async switchTo(id: string): Promise { + const { plugin, state, subagentManager } = this.deps; + + if (id === state.currentConversationId) return; + if (state.isStreaming) return; + if (state.isSwitchingConversation) return; + if (state.isCreatingConversation) return; + + state.isSwitchingConversation = true; + + try { + this.deps.dismissPendingInlinePrompts?.(); + await this.save(); + + subagentManager.orphanAllActive(); + subagentManager.clear(); + + const conversation = await plugin.switchConversation(id); + if (!conversation) { + return; + } + + await this.deps.ensureServiceForConversation?.(conversation); + + this.deps.getInputEl().value = ''; + this.deps.clearQueuedMessage(); + + this.restoreConversation(conversation); + + this.deps.getHistoryDropdown()?.removeClass('visible'); + this.updateWelcomeVisibility(); + + this.callbacks.onConversationSwitched?.(); + } finally { + state.isSwitchingConversation = false; + } + } + + async rewind( + userMessageId: string, + mode: ChatRewindMode = 'code-and-conversation', + ): Promise { + const { plugin, state, renderer } = this.deps; + + const agentServiceForCheck = this.getAgentService(); + if (agentServiceForCheck && !agentServiceForCheck.getCapabilities().supportsRewind) { + new Notice(t('chat.rewind.failed', { error: 'Rewind is not supported by this provider.' })); + return; + } + + if (state.isStreaming) { + new Notice(t('chat.rewind.unavailableStreaming')); + return; + } + + const msgs = state.messages; + const userIdx = msgs.findIndex(m => m.id === userMessageId); + if (userIdx === -1) { + new Notice(t('chat.rewind.failed', { error: 'Message not found' })); + return; + } + const userMsg = msgs[userIdx]; + if (!userMsg.userMessageId) { + new Notice(t('chat.rewind.unavailableNoUuid')); + return; + } + + const rewindCtx = findRewindContext(msgs, userIdx); + if (!rewindCtx.hasResponse) { + new Notice(t('chat.rewind.unavailableNoUuid')); + return; + } + const prevAssistantUuid = rewindCtx.prevAssistantUuid; + + const confirmed = await confirm( + plugin.app, + mode === 'conversation' + ? t('chat.rewind.confirmMessageConversationOnly') + : t('chat.rewind.confirmMessage'), + t('chat.rewind.confirmButton') + ); + if (!confirmed) return; + + if (state.isStreaming) { + new Notice(t('chat.rewind.unavailableStreaming')); + return; + } + + const agentService = this.getAgentService(); + if (!agentService) { + new Notice(t('chat.rewind.failed', { error: 'Agent service not available' })); + return; + } + + let result; + try { + result = await agentService.rewind(userMsg.userMessageId, prevAssistantUuid, mode); + } catch (e) { + new Notice(t('chat.rewind.failed', { error: e instanceof Error ? e.message : 'Unknown error' })); + return; + } + if (!result.canRewind) { + new Notice(t('chat.rewind.cannot', { error: result.error ?? 'Unknown error' })); + return; + } + + state.truncateAt(userMessageId); + + const inputEl = this.deps.getInputEl(); + inputEl.value = userMsg.content; + inputEl.focus(); + + const welcomeEl = renderer.renderMessages(state.messages, () => this.getGreeting()); + this.deps.setWelcomeEl(welcomeEl); + this.updateWelcomeVisibility(); + + const filesChanged = result.filesChanged?.length ?? 0; + let saveError: string | null = null; + try { + await this.save(false, { + resumeAtMessageId: prevAssistantUuid, + resetProviderSession: !prevAssistantUuid, + }); + } catch (e) { + saveError = e instanceof Error ? e.message : 'Failed to save'; + } + + if (saveError) { + new Notice( + mode === 'conversation' + ? t('chat.rewind.noticeConversationOnlySaveFailed', { error: saveError }) + : t('chat.rewind.noticeSaveFailed', { count: String(filesChanged), error: saveError }) + ); + return; + } + + new Notice( + mode === 'conversation' + ? t('chat.rewind.noticeConversationOnly') + : t('chat.rewind.notice', { count: String(filesChanged) }) + ); + } + + /** + * Saves the current conversation. + * + * If we're at an entry point (no conversation yet) and have messages, + * creates a new conversation first (lazy creation). + * + * For native sessions (new conversations with sessionId from SDK), + * only metadata is saved - the SDK handles message persistence. + */ + async save(updateLastResponse = false, options?: SaveOptions): Promise { + const { plugin, state } = this.deps; + + // Entry point with no messages - nothing to save + if (!state.currentConversationId && state.messages.length === 0) { + return; + } + + const agentService = this.getAgentService(); + const sessionInvalidated = agentService?.consumeSessionInvalidation?.() ?? false; + + // Entry point with messages - create conversation lazily + // New conversations always use SDK-native storage. + if (!state.currentConversationId && state.messages.length > 0) { + const initialSessionId = agentService?.getSessionId() ?? undefined; + const selectedModel = this.deps.getSelectedModel?.() ?? undefined; + const conversation = await plugin.createConversation({ + providerId: agentService?.providerId, + sessionId: initialSessionId, + ...(selectedModel ? { selectedModel } : {}), + }); + state.currentConversationId = conversation.id; + } + + const fileCtx = this.deps.getFileContextManager(); + const currentNote = fileCtx?.getCurrentNotePath() || undefined; + const externalContextSelector = this.deps.getExternalContextSelector(); + const externalContextPaths = externalContextSelector?.getExternalContexts() ?? []; + const mcpServerSelector = this.deps.getMcpServerSelector(); + const enabledMcpServers = mcpServerSelector ? Array.from(mcpServerSelector.getEnabledServers()) : []; + + const conversation = plugin.getConversationSync(state.currentConversationId!); + + const { updates: sessionUpdates } = agentService && !options?.resetProviderSession + ? agentService.buildSessionUpdates({ conversation, sessionInvalidated }) + : { updates: {} }; + + const updates: Partial = { + ...sessionUpdates, + messages: state.messages, + currentNote: currentNote, + externalContextPaths: externalContextPaths.length > 0 ? externalContextPaths : undefined, + usage: state.usage ?? undefined, + enabledMcpServers: enabledMcpServers.length > 0 ? enabledMcpServers : undefined, + }; + + if (updateLastResponse) { + updates.lastResponseAt = Date.now(); + } + + if (options) { + updates.resumeAtMessageId = options.resumeAtMessageId; + if (options.resetProviderSession) { + updates.sessionId = null; + updates.providerState = undefined; + } + } + + await plugin.updateConversation(state.currentConversationId!, updates); + state.hasPendingConversationSave = false; + } + + /** + * Shared logic for restoring a conversation into the current tab. + * Used by both loadActive() and switchTo() to avoid duplication. + */ + private restoreConversation( + conversation: Conversation, + options?: { autoAttachFile?: boolean } + ): void { + const { plugin, state, renderer } = this.deps; + + state.currentConversationId = conversation.id; + state.messages = [...conversation.messages]; + state.usage = conversation.usage ?? null; + state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true; + state.hasPendingConversationSave = false; + + // Clear status panels (auto-hide: panels reappear when agent creates new todos) + state.currentTodos = null; + + const hasMessages = state.messages.length > 0; + + // Determine external context paths for this session + // Empty session: use persistent paths; session with messages: use saved paths + const externalContextPaths = hasMessages + ? conversation.externalContextPaths || [] + : plugin.settings.persistentExternalContextPaths || []; + + this.getAgentService()?.syncConversationState(conversation, externalContextPaths); + + const fileCtx = this.deps.getFileContextManager(); + fileCtx?.resetForLoadedConversation(hasMessages); + + if (conversation.currentNote) { + fileCtx?.setCurrentNote(conversation.currentNote); + } else if (!hasMessages && options?.autoAttachFile) { + fileCtx?.autoAttachActiveFile(); + } + + this.restoreExternalContextPaths(conversation.externalContextPaths, !hasMessages); + + const mcpServerSelector = this.deps.getMcpServerSelector(); + if (conversation.enabledMcpServers && conversation.enabledMcpServers.length > 0) { + mcpServerSelector?.setEnabledServers(conversation.enabledMcpServers); + } else { + mcpServerSelector?.clearEnabled(); + } + + const welcomeEl = renderer.renderMessages( + state.messages, + () => this.getGreeting() + ); + this.deps.setWelcomeEl(welcomeEl); + } + + /** + * Restores external context paths based on session state. + * New or empty sessions get current persistent paths from settings. + * Sessions with messages restore exactly what was saved. + */ + private restoreExternalContextPaths( + savedPaths: string[] | undefined, + isEmptySession: boolean + ): void { + const { plugin } = this.deps; + const externalContextSelector = this.deps.getExternalContextSelector(); + if (!externalContextSelector) { + return; + } + + if (isEmptySession) { + // Empty session: use current persistent paths from settings + externalContextSelector.clearExternalContexts( + plugin.settings.persistentExternalContextPaths || [] + ); + } else { + // Session with messages: restore exactly what was saved + externalContextSelector.setExternalContexts(savedPaths || []); + } + } + + // ============================================ + // History Dropdown + // ============================================ + + toggleHistoryDropdown(): void { + const dropdown = this.deps.getHistoryDropdown(); + if (!dropdown) return; + + const isVisible = dropdown.hasClass('visible'); + if (isVisible) { + dropdown.removeClass('visible'); + } else { + this.updateHistoryDropdown(); + dropdown.addClass('visible'); + } + } + + updateHistoryDropdown(): void { + const dropdown = this.deps.getHistoryDropdown(); + if (!dropdown) return; + + this.renderHistoryItems(dropdown, { + onSelectConversation: (id) => this.switchTo(id), + onRerender: () => this.updateHistoryDropdown(), + }); + } + + /** + * Renders history dropdown items to a container. + * Shared implementation for updateHistoryDropdown() and renderHistoryDropdown(). + */ + private renderHistoryItems( + container: HTMLElement, + options: HistoryRenderOptions + ): void { + const { plugin, state } = this.deps; + + container.empty(); + + const dropdownHeader = container.createDiv({ cls: 'claudian-history-header' }); + dropdownHeader.createSpan({ text: 'Conversations' }); + + const list = container.createDiv({ cls: 'claudian-history-list' }); + const allConversations = plugin.getConversationList(); + + if (allConversations.length === 0) { + list.createDiv({ cls: 'claudian-history-empty', text: 'No conversations' }); + return; + } + + // Sort by lastResponseAt (fallback to createdAt) descending + const conversations = [...allConversations].sort((a, b) => { + return (b.lastResponseAt ?? b.createdAt) - (a.lastResponseAt ?? a.createdAt); + }); + + for (const conv of conversations) { + const fallbackOpenState: HistoryConversationOpenState = + conv.id === state.currentConversationId ? 'current' : 'closed'; + const conversationStatus = this.getHistoryConversationStatus(conv.id, fallbackOpenState, options); + const { openState, isRunning } = conversationStatus; + const isCurrent = openState === 'current'; + const isOpen = openState === 'open'; + const item = list.createDiv({ + cls: [ + 'claudian-history-item', + isCurrent ? 'active' : '', + isOpen ? 'open' : '', + isRunning ? 'running' : '', + ].filter(Boolean).join(' '), + }); + item.setAttribute('data-open-state', openState); + item.setAttribute('data-running', isRunning ? 'true' : 'false'); + item.setAttribute('data-tab-location', conversationStatus.location ?? 'current-view'); + if (typeof conversationStatus.tabIndex === 'number') { + item.setAttribute('data-tab-index', String(conversationStatus.tabIndex)); + } + + const iconEl = item.createDiv({ cls: 'claudian-history-item-icon' }); + setIcon(iconEl, this.getHistoryItemIcon(openState, isRunning)); + + const content = item.createDiv({ cls: 'claudian-history-item-content' }); + const titleEl = content.createDiv({ cls: 'claudian-history-item-title', text: conv.title }); + titleEl.setAttribute('title', conv.title); + content.createDiv({ + cls: 'claudian-history-item-date', + text: this.getHistoryItemStatusText(conversationStatus, conv.lastResponseAt ?? conv.createdAt), + }); + + if (!isCurrent) { + content.addEventListener('click', (e) => { + e.stopPropagation(); + if (this.isHistoryNewTabModifierClick(e) && options.onOpenConversationInNewTab) { + e.preventDefault(); + runConversationAction( + () => this.runHistoryAction( + () => options.onOpenConversationInNewTab?.(conv.id, true), + 'Failed to load conversation', + ), + 'Failed to load conversation', + ); + return; + } + + runConversationAction( + () => this.runHistoryAction( + () => options.onSelectConversation(conv.id), + 'Failed to load conversation', + ), + 'Failed to load conversation', + ); + }); + + if (options.onOpenConversationInNewTab) { + content.addEventListener('auxclick', (e) => { + if (e.button !== 1) return; + e.preventDefault(); + e.stopPropagation(); + runConversationAction( + () => this.runHistoryAction( + () => options.onOpenConversationInNewTab?.(conv.id, true), + 'Failed to load conversation', + ), + 'Failed to load conversation', + ); + }); + } + } + + item.addEventListener('contextmenu', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.showHistoryContextMenu(item, conv.id, conv.title, isCurrent, options, e); + }); + + const actions = item.createDiv({ cls: 'claudian-history-item-actions' }); + + // Show regenerate button if title generation failed, or loading indicator if pending + if (conv.titleGenerationStatus === 'pending') { + const loadingEl = actions.createEl('span', { cls: 'claudian-action-btn claudian-action-loading' }); + setIcon(loadingEl, 'loader-2'); + loadingEl.setAttribute('aria-label', 'Generating title...'); + } else if (conv.titleGenerationStatus === 'failed') { + const regenerateBtn = actions.createEl('button', { cls: 'claudian-action-btn' }); + setIcon(regenerateBtn, 'refresh-cw'); + regenerateBtn.setAttribute('aria-label', 'Regenerate title'); + regenerateBtn.addEventListener('click', (e) => { + e.stopPropagation(); + runConversationAction( + () => this.regenerateTitle(conv.id), + 'Failed to regenerate response', + ); + }); + } + + if (openState === 'closed' && options.onOpenConversationInNewTab) { + const openInNewTabBtn = actions.createEl('button', { + cls: 'claudian-action-btn claudian-open-new-tab-btn', + }); + setIcon(openInNewTabBtn, 'square-plus'); + openInNewTabBtn.setAttribute('aria-label', 'Open in new tab'); + openInNewTabBtn.addEventListener('click', (e) => { + e.stopPropagation(); + runConversationAction( + () => this.runHistoryAction( + () => options.onOpenConversationInNewTab?.(conv.id, true), + 'Failed to load conversation', + ), + 'Failed to load conversation', + ); + }); + } + + const renameBtn = actions.createEl('button', { cls: 'claudian-action-btn' }); + setIcon(renameBtn, 'pencil'); + renameBtn.setAttribute('aria-label', 'Rename'); + renameBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.showRenameInput(item, conv.id, conv.title); + }); + + const deleteBtn = actions.createEl('button', { cls: 'claudian-action-btn claudian-delete-btn' }); + setIcon(deleteBtn, 'trash-2'); + deleteBtn.setAttribute('aria-label', 'Delete'); + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + runConversationAction( + () => this.runHistoryAction( + () => this.deleteHistoryConversation(conv.id, options), + 'Failed to delete conversation', + ), + 'Failed to delete conversation', + ); + }); + } + } + + private getHistoryConversationStatus( + conversationId: string, + fallbackOpenState: HistoryConversationOpenState, + options: HistoryRenderOptions, + ): HistoryConversationStatus { + const status = options.getConversationStatus?.(conversationId); + if (status) return status; + + return { + openState: options.getConversationOpenState?.(conversationId) ?? fallbackOpenState, + isRunning: false, + }; + } + + private getHistoryItemStatusText( + status: HistoryConversationStatus, + timestamp: number, + ): string { + const { openState, isRunning } = status; + const location = status.location ?? 'current-view'; + + if (openState !== 'closed' && location === 'other-view') { + return isRunning ? 'Running in another pane' : 'Open in another pane'; + } + + if (isRunning) { + if (openState === 'closed') return 'Running'; + return `Running in ${this.getHistoryTabLabel(status)}`; + } + + switch (openState) { + case 'current': + return typeof status.tabIndex === 'number' + ? `Current tab ${status.tabIndex}` + : 'Current session'; + case 'open': + return `Open in ${this.getHistoryTabLabel(status)}`; + case 'closed': + return this.formatDate(timestamp); + } + } + + private getHistoryTabLabel(status: HistoryConversationStatus): string { + if (typeof status.tabIndex === 'number') { + return `tab ${status.tabIndex}`; + } + + if (status.openState === 'current') { + return 'current tab'; + } + + return 'tab'; + } + + private getHistoryItemIcon( + openState: HistoryConversationOpenState, + isRunning: boolean, + ): string { + if (isRunning) return 'loader-2'; + if (openState === 'current') return 'message-square-dot'; + return 'message-square'; + } + + private isHistoryNewTabModifierClick(event: MouseEvent): boolean { + return !event.altKey && !event.shiftKey && (event.metaKey || event.ctrlKey); + } + + private async runHistoryAction( + action: () => Promise | void, + errorMessage: string, + ): Promise { + try { + await action(); + } catch { + new Notice(errorMessage); + } + } + + private showHistoryContextMenu( + item: HTMLElement, + conversationId: string, + title: string, + isCurrent: boolean, + options: HistoryRenderOptions, + event: MouseEvent, + ): void { + const menu = new Menu(); + const fallbackOpenState: HistoryConversationOpenState = isCurrent ? 'current' : 'closed'; + const { openState } = this.getHistoryConversationStatus(conversationId, fallbackOpenState, options); + + if (openState !== 'current') { + if (openState === 'closed' && options.onOpenConversationInNewTab) { + menu.addItem((menuItem) => menuItem + .setTitle('Open in new tab') + .onClick(() => { + void this.runHistoryAction( + () => options.onOpenConversationInNewTab?.(conversationId, true), + 'Failed to load conversation', + ); + })); + menu.addItem((menuItem) => menuItem + .setTitle('Open in background tab') + .onClick(() => { + void this.runHistoryAction( + () => options.onOpenConversationInNewTab?.(conversationId, false), + 'Failed to load conversation', + ); + })); + } else if (openState === 'open') { + menu.addItem((menuItem) => menuItem + .setTitle('Switch to open session') + .onClick(() => { + void this.runHistoryAction( + () => options.onSelectConversation(conversationId), + 'Failed to load conversation', + ); + })); + } + } + + menu.addItem((menuItem) => menuItem + .setTitle('Rename') + .onClick(() => { + this.showRenameInput(item, conversationId, title); + })); + menu.addItem((menuItem) => menuItem + .setTitle('Delete') + .onClick(() => { + void this.runHistoryAction( + () => this.deleteHistoryConversation(conversationId, options), + 'Failed to delete conversation', + ); + })); + + menu.showAtMouseEvent(event); + } + + private async deleteHistoryConversation( + conversationId: string, + options: HistoryRenderOptions, + ): Promise { + const { plugin, state } = this.deps; + if (state.isStreaming) return; + + await plugin.deleteConversation(conversationId); + options.onRerender(); + + if (conversationId === state.currentConversationId) { + await this.loadActive(); + } + } + + /** Shows inline rename input for a conversation. */ + private showRenameInput(item: HTMLElement, convId: string, currentTitle: string): void { + const titleEl = item.querySelector('.claudian-history-item-title') as HTMLElement; + if (!titleEl) return; + + const input = (item.ownerDocument ?? window.document).createElement('input'); + input.type = 'text'; + input.className = 'claudian-rename-input'; + input.value = currentTitle; + + titleEl.replaceWith(input); + input.focus(); + input.select(); + + const finishRename = async () => { + try { + const newTitle = input.value.trim() || currentTitle; + await this.deps.plugin.renameConversation(convId, newTitle); + this.updateHistoryDropdown(); + } catch { + new Notice('Failed to rename conversation'); + } + }; + + input.addEventListener('blur', () => { + runConversationAction(finishRename, 'Failed to rename conversation'); + }); + input.addEventListener('keydown', (e) => { + // Check !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) + if (e.key === 'Enter' && !e.isComposing) { + input.blur(); + } else if (e.key === 'Escape' && !e.isComposing) { + input.value = currentTitle; + input.blur(); + } + }); + } + + // ============================================ + // Welcome & Greeting + // ============================================ + + /** Generates a dynamic greeting based on time/day. */ + getGreeting(): string { + const now = new Date(); + const hour = now.getHours(); + const day = now.getDay(); // 0 = Sunday, 6 = Saturday + const name = this.deps.plugin.settings.userName?.trim(); + + // Helper to optionally personalize a greeting (with fallback for no-name case) + const personalize = (base: string, noNameFallback?: string): string => + name ? `${base}, ${name}` : (noNameFallback ?? base); + + // Day-specific greetings (some personalized, some universal) + const dayGreetings: Record = { + 0: [personalize('Happy Sunday'), 'Sunday session?', 'Welcome to the weekend'], + 1: [personalize('Happy Monday'), personalize('Back at it', 'Back at it!')], + 2: [personalize('Happy Tuesday')], + 3: [personalize('Happy Wednesday')], + 4: [personalize('Happy Thursday')], + 5: [personalize('Happy Friday'), personalize('That Friday feeling')], + 6: [personalize('Happy Saturday', 'Happy Saturday!'), personalize('Welcome to the weekend')], + }; + + // Time-specific greetings + const getTimeGreetings = (): string[] => { + if (hour >= 5 && hour < 12) { + return [personalize('Good morning'), 'Coffee and Claudian time?']; + } else if (hour >= 12 && hour < 18) { + return [personalize('Good afternoon'), personalize('Hey there'), personalize("How's it going") + '?']; + } else if (hour >= 18 && hour < 22) { + return [personalize('Good evening'), personalize('Evening'), personalize('How was your day') + '?']; + } else { + return ['Hello, night owl', personalize('Evening')]; + } + }; + + // General greetings + const generalGreetings = [ + personalize('Hey there'), + name ? `Hi ${name}, how are you?` : 'Hi, how are you?', + personalize("How's it going") + '?', + personalize('Welcome back') + '!', + personalize("What's new") + '?', + ...(name ? [`${name} returns!`] : []), + 'You are absolutely right!', + ]; + + // Combine day + time + general greetings, pick randomly + const allGreetings = [ + ...(dayGreetings[day] || []), + ...getTimeGreetings(), + ...generalGreetings, + ]; + + return allGreetings[Math.floor(Math.random() * allGreetings.length)]; + } + + /** Updates welcome element visibility based on message count. */ + updateWelcomeVisibility(): void { + const welcomeEl = this.deps.getWelcomeEl(); + if (!welcomeEl) return; + + if (this.deps.state.messages.length === 0) { + welcomeEl.removeClass('claudian-hidden'); + } else { + welcomeEl.addClass('claudian-hidden'); + } + } + + /** + * Initializes the welcome greeting for a new tab without a conversation. + * Called when a new tab is activated and has no conversation loaded. + */ + initializeWelcome(): void { + const welcomeEl = this.deps.getWelcomeEl(); + if (!welcomeEl) return; + + // Initialize file context to auto-attach the currently focused note + const fileCtx = this.deps.getFileContextManager(); + fileCtx?.resetForNewConversation(); + fileCtx?.autoAttachActiveFile(); + + // Only add greeting if not already present + if (!welcomeEl.querySelector('.claudian-welcome-greeting')) { + welcomeEl.createDiv({ cls: 'claudian-welcome-greeting', text: this.getGreeting() }); + } + + this.updateWelcomeVisibility(); + } + + // ============================================ + // Utilities + // ============================================ + + /** Generates a fallback title from the first message (used when AI fails). */ + generateFallbackTitle(firstMessage: string): string { + const firstSentence = firstMessage.split(/[.!?\n]/)[0].trim(); + const autoTitle = firstSentence.substring(0, 50); + const suffix = firstSentence.length > 50 ? '...' : ''; + return `${autoTitle}${suffix}`; + } + + /** Regenerates AI title for a conversation. */ + async regenerateTitle(conversationId: string): Promise { + const { plugin } = this.deps; + if (!plugin.settings.enableAutoTitleGeneration) return; + + // Title generation is delegated to the active provider service + const fullConv = await plugin.getConversationById(conversationId); + if (!fullConv || fullConv.messages.length < 1) return; + + const titleService = this.deps.getTitleGenerationService(); + if (!titleService) return; + + // Find first user message by role (not by index) + const firstUserMsg = fullConv.messages.find(m => m.role === 'user'); + if (!firstUserMsg) return; + + const userContent = firstUserMsg.displayContent + ?? extractUserDisplayContent(firstUserMsg.content) + ?? firstUserMsg.content; + + // Store current title to check if user renames during generation + const expectedTitle = fullConv.title; + + // Set pending status before starting generation + await plugin.updateConversation(conversationId, { titleGenerationStatus: 'pending' }); + this.updateHistoryDropdown(); + + // Fire async AI title generation + await titleService.generateTitle( + conversationId, + userContent, + async (convId, result) => { + // Check if conversation still exists and user hasn't manually renamed + const currentConv = await plugin.getConversationById(convId); + if (!currentConv) return; + + // Only apply AI title if user hasn't manually renamed (title still matches expected) + const userManuallyRenamed = currentConv.title !== expectedTitle; + + if (result.success && !userManuallyRenamed) { + await plugin.renameConversation(convId, result.title); + await plugin.updateConversation(convId, { titleGenerationStatus: 'success' }); + } else if (!userManuallyRenamed) { + // Keep existing title, mark as failed (only if user hasn't renamed) + await plugin.updateConversation(convId, { titleGenerationStatus: 'failed' }); + } else { + // User manually renamed, clear the status (user's choice takes precedence) + await plugin.updateConversation(convId, { titleGenerationStatus: undefined }); + } + this.updateHistoryDropdown(); + } + ); + } + + /** Formats a timestamp for display. */ + formatDate(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + + if (date.toDateString() === now.toDateString()) { + return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); + } + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } + + // ============================================ + // History Dropdown Rendering (for ClaudianView) + // ============================================ + + /** + * Renders the history dropdown content to a provided container. + * Used by ClaudianView to render the dropdown with custom selection callback. + */ + renderHistoryDropdown( + container: HTMLElement, + options: Omit, + ): void { + this.renderHistoryItems(container, { + ...options, + onRerender: () => this.renderHistoryDropdown(container, options), + }); + } +} diff --git a/src/features/chat/controllers/InputController.ts b/src/features/chat/controllers/InputController.ts new file mode 100644 index 0000000..fcb3a87 --- /dev/null +++ b/src/features/chat/controllers/InputController.ts @@ -0,0 +1,1822 @@ +import { Notice, setIcon } from 'obsidian'; + +import { + type BuiltInCommand, + detectBuiltInCommand, + isBuiltInCommandSupported, +} from '../../../core/commands/builtInCommands'; +import { ProviderRegistry } from '../../../core/providers/ProviderRegistry'; +import { + DEFAULT_CHAT_PROVIDER_ID, + type InstructionRefineService, + type ProviderCapabilities, + type ProviderId, + type TitleGenerationService, +} from '../../../core/providers/types'; +import type { ChatRuntime } from '../../../core/runtime/ChatRuntime'; +import { + cloneChatTurnRequest, + mergeQueuedChatTurns, + type QueuedChatTurn, +} from '../../../core/runtime/QueuedTurn'; +import type { + ApprovalCallbackOptions, + ApprovalDecisionOption, + ChatRuntimeQueryOptions, + ChatTurnRequest, +} from '../../../core/runtime/types'; +import { TOOL_EXIT_PLAN_MODE } from '../../../core/tools/toolNames'; +import type { ApprovalDecision, ChatMessage, ExitPlanModeDecision, StreamChunk } from '../../../core/types'; +import { ResumeSessionDropdown } from '../../../shared/components/ResumeSessionDropdown'; +import { InstructionModal } from '../../../shared/modals/InstructionConfirmModal'; +import type { BrowserSelectionContext } from '../../../utils/browser'; +import type { CanvasSelectionContext } from '../../../utils/canvas'; +import { extractUserDisplayContent } from '../../../utils/context'; +import { formatDurationMmSs } from '../../../utils/date'; +import type { EditorSelectionContext } from '../../../utils/editor'; +import { appendMarkdownSnippet } from '../../../utils/markdown'; +import type { FeatureHost } from '../../FeatureHost'; +import { COMPLETION_FLAVOR_WORDS } from '../constants'; +import { type InlineAskQuestionConfig, InlineAskUserQuestion } from '../rendering/InlineAskUserQuestion'; +import { InlineExitPlanMode } from '../rendering/InlineExitPlanMode'; +import { InlinePlanApproval,type PlanApprovalDecision } from '../rendering/InlinePlanApproval'; +import type { MessageRenderer } from '../rendering/MessageRenderer'; +import { setToolIcon, updateToolCallResult } from '../rendering/ToolCallRenderer'; +import type { SubagentManager } from '../services/SubagentManager'; +import type { ChatState } from '../state/ChatState'; +import type { QueuedMessage } from '../state/types'; +import type { FileContextManager } from '../ui/FileContext'; +import type { ImageContextManager } from '../ui/ImageContext'; +import type { AddExternalContextResult, McpServerSelector } from '../ui/InputToolbar'; +import type { InstructionModeManager } from '../ui/InstructionModeManager'; +import type { StatusPanel } from '../ui/StatusPanel'; +import type { BrowserSelectionController } from './BrowserSelectionController'; +import type { CanvasSelectionController } from './CanvasSelectionController'; +import type { ConversationController } from './ConversationController'; +import type { SelectionController } from './SelectionController'; +import type { StreamController } from './StreamController'; +import type { ActiveTurnOwner } from './TurnCoordinator'; +import { TurnCoordinator } from './TurnCoordinator'; + +const APPROVAL_OPTION_MAP: Record = { + 'Deny': 'deny', + 'Allow once': 'allow', + 'Always allow': 'allow-always', +}; + +const DEFAULT_APPROVAL_DECISION_OPTIONS: ApprovalDecisionOption[] = + Object.entries(APPROVAL_OPTION_MAP).map(([label, decision]) => ({ + label, + value: label, + decision, + })); + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +export interface InputControllerDeps { + plugin: FeatureHost; + state: ChatState; + renderer: MessageRenderer; + streamController: StreamController; + selectionController: SelectionController; + browserSelectionController?: BrowserSelectionController; + canvasSelectionController: CanvasSelectionController; + conversationController: ConversationController; + getInputEl: () => HTMLTextAreaElement; + getWelcomeEl: () => HTMLElement | null; + getMessagesEl: () => HTMLElement; + getFileContextManager: () => FileContextManager | null; + getImageContextManager: () => ImageContextManager | null; + getMcpServerSelector: () => McpServerSelector | null; + getExternalContextSelector: () => { + getExternalContexts: () => string[]; + addExternalContext: (path: string) => AddExternalContextResult; + } | null; + getInstructionModeManager: () => InstructionModeManager | null; + getInstructionRefineService: () => InstructionRefineService | null; + getTitleGenerationService: () => TitleGenerationService | null; + getStatusPanel: () => StatusPanel | null; + getInputContainerEl: () => HTMLElement; + generateId: () => string; + resetInputHeight: () => void; + getAuxiliaryModel?: () => string | null; + getAgentService?: () => ChatRuntime | null; + getSubagentManager: () => SubagentManager; + /** Tab-level provider fallback for blank tabs (derived from draft model). */ + getTabProviderId?: () => ProviderId; + /** Returns true if ready. */ + ensureServiceInitialized?: () => Promise; + openConversation?: (conversationId: string) => Promise; + onForkAll?: () => Promise; + restorePrePlanPermissionModeIfNeeded?: () => void | Promise; + turnOwner?: ActiveTurnOwner; +} + +export interface SendMessageOptions { + editorContextOverride?: EditorSelectionContext | null; + browserContextOverride?: BrowserSelectionContext | null; + canvasContextOverride?: CanvasSelectionContext | null; + content?: string; + images?: ChatMessage['images']; + turnRequestOverride?: ChatTurnRequest; +} + +export class InputController { + private deps: InputControllerDeps; + private pendingApprovalInline: InlineAskUserQuestion | null = null; + private pendingAskInline: InlineAskUserQuestion | null = null; + private pendingExitPlanModeInline: InlineExitPlanMode | null = null; + private pendingPlanApproval: InlinePlanApproval | null = null; + private pendingPlanApprovalInvalidated = false; + private activeResumeDropdown: ResumeSessionDropdown | null = null; + private inputContainerHideDepth = 0; + private steerInFlight = false; + private pendingSteerMessage: QueuedMessage | null = null; + private activeStreamingAssistantMessage: ChatMessage | null = null; + private pendingProviderUserMessages: Array<{ + displayContent: string; + persistedContent?: string; + currentNote?: string; + images?: ChatMessage['images']; + }> = []; + private sawInitialProviderUserMessage = false; + private awaitingProviderAssistantStart = false; + private readonly turnCoordinator: TurnCoordinator; + + constructor(deps: InputControllerDeps) { + this.deps = deps; + this.turnCoordinator = new TurnCoordinator( + (options) => this.executeSendMessage(options), + deps.turnOwner, + ); + } + + private getAgentService(): ChatRuntime | null { + return this.deps.getAgentService?.() ?? null; + } + + private getAuxiliaryModel(): string | null { + return this.deps.getAuxiliaryModel?.() + ?? this.getAgentService()?.getAuxiliaryModel?.() + ?? null; + } + + private syncInstructionRefineModelOverride( + instructionRefineService: InstructionRefineService, + ): void { + instructionRefineService.setModelOverride?.(this.getAuxiliaryModel() ?? undefined); + } + + private getActiveProviderId(): ProviderId { + const agentService = this.getAgentService(); + const conversationId = this.deps.state.currentConversationId; + if (!conversationId) { + return this.deps.getTabProviderId?.() ?? agentService?.providerId ?? DEFAULT_CHAT_PROVIDER_ID; + } + + if (agentService?.providerId) { + return agentService.providerId; + } + + return this.deps.plugin.getConversationSync(conversationId)?.providerId ?? DEFAULT_CHAT_PROVIDER_ID; + } + + private getActiveCapabilities(): ProviderCapabilities { + const providerId = this.getActiveProviderId(); + const agentService = this.getAgentService(); + if (agentService?.providerId === providerId) { + return agentService.getCapabilities(); + } + + return ProviderRegistry.getCapabilities(providerId); + } + + private isResumeSessionAtStillNeeded(resumeUuid: string, previousMessages: ChatMessage[]): boolean { + for (let i = previousMessages.length - 1; i >= 0; i--) { + if (previousMessages[i].role === 'assistant' && previousMessages[i].assistantMessageId === resumeUuid) { + // Still needed only if no messages follow the resume point + return i === previousMessages.length - 1; + } + } + return false; + } + + // ============================================ + // Message Sending + // ============================================ + + async sendMessage(options?: SendMessageOptions): Promise { + await this.turnCoordinator.run(options); + } + + private async executeSendMessage(options?: SendMessageOptions): Promise { + const { + plugin, + state, + renderer, + streamController, + selectionController, + browserSelectionController, + canvasSelectionController, + conversationController + } = this.deps; + + // During conversation creation/switching, don't send - input is preserved so user can retry + if (state.isCreatingConversation || state.isSwitchingConversation) return; + + const inputEl = this.deps.getInputEl(); + const imageContextManager = this.deps.getImageContextManager(); + const fileContextManager = this.deps.getFileContextManager(); + + const contentOverride = options?.content; + const shouldUseInput = contentOverride === undefined; + const content = (contentOverride ?? inputEl.value).trim(); + const imageOverride = options?.images; + const hasImages = imageOverride !== undefined + ? imageOverride.length > 0 + : (imageContextManager?.hasImages() ?? false); + if (!content && !hasImages) return; + + // Check for built-in commands first (e.g., /clear, /new, /add-dir) + const builtInCmd = detectBuiltInCommand(content); + if (builtInCmd) { + if (shouldUseInput) { + inputEl.value = ''; + this.deps.resetInputHeight(); + } + await this.executeBuiltInCommand(builtInCmd.command, builtInCmd.args); + return; + } + + // If agent is working, queue the message instead of dropping it + if (state.isStreaming) { + const images = hasImages + ? [...(imageOverride ?? imageContextManager?.getAttachedImages() ?? [])] + : undefined; + const editorContext = selectionController.getContext(); + const browserContext = browserSelectionController?.getContext() ?? null; + const canvasContext = canvasSelectionController.getContext(); + const { displayContent, turnRequest } = this.buildTurnSubmission({ + content, + images, + editorContextOverride: editorContext, + browserContextOverride: browserContext, + canvasContextOverride: canvasContext, + }); + state.queuedMessage = this.mergeQueuedMessages( + state.queuedMessage, + this.createQueuedMessage(displayContent, turnRequest), + ); + + if (shouldUseInput) { + inputEl.value = ''; + this.deps.resetInputHeight(); + } + if (shouldUseInput) { + imageContextManager?.clearImages(); + } + this.updateQueueIndicator(); + return; + } + + if (shouldUseInput) { + inputEl.value = ''; + this.deps.resetInputHeight(); + } + state.isStreaming = true; + state.cancelRequested = false; + state.ignoreUsageUpdates = false; // Allow usage updates for new query + this.deps.getSubagentManager().resetSpawnedCount(); + state.autoScrollEnabled = plugin.settings.enableAutoScroll ?? true; // Reset auto-scroll based on setting + const streamGeneration = state.bumpStreamGeneration(); + + // Hide welcome message when sending first message + const welcomeEl = this.deps.getWelcomeEl(); + if (welcomeEl) { + welcomeEl.addClass('claudian-hidden'); + } + + fileContextManager?.startSession(); + + // Slash commands are passed directly to SDK for handling + // SDK handles expansion, $ARGUMENTS, @file references, and frontmatter options + const images = imageOverride ?? imageContextManager?.getAttachedImages() ?? []; + const imagesForMessage = images.length > 0 ? [...images] : undefined; + const isCompact = /^\/compact(\s|$)/i.test(content); + + // Only clear images if we consumed user input (not for programmatic content override) + if (shouldUseInput) { + imageContextManager?.clearImages(); + } + + const turnSubmission = options?.turnRequestOverride + ? { + displayContent: content, + turnRequest: cloneChatTurnRequest(options.turnRequestOverride), + } + : this.buildTurnSubmission({ + content, + images: imagesForMessage, + editorContextOverride: options?.editorContextOverride, + browserContextOverride: options?.browserContextOverride, + canvasContextOverride: options?.canvasContextOverride, + }); + const { displayContent, turnRequest } = turnSubmission; + const messagesBeforeTurn = state.messages; + const hadPendingConversationSave = state.hasPendingConversationSave; + + fileContextManager?.markCurrentNoteSent(); + + const userMsg: ChatMessage = { + id: this.deps.generateId(), + role: 'user', + content: displayContent, + displayContent, // Original user input (for UI display) + timestamp: Date.now(), + images: imagesForMessage, + }; + state.addMessage(userMsg); + state.hasPendingConversationSave = true; + renderer.addMessage(userMsg); + + try { + await this.triggerTitleGeneration(); + } catch (error) { + this.restoreMessageToInput(this.createQueuedMessage(displayContent, turnRequest)); + this.rollbackFailedTurn(messagesBeforeTurn, hadPendingConversationSave); + throw error; + } + + const assistantMsg: ChatMessage = { + id: this.deps.generateId(), + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [], + contentBlocks: [], + }; + state.addMessage(assistantMsg); + this.activeStreamingAssistantMessage = assistantMsg; + this.activateStreamingAssistantMessage(assistantMsg); + this.pendingProviderUserMessages = [{ + displayContent, + images: imagesForMessage, + }]; + this.sawInitialProviderUserMessage = false; + this.awaitingProviderAssistantStart = true; + + streamController.showThinkingIndicator( + isCompact ? 'Compacting...' : undefined, + isCompact ? 'claudian-thinking--compact' : undefined, + ); + state.responseStartTime = performance.now(); + + let wasInterrupted = false; + let wasInvalidated = false; + let didEnqueueToSdk = false; + let planCompleted = false; + + // Lazy initialization: ensure service is ready before first query + if (this.deps.ensureServiceInitialized) { + const ready = await this.deps.ensureServiceInitialized(); + if (!ready) { + new Notice('Failed to initialize agent service. Please try again.'); + this.restoreMessageToInput(this.createQueuedMessage(displayContent, turnRequest)); + this.rollbackFailedTurn(messagesBeforeTurn, hadPendingConversationSave); + this.activeStreamingAssistantMessage = null; + this.resetProviderMessageBoundaryState(); + return; + } + } + + const agentService = this.getAgentService(); + if (!agentService) { + new Notice('Agent service not available. Please reload the plugin.'); + this.restoreMessageToInput(this.createQueuedMessage(displayContent, turnRequest)); + this.rollbackFailedTurn(messagesBeforeTurn, hadPendingConversationSave); + this.activeStreamingAssistantMessage = null; + this.resetProviderMessageBoundaryState(); + return; + } + + // Restore pendingResumeAt from persisted conversation state (survives plugin reload) + const conversationIdForSend = state.currentConversationId; + if (conversationIdForSend) { + const conv = plugin.getConversationSync(conversationIdForSend); + if (conv?.resumeAtMessageId) { + if (this.isResumeSessionAtStillNeeded(conv.resumeAtMessageId, state.messages.slice(0, -2))) { + agentService.setResumeCheckpoint(conv.resumeAtMessageId); + } else { + try { + await plugin.updateConversation(conversationIdForSend, { resumeAtMessageId: undefined }); + } catch { + // Best-effort — don't block send + } + } + } + } + + try { + const preparedTurn = agentService.prepareTurn(turnRequest); + userMsg.content = preparedTurn.persistedContent; + userMsg.currentNote = preparedTurn.isCompact + ? undefined + : preparedTurn.request.currentNotePath; + + // Pass history WITHOUT current turn (userMsg + assistantMsg we just added) + // This prevents duplication when rebuilding context for new sessions + const previousMessages = state.messages.slice(0, -2); + const selectedModel = this.getAuxiliaryModel(); + const queryOptions: ChatRuntimeQueryOptions | undefined = selectedModel + ? { model: selectedModel } + : undefined; + for await (const chunk of agentService.query(preparedTurn, previousMessages, queryOptions)) { + if (state.streamGeneration !== streamGeneration) { + wasInvalidated = true; + break; + } + if (state.cancelRequested) { + wasInterrupted = true; + break; + } + + if (chunk.type === 'error' && chunk.code === 'provider_session_missing') { + const retryMessage = this.createQueuedMessage(displayContent, { + ...turnRequest, + images: imagesForMessage ?? turnRequest.images, + }); + const pendingMessagesToRestore = this.mergePendingMessages( + this.pendingSteerMessage, + state.queuedMessage, + ); + const composerDraftToRestore = this.captureComposerDraft(); + const staleConversationId = state.currentConversationId; + const resolution = staleConversationId + ? await plugin.handleMissingProviderSession( + staleConversationId, + chunk.providerSessionId, + ) + : 'not_found'; + if (resolution === 'deleted') { + this.restoreMessageToInput(composerDraftToRestore, { mergeWithComposer: true }); + this.restoreMessageToInput(pendingMessagesToRestore, { mergeWithComposer: true }); + this.restoreMessageToInput(retryMessage, { mergeWithComposer: true }); + } else { + this.restoreMessageToInput(retryMessage, { mergeWithComposer: true }); + this.restorePendingSteerMessageToQueue(); + this.rollbackFailedTurn(messagesBeforeTurn, hadPendingConversationSave); + } + const notice = resolution === 'deleted' + ? 'The provider session no longer exists. Its Claudian record was removed; send again to start a new session.' + : resolution === 'reset' + ? 'The provider session no longer exists. Claudian preserved the recoverable history; send again to rebuild the session.' + : resolution === 'preserved' + ? 'The provider session no longer exists. Claudian preserved its record because the remaining history could not be verified.' + : 'The provider session no longer exists. Send again to start a new session.'; + new Notice(notice); + wasInvalidated = true; + break; + } + + if (await this.handleProviderMessageBoundaryChunk(chunk)) { + continue; + } + + await streamController.handleStreamChunk( + chunk, + this.activeStreamingAssistantMessage ?? assistantMsg, + ); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + await streamController.appendText(`\n\n**Error:** ${errorMsg}`); + } finally { + const finalAssistantMsg = this.activeStreamingAssistantMessage ?? assistantMsg; + const turnMetadata = agentService.consumeTurnMetadata(); + userMsg.userMessageId = turnMetadata.userMessageId ?? userMsg.userMessageId; + finalAssistantMsg.assistantMessageId = turnMetadata.assistantMessageId ?? finalAssistantMsg.assistantMessageId; + didEnqueueToSdk = didEnqueueToSdk || turnMetadata.wasSent === true; + planCompleted = planCompleted || turnMetadata.planCompleted === true; + + // ALWAYS clear the timer interval, even on stream invalidation (prevents memory leaks) + state.clearFlavorTimerInterval(); + + // Skip remaining cleanup if stream was invalidated (tab closed or conversation switched) + if (!wasInvalidated && state.streamGeneration === streamGeneration) { + const didCancelThisTurn = wasInterrupted || state.cancelRequested; + if (didCancelThisTurn && !state.pendingNewSessionPlan) { + await streamController.appendText('\n\nInterrupted · What should Claudian do instead?'); + } + streamController.hideThinkingIndicator(); + state.isStreaming = false; + state.cancelRequested = false; + this.restorePendingSteerMessageToQueue(); + + // Capture response duration before resetting state (skip for interrupted responses and compaction) + const hasCompactBoundary = finalAssistantMsg.contentBlocks?.some(b => b.type === 'context_compacted'); + if (!didCancelThisTurn && !hasCompactBoundary) { + const durationSeconds = state.responseStartTime + ? Math.floor((performance.now() - state.responseStartTime) / 1000) + : 0; + if (durationSeconds > 0) { + const flavorWord = + COMPLETION_FLAVOR_WORDS[Math.floor(Math.random() * COMPLETION_FLAVOR_WORDS.length)]; + finalAssistantMsg.durationSeconds = durationSeconds; + finalAssistantMsg.durationFlavorWord = flavorWord; + // Add footer to live message in DOM + if (state.currentContentEl) { + const footerEl = state.currentContentEl.createDiv({ cls: 'claudian-response-footer' }); + footerEl.createSpan({ + text: `* ${flavorWord} for ${formatDurationMmSs(durationSeconds)}`, + cls: 'claudian-baked-duration', + }); + } + } + } + + state.currentContentEl = null; + + await streamController.finalizeCurrentThinkingBlock(finalAssistantMsg); + await streamController.finalizeCurrentTextBlock(finalAssistantMsg); + this.deps.getSubagentManager().resetStreamingState(); + + // Auto-hide completed todo panel on response end + // Panel reappears only when new TodoWrite tool is called + if (state.currentTodos && state.currentTodos.every(t => t.status === 'completed')) { + state.currentTodos = null; + } + this.syncScrollToBottomAfterRenderUpdates(); + + // approve-new-session: the tool_result chunk is dropped because cancelRequested + // was set before the stream loop could process it — manually set the result so + // the saved conversation renders correctly when revisited + if (state.pendingNewSessionPlan && finalAssistantMsg.toolCalls) { + for (const tc of finalAssistantMsg.toolCalls) { + if (tc.name === TOOL_EXIT_PLAN_MODE && !tc.result) { + tc.status = 'completed'; + tc.result = 'User approved the plan and started a new session.'; + updateToolCallResult(tc.id, tc, state.toolCallElements); + } + } + } + + // Provider-agnostic post-plan approval: show UI and await decision before save/auto-send + let planAutoSendContent: string | null = null; + let planApprovalInvalidated = false; + let shouldProcessQueuedMessage = true; + if (planCompleted && !didCancelThisTurn) { + const { decision, invalidated } = await this.showPlanApproval(); + + // Re-check invalidation after async approval prompt + if (state.streamGeneration !== streamGeneration || invalidated) { + planApprovalInvalidated = true; + } else if (decision?.type === 'implement') { + await this.deps.restorePrePlanPermissionModeIfNeeded?.(); + planAutoSendContent = 'Implement the plan.'; + } else if (decision?.type === 'revise') { + // Keep plan mode active, populate input with feedback text + this.deps.getInputEl().value = decision.text; + shouldProcessQueuedMessage = false; + } else { + // cancel or null (dismissed) + await this.deps.restorePrePlanPermissionModeIfNeeded?.(); + } + } + + if (!planApprovalInvalidated) { + // Only clear resumeAtMessageId if enqueue succeeded; preserve checkpoint on failure for retry + const saveExtras = didEnqueueToSdk ? { resumeAtMessageId: undefined } : undefined; + await conversationController.save(true, saveExtras); + + const userMsgIndex = state.messages.indexOf(userMsg); + renderer.refreshActionButtons(userMsg, state.messages, userMsgIndex >= 0 ? userMsgIndex : undefined); + + // Auto-implement takes precedence over both approve-new-session and queued input + if (planAutoSendContent) { + this.deps.getInputEl().value = planAutoSendContent; + this.sendMessage().catch(() => {}); + } else { + // approve-new-session: create fresh conversation and send plan content + // Must be inside the invalidation guard — if the tab was closed or + // conversation switched, we must not create a new session on stale state. + const planContent = state.pendingNewSessionPlan; + if (planContent) { + state.pendingNewSessionPlan = null; + await conversationController.createNew(); + this.deps.getInputEl().value = planContent; + this.sendMessage().catch(() => { + // sendMessage() handles its own errors internally; this prevents + // unhandled rejection if an unexpected error slips through. + }); + } else if (shouldProcessQueuedMessage) { + this.processQueuedMessage(); + } + } + } + } + + if (wasInvalidated) { + this.clearPendingSteerState(); + this.updateQueueIndicator(); + } + + this.activeStreamingAssistantMessage = null; + this.resetProviderMessageBoundaryState(); + } + } + + // ============================================ + // Queue Management + // ============================================ + + updateQueueIndicator(): void { + const { state } = this.deps; + const indicatorEl = state.queueIndicatorEl; + if (!indicatorEl) return; + + indicatorEl.empty(); + + const visibleQueuedMessage = state.queuedMessage ?? this.pendingSteerMessage; + if (visibleQueuedMessage) { + const isPendingSteerOnly = !state.queuedMessage && !!this.pendingSteerMessage; + indicatorEl.createSpan({ + cls: 'claudian-queue-indicator-text', + text: `${isPendingSteerOnly ? '⌙ Steering: ' : '⌙ Queued: '}${this.getQueuedMessageDisplay(visibleQueuedMessage)}`, + }); + + if (state.queuedMessage) { + const actionsEl = indicatorEl.createDiv({ cls: 'claudian-queue-indicator-actions' }); + + if (this.canSteerQueuedMessage()) { + const steerButton = actionsEl.createEl('button', { + cls: 'claudian-queue-indicator-action', + text: this.steerInFlight ? 'Steering...' : 'Steer Now', + }); + steerButton.setAttribute('type', 'button'); + if (this.steerInFlight) { + steerButton.setAttribute('disabled', 'true'); + } else { + steerButton.addEventListener('click', (event) => { + event.stopPropagation(); + void this.steerQueuedMessage(); + }); + } + } + + const editButton = this.createQueueIconButton( + actionsEl, + 'pencil', + 'Edit queued message', + ); + editButton.addEventListener('click', (event) => { + event.stopPropagation(); + this.withdrawQueuedMessageToComposer(); + }); + + const discardButton = this.createQueueIconButton( + actionsEl, + 'trash-2', + 'Discard queued message', + ); + discardButton.addEventListener('click', (event) => { + event.stopPropagation(); + this.clearQueuedMessage(); + }); + } + + indicatorEl.addClass('claudian-visible-flex'); + indicatorEl.removeClass('claudian-hidden'); + return; + } + + indicatorEl.removeClass('claudian-visible-flex'); + indicatorEl.addClass('claudian-hidden'); + } + + clearQueuedMessage(): void { + const { state } = this.deps; + state.queuedMessage = null; + this.updateQueueIndicator(); + } + + withdrawQueuedMessageToComposer(): void { + const { state } = this.deps; + if (!state.queuedMessage) return; + + const queuedMessage = this.cloneQueuedMessage(state.queuedMessage); + state.queuedMessage = null; + this.restoreMessageToInput(queuedMessage, { mergeWithComposer: true }); + this.updateQueueIndicator(); + } + + private restoreMessageToInput( + message: QueuedMessage | null, + options: { mergeWithComposer?: boolean } = {}, + ): void { + if (!message) return; + + const { content, images } = message; + const inputEl = this.deps.getInputEl(); + const currentContent = options.mergeWithComposer ? inputEl.value.trim() : ''; + inputEl.value = currentContent + ? appendMarkdownSnippet(content, currentContent) + : content; + + const imageContextManager = this.deps.getImageContextManager(); + const currentImages = options.mergeWithComposer + ? (imageContextManager?.getAttachedImages() ?? []) + : []; + const restoredImages = [...(images ?? []), ...currentImages]; + if (restoredImages.length > 0) { + imageContextManager?.setImages(restoredImages); + } + this.deps.resetInputHeight(); + inputEl.focus(); + } + + private captureComposerDraft(): QueuedMessage | null { + const content = this.deps.getInputEl().value; + const attachedImages = this.deps.getImageContextManager()?.getAttachedImages() ?? []; + const images = attachedImages.length > 0 ? [...attachedImages] : undefined; + if (!content.trim() && !images) { + return null; + } + + return this.createQueuedMessage(content, { + text: content, + images, + }); + } + + private restorePendingMessagesToInput(): void { + const { state } = this.deps; + const combinedMessage = this.mergePendingMessages( + this.pendingSteerMessage, + state.queuedMessage, + ); + this.restoreMessageToInput(combinedMessage, { mergeWithComposer: true }); + state.queuedMessage = null; + this.clearPendingSteerState(); + this.updateQueueIndicator(); + } + + private processQueuedMessage(): void { + const { state } = this.deps; + if (!state.queuedMessage) return; + + const queuedMessage = this.cloneQueuedMessage(state.queuedMessage); + state.queuedMessage = null; + this.updateQueueIndicator(); + + window.setTimeout( + () => { + void this.sendMessage({ + content: queuedMessage.content, + images: queuedMessage.images, + turnRequestOverride: this.toQueuedChatTurn(queuedMessage).request, + }); + }, + 0 + ); + } + + private buildTurnSubmission(options: { + content: string; + images?: ChatMessage['images']; + editorContextOverride?: EditorSelectionContext | null; + browserContextOverride?: BrowserSelectionContext | null; + canvasContextOverride?: CanvasSelectionContext | null; + }): { + displayContent: string; + turnRequest: ChatTurnRequest; + } { + const { + selectionController, + browserSelectionController, + canvasSelectionController, + } = this.deps; + + const fileContextManager = this.deps.getFileContextManager(); + const mcpServerSelector = this.deps.getMcpServerSelector(); + const externalContextSelector = this.deps.getExternalContextSelector(); + + const currentNotePath = fileContextManager?.getCurrentNotePath() || null; + const shouldSendCurrentNote = fileContextManager?.shouldSendCurrentNote(currentNotePath) ?? false; + + const editorContext = options.editorContextOverride !== undefined + ? options.editorContextOverride + : selectionController.getContext(); + const browserContext = options.browserContextOverride !== undefined + ? options.browserContextOverride + : (browserSelectionController?.getContext() ?? null); + const canvasContext = options.canvasContextOverride !== undefined + ? options.canvasContextOverride + : canvasSelectionController.getContext(); + + const externalContextPaths = externalContextSelector?.getExternalContexts(); + const isCompact = /^\/compact(\s|$)/i.test(options.content); + const transformedText = !isCompact && fileContextManager + ? fileContextManager.transformContextMentions(options.content) + : options.content; + const enabledMcpServers = mcpServerSelector?.getEnabledServers(); + + return { + displayContent: options.content, + turnRequest: { + text: transformedText, + images: options.images, + currentNotePath: shouldSendCurrentNote && currentNotePath ? currentNotePath : undefined, + editorSelection: editorContext, + browserSelection: browserContext, + canvasSelection: canvasContext, + externalContextPaths: externalContextPaths && externalContextPaths.length > 0 + ? externalContextPaths + : undefined, + enabledMcpServers: enabledMcpServers && enabledMcpServers.size > 0 + ? enabledMcpServers + : undefined, + }, + }; + } + + private getQueuedMessageDisplay(message: QueuedMessage | null): string { + if (!message) { + return ''; + } + + const rawContent = message.content.trim(); + const preview = rawContent.length > 40 + ? rawContent.slice(0, 40) + '...' + : rawContent; + const hasImages = (message.images?.length ?? 0) > 0; + + if (hasImages) { + return preview ? `${preview} [images]` : '[images]'; + } + + return preview; + } + + private createQueueIconButton( + parentEl: HTMLElement, + icon: string, + label: string, + ): HTMLElement { + const button = parentEl.createEl('button', { + cls: 'claudian-queue-indicator-icon-action', + attr: { + 'aria-label': label, + title: label, + type: 'button', + }, + }); + setIcon(button, icon); + return button; + } + + private canSteerQueuedMessage(): boolean { + const agentService = this.getAgentService(); + return this.deps.state.isStreaming + && this.getActiveCapabilities().supportsTurnSteer === true + && typeof agentService?.steer === 'function'; + } + + private cloneQueuedMessage(message: QueuedMessage): QueuedMessage { + return { + ...message, + images: message.images ? [...message.images] : undefined, + turnRequest: message.turnRequest + ? cloneChatTurnRequest(message.turnRequest) + : undefined, + }; + } + + private createQueuedMessage(displayContent: string, turnRequest: ChatTurnRequest): QueuedMessage { + const request = cloneChatTurnRequest(turnRequest); + return { + content: displayContent, + images: request.images, + editorContext: request.editorSelection ?? null, + browserContext: request.browserSelection ?? null, + canvasContext: request.canvasSelection ?? null, + turnRequest: request, + }; + } + + private toQueuedChatTurn(message: QueuedMessage): QueuedChatTurn { + if (message.turnRequest) { + return { + displayContent: message.content, + request: cloneChatTurnRequest(message.turnRequest), + }; + } + + return { + displayContent: message.content, + request: { + text: message.content, + images: message.images ? [...message.images] : undefined, + editorSelection: message.editorContext, + browserSelection: message.browserContext ?? null, + canvasSelection: message.canvasContext, + }, + }; + } + + private mergePendingMessages( + first: QueuedMessage | null, + second: QueuedMessage | null, + ): QueuedMessage | null { + if (first && second) { + return this.mergeQueuedMessages(first, second); + } + + if (first) { + return this.cloneQueuedMessage(first); + } + + if (second) { + return this.cloneQueuedMessage(second); + } + + return null; + } + + private clearPendingSteerState(): void { + this.pendingSteerMessage = null; + this.steerInFlight = false; + } + + private restorePendingSteerMessageToQueue(): void { + if (!this.pendingSteerMessage) { + return; + } + + const { state } = this.deps; + const pendingSteerMessage = this.cloneQueuedMessage(this.pendingSteerMessage); + this.clearPendingSteerState(); + state.queuedMessage = state.queuedMessage + ? this.mergeQueuedMessages(pendingSteerMessage, state.queuedMessage) + : pendingSteerMessage; + this.updateQueueIndicator(); + } + + private mergeQueuedMessages( + existing: QueuedMessage | null, + incoming: QueuedMessage, + ): QueuedMessage { + if (!existing) { + return this.cloneQueuedMessage(incoming); + } + + const mergedTurn = mergeQueuedChatTurns( + this.toQueuedChatTurn(existing), + this.toQueuedChatTurn(incoming), + ); + return this.createQueuedMessage(mergedTurn.displayContent, mergedTurn.request); + } + + private async steerQueuedMessage(): Promise { + if (this.steerInFlight) { + return; + } + + const { state } = this.deps; + const agentService = this.getAgentService(); + if (!state.queuedMessage || !this.canSteerQueuedMessage() || !agentService?.steer) { + return; + } + + const queuedMessage = this.cloneQueuedMessage(state.queuedMessage); + state.queuedMessage = null; + this.pendingSteerMessage = queuedMessage; + this.steerInFlight = true; + this.updateQueueIndicator(); + + try { + const { displayContent, request } = this.toQueuedChatTurn(queuedMessage); + + const preparedTurn = agentService.prepareTurn(request); + const accepted = await agentService.steer(preparedTurn); + if (state.cancelRequested || !this.pendingSteerMessage) { + return; + } + if (!accepted) { + this.restoreQueuedMessageAfterSteerFailure(queuedMessage); + return; + } + + this.deps.getFileContextManager()?.markCurrentNoteSent(); + + this.pendingProviderUserMessages.push({ + displayContent, + persistedContent: preparedTurn.persistedContent, + currentNote: preparedTurn.isCompact + ? undefined + : preparedTurn.request.currentNotePath, + images: request.images, + }); + } catch { + this.restoreQueuedMessageAfterSteerFailure(queuedMessage); + new Notice('Failed to steer the queued Codex message. It is still available.'); + } + } + + private restoreQueuedMessageAfterSteerFailure( + message: QueuedMessage, + ): void { + const { state } = this.deps; + this.clearPendingSteerState(); + if (state.cancelRequested) { + this.updateQueueIndicator(); + return; + } + + if (state.isStreaming) { + state.queuedMessage = state.queuedMessage + ? this.mergeQueuedMessages(message, state.queuedMessage) + : message; + this.updateQueueIndicator(); + return; + } + + this.restoreMessageToInput(message, { mergeWithComposer: true }); + this.updateQueueIndicator(); + } + + private activateStreamingAssistantMessage(message: ChatMessage): void { + const { state, renderer } = this.deps; + const msgEl = renderer.addMessage(message); + const contentEl = msgEl.querySelector('.claudian-message-content'); + + if (!contentEl) { + return; + } + + if (!state.currentContentEl) { + state.toolCallElements.clear(); + } + + state.currentContentEl = contentEl; + state.currentTextEl = null; + state.currentTextContent = ''; + state.currentThinkingState = null; + } + + private resetProviderMessageBoundaryState(): void { + this.pendingProviderUserMessages = []; + this.sawInitialProviderUserMessage = false; + this.awaitingProviderAssistantStart = false; + } + + private async handleProviderMessageBoundaryChunk(chunk: StreamChunk): Promise { + switch (chunk.type) { + case 'user_message_start': + await this.handleProviderUserMessageStart(chunk); + return true; + case 'assistant_message_start': + await this.handleProviderAssistantMessageStart(); + return true; + default: + return false; + } + } + + private async handleProviderUserMessageStart( + chunk: Extract, + ): Promise { + const expected = this.pendingProviderUserMessages.shift(); + if (!this.sawInitialProviderUserMessage) { + this.sawInitialProviderUserMessage = true; + return; + } + + this.clearPendingSteerState(); + this.updateQueueIndicator(); + + const previousAssistant = this.activeStreamingAssistantMessage; + const shouldDiscardPlaceholder = this.shouldDiscardPendingAssistantPlaceholder(previousAssistant); + if (previousAssistant) { + if (shouldDiscardPlaceholder) { + this.discardStreamingAssistantMessage(previousAssistant.id); + } else { + await this.deps.streamController.finalizeCurrentThinkingBlock(previousAssistant); + await this.deps.streamController.finalizeCurrentTextBlock(previousAssistant); + } + } + this.deps.streamController.hideThinkingIndicator(); + + const displayContent = expected?.displayContent ?? chunk.content; + const persistedContent = expected?.persistedContent ?? displayContent; + const images = expected?.images; + if (displayContent || (images?.length ?? 0) > 0) { + const userMessage: ChatMessage = { + id: this.deps.generateId(), + role: 'user', + content: persistedContent, + displayContent, + timestamp: Date.now(), + currentNote: expected?.currentNote, + images, + }; + this.deps.state.addMessage(userMessage); + this.deps.renderer.addMessage(userMessage); + } + + const assistantMessage: ChatMessage = { + id: this.deps.generateId(), + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [], + contentBlocks: [], + }; + this.deps.state.addMessage(assistantMessage); + this.activeStreamingAssistantMessage = assistantMessage; + this.activateStreamingAssistantMessage(assistantMessage); + this.deps.streamController.showThinkingIndicator(); + this.deps.state.responseStartTime = performance.now(); + this.awaitingProviderAssistantStart = true; + } + + private async handleProviderAssistantMessageStart(): Promise { + if (this.awaitingProviderAssistantStart) { + this.awaitingProviderAssistantStart = false; + return; + } + + const previousAssistant = this.activeStreamingAssistantMessage; + if (previousAssistant) { + await this.deps.streamController.finalizeCurrentThinkingBlock(previousAssistant); + await this.deps.streamController.finalizeCurrentTextBlock(previousAssistant); + } + + const assistantMessage: ChatMessage = { + id: this.deps.generateId(), + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [], + contentBlocks: [], + }; + this.deps.state.addMessage(assistantMessage); + this.activeStreamingAssistantMessage = assistantMessage; + this.activateStreamingAssistantMessage(assistantMessage); + this.deps.streamController.showThinkingIndicator(); + } + + private shouldDiscardPendingAssistantPlaceholder(message: ChatMessage | null): boolean { + return this.awaitingProviderAssistantStart + && !!message + && !message.content.trim() + && (message.toolCalls?.length ?? 0) === 0 + && (message.contentBlocks?.length ?? 0) === 0; + } + + private discardStreamingAssistantMessage(messageId: string): void { + const { state, renderer } = this.deps; + state.messages = state.messages.filter((message) => message.id !== messageId); + renderer.removeMessage(messageId); + state.currentContentEl = null; + state.currentTextEl = null; + state.currentTextContent = ''; + state.currentThinkingState = null; + } + + private rollbackFailedTurn( + messagesBeforeTurn: ChatMessage[], + hadPendingConversationSave: boolean, + ): void { + const { state, renderer, streamController } = this.deps; + const retainedMessageIds = new Set(messagesBeforeTurn.map(message => message.id)); + for (const message of state.messages) { + if (!retainedMessageIds.has(message.id)) { + renderer.removeMessage(message.id); + } + } + + state.messages = messagesBeforeTurn; + state.hasPendingConversationSave = hadPendingConversationSave; + streamController.hideThinkingIndicator(); + state.isStreaming = false; + state.cancelRequested = false; + state.currentContentEl = null; + state.currentTextEl = null; + state.currentTextContent = ''; + state.currentThinkingState = null; + state.responseStartTime = null; + this.deps.getSubagentManager().resetStreamingState(); + + if (messagesBeforeTurn.length === 0) { + this.deps.getWelcomeEl()?.removeClass('claudian-hidden'); + } + } + + // ============================================ + // Title Generation + // ============================================ + + /** + * Triggers AI title generation after first user message. + * Handles setting fallback title, firing async generation, and updating UI. + */ + private async triggerTitleGeneration(): Promise { + const { plugin, state, conversationController } = this.deps; + + if (state.messages.length !== 1) { + return; + } + + if (!state.currentConversationId) { + const sessionId = this.getAgentService()?.getSessionId() ?? undefined; + const selectedModel = this.getAuxiliaryModel() ?? undefined; + const conversation = await plugin.createConversation({ + providerId: this.getActiveProviderId(), + sessionId, + ...(selectedModel ? { selectedModel } : {}), + }); + state.currentConversationId = conversation.id; + } + + // Find first user message by role (not by index) + const firstUserMsg = state.messages.find(m => m.role === 'user'); + + if (!firstUserMsg) { + return; + } + + const userContent = firstUserMsg.displayContent + ?? extractUserDisplayContent(firstUserMsg.content) + ?? firstUserMsg.content; + + // Set immediate fallback title + const fallbackTitle = conversationController.generateFallbackTitle(userContent); + await plugin.renameConversation(state.currentConversationId, fallbackTitle); + + if (!plugin.settings.enableAutoTitleGeneration) { + return; + } + + // Fire async AI title generation only if service available + const titleService = this.deps.getTitleGenerationService(); + if (!titleService) { + // No titleService, just keep the fallback title with no status + return; + } + + // Mark as pending only when we're actually starting generation + await plugin.updateConversation(state.currentConversationId, { titleGenerationStatus: 'pending' }); + conversationController.updateHistoryDropdown(); + + const convId = state.currentConversationId; + const expectedTitle = fallbackTitle; // Store to check if user renamed during generation + + titleService.generateTitle( + convId, + userContent, + async (conversationId, result) => { + // Check if conversation still exists and user hasn't manually renamed + const currentConv = await plugin.getConversationById(conversationId); + if (!currentConv) return; + + // Only apply AI title if user hasn't manually renamed (title still matches fallback) + const userManuallyRenamed = currentConv.title !== expectedTitle; + + if (result.success && !userManuallyRenamed) { + await plugin.renameConversation(conversationId, result.title); + await plugin.updateConversation(conversationId, { titleGenerationStatus: 'success' }); + } else if (!userManuallyRenamed) { + // Keep fallback title, mark as failed (only if user hasn't renamed) + await plugin.updateConversation(conversationId, { titleGenerationStatus: 'failed' }); + } else { + // User manually renamed, clear the status (user's choice takes precedence) + await plugin.updateConversation(conversationId, { titleGenerationStatus: undefined }); + } + conversationController.updateHistoryDropdown(); + } + ).catch(() => { + // Silently ignore title generation errors + }); + } + + // ============================================ + // Streaming Control + // ============================================ + + cancelStreaming(): void { + const { state, streamController } = this.deps; + if (!state.isStreaming) return; + state.cancelRequested = true; + // Restore queued message to input instead of discarding + this.restorePendingMessagesToInput(); + this.getAgentService()?.cancel(); + streamController.hideThinkingIndicator(); + } + + private syncScrollToBottomAfterRenderUpdates(): void { + const { plugin, state } = this.deps; + if (!(plugin.settings.enableAutoScroll ?? true)) return; + if (!state.autoScrollEnabled) return; + + window.requestAnimationFrame(() => { + if (!(this.deps.plugin.settings.enableAutoScroll ?? true)) return; + if (!this.deps.state.autoScrollEnabled) return; + + const messagesEl = this.deps.getMessagesEl(); + messagesEl.scrollTop = messagesEl.scrollHeight; + }); + } + + // ============================================ + // Instruction Mode + // ============================================ + + async handleInstructionSubmit(rawInstruction: string): Promise { + const { plugin } = this.deps; + + const instructionRefineService = this.deps.getInstructionRefineService(); + const instructionModeManager = this.deps.getInstructionModeManager(); + + if (!instructionRefineService) return; + + const existingPrompt = plugin.settings.systemPrompt; + let modal: InstructionModal | null = null; + let wasCancelled = false; + + try { + modal = new InstructionModal( + plugin.app, + rawInstruction, + { + onAccept: (finalInstruction) => { + void (async (): Promise => { + await plugin.mutateSettings((settings) => { + settings.systemPrompt = appendMarkdownSnippet( + settings.systemPrompt, + finalInstruction, + ); + }); + + new Notice('Instruction added to custom system prompt'); + instructionModeManager?.clear(); + })(); + }, + onReject: () => { + wasCancelled = true; + instructionRefineService.cancel(); + instructionModeManager?.clear(); + }, + onClarificationSubmit: async (response) => { + this.syncInstructionRefineModelOverride(instructionRefineService); + const result = await instructionRefineService.continueConversation(response); + + if (wasCancelled) { + return; + } + + if (!result.success) { + if (result.error === 'Cancelled') { + return; + } + new Notice(result.error || 'Failed to process response'); + modal?.showError(result.error || 'Failed to process response'); + return; + } + + if (result.clarification) { + modal?.showClarification(result.clarification); + } else if (result.refinedInstruction) { + modal?.showConfirmation(result.refinedInstruction); + } + } + } + ); + modal.open(); + + this.syncInstructionRefineModelOverride(instructionRefineService); + instructionRefineService.resetConversation(); + const result = await instructionRefineService.refineInstruction( + rawInstruction, + existingPrompt + ); + + if (wasCancelled) { + return; + } + + if (!result.success) { + if (result.error === 'Cancelled') { + instructionModeManager?.clear(); + return; + } + new Notice(result.error || 'Failed to refine instruction'); + modal.showError(result.error || 'Failed to refine instruction'); + instructionModeManager?.clear(); + return; + } + + if (result.clarification) { + modal.showClarification(result.clarification); + } else if (result.refinedInstruction) { + modal.showConfirmation(result.refinedInstruction); + } else { + new Notice('No instruction received'); + modal.showError('No instruction received'); + instructionModeManager?.clear(); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + new Notice(`Error: ${errorMsg}`); + modal?.showError(errorMsg); + instructionModeManager?.clear(); + } + } + + // ============================================ + // Approval Dialogs + // ============================================ + + async handleApprovalRequest( + toolName: string, + _input: Record, + description: string, + approvalOptions?: ApprovalCallbackOptions, + ): Promise { + const inputContainerEl = this.deps.getInputContainerEl(); + const parentEl = inputContainerEl.parentElement; + if (!parentEl) { + throw new Error('Input container is detached from DOM'); + } + + // Build header element, then detach — InlineAskUserQuestion will re-attach it + const headerEl = parentEl.createDiv({ cls: 'claudian-ask-approval-info' }); + headerEl.remove(); + + const toolEl = headerEl.createDiv({ cls: 'claudian-ask-approval-tool' }); + const iconEl = toolEl.createSpan({ cls: 'claudian-ask-approval-icon' }); + iconEl.setAttribute('aria-hidden', 'true'); + setToolIcon(iconEl, toolName); + toolEl.createSpan({ text: toolName, cls: 'claudian-ask-approval-tool-name' }); + + if (approvalOptions?.decisionReason) { + headerEl.createDiv({ text: approvalOptions.decisionReason, cls: 'claudian-ask-approval-reason' }); + } + if (approvalOptions?.blockedPath) { + headerEl.createDiv({ text: approvalOptions.blockedPath, cls: 'claudian-ask-approval-blocked-path' }); + } + if (approvalOptions?.agentID) { + headerEl.createDiv({ text: `Agent: ${approvalOptions.agentID}`, cls: 'claudian-ask-approval-agent' }); + } + + headerEl.createDiv({ text: description, cls: 'claudian-ask-approval-desc' }); + + const decisionOptions = approvalOptions?.decisionOptions ?? DEFAULT_APPROVAL_DECISION_OPTIONS; + const optionDecisionMap = new Map(); + const questionOptions = decisionOptions.map((option, index) => { + const value = option.value || `approval-option-${index}`; + if (option.decision) { + optionDecisionMap.set(value, option.decision); + } + return { + label: option.label, + description: option.description ?? '', + value, + }; + }); + const input = { + questions: [{ + question: 'Allow this action?', + options: questionOptions, + isOther: false, + isSecret: false, + }], + }; + + const result = await this.showInlineQuestion( + parentEl, + inputContainerEl, + input, + (inline) => { this.pendingApprovalInline = inline; }, + undefined, + { title: 'Permission required', headerEl, showCustomInput: false, immediateSelect: true }, + ); + + if (!result) return 'cancel'; + const selected = Object.values(result)[0]; + const selectedValue = Array.isArray(selected) ? selected[0] : selected; + if (typeof selectedValue !== 'string') { + new Notice(`Unexpected approval selection: "${String(selectedValue)}"`); + return 'cancel'; + } + + const decision = optionDecisionMap.get(selectedValue); + if (decision) { + return decision; + } + + return { + type: 'select-option', + value: selectedValue, + }; + } + + async handleAskUserQuestion( + input: Record, + signal?: AbortSignal, + ): Promise | null> { + const inputContainerEl = this.deps.getInputContainerEl(); + const parentEl = inputContainerEl.parentElement; + if (!parentEl) { + throw new Error('Input container is detached from DOM'); + } + + return this.showInlineQuestion( + parentEl, + inputContainerEl, + input, + (inline) => { this.pendingAskInline = inline; }, + signal, + ); + } + + private showInlineQuestion( + parentEl: HTMLElement, + inputContainerEl: HTMLElement, + input: Record, + setPending: (inline: InlineAskUserQuestion | null) => void, + signal?: AbortSignal, + config?: InlineAskQuestionConfig, + ): Promise | null> { + this.deps.streamController.hideThinkingIndicator(); + this.hideInputContainer(inputContainerEl); + + return new Promise | null>((resolve, reject) => { + const inline = new InlineAskUserQuestion( + parentEl, + input, + (result: Record | null) => { + setPending(null); + this.restoreInputContainer(inputContainerEl); + resolve(result); + }, + signal, + config, + ); + setPending(inline); + try { + inline.render(); + } catch (err) { + setPending(null); + this.restoreInputContainer(inputContainerEl); + reject(toError(err)); + } + }); + } + + async handleExitPlanMode( + input: Record, + signal?: AbortSignal, + ): Promise { + const { state, streamController } = this.deps; + const inputContainerEl = this.deps.getInputContainerEl(); + const parentEl = inputContainerEl.parentElement; + if (!parentEl) { + throw new Error('Input container is detached from DOM'); + } + + streamController.hideThinkingIndicator(); + this.hideInputContainer(inputContainerEl); + + const enrichedInput = state.planFilePath + ? { ...input, planFilePath: state.planFilePath } + : input; + + const renderContent = (el: HTMLElement, markdown: string) => + this.deps.renderer.renderContent(el, markdown); + + const planPathPrefix = this.getActiveCapabilities().planPathPrefix; + + return new Promise((resolve, reject) => { + const inline = new InlineExitPlanMode( + parentEl, + enrichedInput, + (decision: ExitPlanModeDecision | null) => { + this.pendingExitPlanModeInline = null; + this.restoreInputContainer(inputContainerEl); + resolve(decision); + }, + signal, + renderContent, + planPathPrefix, + ); + this.pendingExitPlanModeInline = inline; + try { + inline.render(); + } catch (err) { + this.pendingExitPlanModeInline = null; + this.restoreInputContainer(inputContainerEl); + reject(toError(err)); + } + }); + } + + dismissPendingApprovalPrompt(): void { + if (this.pendingApprovalInline) { + this.pendingApprovalInline.destroy(); + this.pendingApprovalInline = null; + } + } + + dismissPendingApproval(): void { + this.dismissPendingApprovalPrompt(); + if (this.pendingAskInline) { + this.pendingAskInline.destroy(); + this.pendingAskInline = null; + } + if (this.pendingExitPlanModeInline) { + this.pendingExitPlanModeInline.destroy(); + this.pendingExitPlanModeInline = null; + } + this.dismissPendingPlanApproval(true); + this.resetInputContainerVisibility(); + } + + private showPlanApproval(): Promise<{ decision: PlanApprovalDecision | null; invalidated: boolean }> { + const inputContainerEl = this.deps.getInputContainerEl(); + const parentEl = inputContainerEl.parentElement; + if (!parentEl) { + return Promise.resolve({ decision: null, invalidated: false }); + } + + this.hideInputContainer(inputContainerEl); + this.pendingPlanApprovalInvalidated = false; + + return new Promise<{ decision: PlanApprovalDecision | null; invalidated: boolean }>((resolve, reject) => { + const inline = new InlinePlanApproval( + parentEl, + (decision: PlanApprovalDecision | null) => { + const invalidated = this.pendingPlanApprovalInvalidated; + this.pendingPlanApprovalInvalidated = false; + this.pendingPlanApproval = null; + this.restoreInputContainer(inputContainerEl); + resolve({ decision, invalidated }); + }, + ); + this.pendingPlanApproval = inline; + try { + inline.render(); + } catch (err) { + this.pendingPlanApproval = null; + this.pendingPlanApprovalInvalidated = false; + this.restoreInputContainer(inputContainerEl); + reject(toError(err)); + } + }); + } + + private dismissPendingPlanApproval(invalidated: boolean): void { + if (!this.pendingPlanApproval) { + return; + } + + if (invalidated) { + this.pendingPlanApprovalInvalidated = true; + } + this.pendingPlanApproval.destroy(); + this.pendingPlanApproval = null; + } + + private hideInputContainer(inputContainerEl: HTMLElement): void { + this.inputContainerHideDepth++; + inputContainerEl.addClass('claudian-hidden'); + } + + private restoreInputContainer(inputContainerEl: HTMLElement): void { + if (this.inputContainerHideDepth <= 0) return; + this.inputContainerHideDepth--; + if (this.inputContainerHideDepth === 0) { + inputContainerEl.removeClass('claudian-hidden'); + } + } + + private resetInputContainerVisibility(): void { + if (this.inputContainerHideDepth > 0) { + this.inputContainerHideDepth = 0; + this.deps.getInputContainerEl().removeClass('claudian-hidden'); + } + } + + // ============================================ + // Built-in Commands + // ============================================ + + private async executeBuiltInCommand(command: BuiltInCommand, args: string): Promise { + const { conversationController } = this.deps; + const capabilities = this.getActiveCapabilities(); + + if (!isBuiltInCommandSupported(command, capabilities)) { + new Notice(`/${command.name} is not supported by this provider.`); + return; + } + + switch (command.action) { + case 'clear': + await conversationController.createNew(); + break; + case 'add-dir': { + const externalContextSelector = this.deps.getExternalContextSelector(); + if (!externalContextSelector) { + new Notice('External context selector not available.'); + return; + } + const result = externalContextSelector.addExternalContext(args); + if (result.success) { + new Notice(`Added external context: ${result.normalizedPath}`); + } else { + new Notice(result.error); + } + break; + } + case 'resume': + this.showResumeDropdown(); + break; + case 'fork': { + if (!this.getActiveCapabilities().supportsFork) { + new Notice('Fork is not supported by this provider.'); + return; + } + if (!this.deps.onForkAll) { + new Notice('Fork not available.'); + return; + } + await this.deps.onForkAll(); + break; + } + default: { + // Unknown command - notify user + const unknownAction = typeof (command as { action?: unknown }).action === 'string' + ? (command as { action: string }).action + : 'unknown'; + new Notice(`Unknown command: ${unknownAction}`); + break; + } + } + } + + // ============================================ + // Resume Session Dropdown + // ============================================ + + handleResumeKeydown(e: KeyboardEvent): boolean { + if (!this.activeResumeDropdown?.isVisible()) return false; + return this.activeResumeDropdown.handleKeydown(e); + } + + isResumeDropdownVisible(): boolean { + return this.activeResumeDropdown?.isVisible() ?? false; + } + + destroyResumeDropdown(): void { + if (this.activeResumeDropdown) { + this.activeResumeDropdown.destroy(); + this.activeResumeDropdown = null; + } + } + + private showResumeDropdown(): void { + const { plugin, state, conversationController } = this.deps; + + // Clean up any existing dropdown + this.destroyResumeDropdown(); + + const conversations = plugin.getConversationList(); + if (conversations.length === 0) { + new Notice('No conversations to resume'); + return; + } + + const openConversation = this.deps.openConversation + ?? ((id: string) => conversationController.switchTo(id)); + + this.activeResumeDropdown = new ResumeSessionDropdown( + this.deps.getInputContainerEl(), + this.deps.getInputEl(), + conversations, + state.currentConversationId, + { + onSelect: (id) => { + this.destroyResumeDropdown(); + openConversation(id).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Failed to open conversation: ${msg}`); + }); + }, + onDismiss: () => { + this.destroyResumeDropdown(); + }, + } + ); + } +} diff --git a/src/features/chat/controllers/NavigationController.ts b/src/features/chat/controllers/NavigationController.ts new file mode 100644 index 0000000..e6ed5ac --- /dev/null +++ b/src/features/chat/controllers/NavigationController.ts @@ -0,0 +1,209 @@ +import type { KeyboardNavigationSettings } from '../../../core/types'; +import { + cancelScheduledAnimationFrame, + scheduleAnimationFrame, + type ScheduledAnimationFrame, +} from '../../../utils/animationFrame'; + +/** Scroll speed in pixels per frame (~60fps = 480px/sec). */ +const SCROLL_SPEED = 8; + +export interface NavigationControllerDeps { + getMessagesEl: () => HTMLElement; + getInputEl: () => HTMLTextAreaElement; + getSettings: () => KeyboardNavigationSettings; + isStreaming: () => boolean; + /** Returns true if a UI component (dropdown, modal, mode) should handle Escape instead. */ + shouldSkipEscapeHandling?: () => boolean; +} + +export class NavigationController { + private deps: NavigationControllerDeps; + private scrollDirection: 'up' | 'down' | null = null; + private animationFrame: ScheduledAnimationFrame | null = null; + private keyboardDocument: Document | null = null; + private initialized = false; + private disposed = false; + + // Bound handlers for cleanup + private boundMessagesKeydown: (e: KeyboardEvent) => void; + private boundKeyup: (e: KeyboardEvent) => void; + private boundInputKeydown: (e: KeyboardEvent) => void; + + constructor(deps: NavigationControllerDeps) { + this.deps = deps; + this.boundMessagesKeydown = (e) => this.handleMessagesKeydown(e); + this.boundKeyup = (e) => this.handleKeyup(e); + this.boundInputKeydown = (e) => this.handleInputKeydown(e); + } + + initialize(): void { + if (this.initialized || this.disposed) return; + + const messagesEl = this.deps.getMessagesEl(); + const inputEl = this.deps.getInputEl(); + + // Guard against missing DOM elements + if (!messagesEl || !inputEl) return; + + // Make messages panel focusable (focus style handled in CSS) + messagesEl.setAttribute('tabindex', '0'); + messagesEl.addClass('claudian-messages-focusable'); + + // Attach event listeners + messagesEl.addEventListener('keydown', this.boundMessagesKeydown); + this.keyboardDocument = messagesEl.ownerDocument; + this.keyboardDocument.addEventListener('keyup', this.boundKeyup); + + // Use capture phase to run before other handlers + inputEl.addEventListener('keydown', this.boundInputKeydown, { capture: true }); + + this.initialized = true; + } + + /** Cleans up event listeners and animation frames. */ + dispose(): void { + if (this.disposed) return; + this.disposed = true; + + this.stopScrolling(); + + // Always clean up document listener first (most important for preventing leaks) + this.keyboardDocument?.removeEventListener('keyup', this.boundKeyup); + this.keyboardDocument = null; + + // Element cleanup - may already be destroyed during view teardown + const messagesEl = this.deps.getMessagesEl(); + messagesEl?.removeEventListener('keydown', this.boundMessagesKeydown); + messagesEl?.removeClass('claudian-messages-focusable'); + + const inputEl = this.deps.getInputEl(); + inputEl?.removeEventListener('keydown', this.boundInputKeydown, { capture: true }); + } + + // ============================================ + // Messages Panel Keyboard Handling + // ============================================ + + private handleMessagesKeydown(e: KeyboardEvent): void { + // Ignore if any modifier is held - allow system shortcuts (Ctrl+W, Cmd+W, etc.) + if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return; + + const settings = this.deps.getSettings(); + const key = e.key.toLowerCase(); + + // Scroll up + if (key === settings.scrollUpKey.toLowerCase()) { + e.preventDefault(); + this.startScrolling('up'); + return; + } + + // Scroll down + if (key === settings.scrollDownKey.toLowerCase()) { + e.preventDefault(); + this.startScrolling('down'); + return; + } + + // Focus input (vim 'i' for insert mode) + if (key === settings.focusInputKey.toLowerCase()) { + e.preventDefault(); + this.deps.getInputEl().focus(); + return; + } + } + + private handleKeyup(e: KeyboardEvent): void { + const settings = this.deps.getSettings(); + const key = e.key.toLowerCase(); + + // Stop scrolling when scroll key is released + if ( + key === settings.scrollUpKey.toLowerCase() || + key === settings.scrollDownKey.toLowerCase() + ) { + this.stopScrolling(); + } + } + + // ============================================ + // Input Keyboard Handling (Escape) + // ============================================ + + private handleInputKeydown(e: KeyboardEvent): void { + if (e.key !== 'Escape') return; + + // Ignore if composing (IME support for Chinese, Japanese, Korean, etc.) + if (e.isComposing) return; + + // If streaming, let existing handler interrupt (don't interfere) + if (this.deps.isStreaming()) { + return; + } + + if (this.deps.shouldSkipEscapeHandling?.()) { + return; + } + + // Not streaming, no active UI: blur input and focus messages panel + e.preventDefault(); + e.stopPropagation(); + this.deps.getInputEl().blur(); + this.deps.getMessagesEl().focus(); + } + + // ============================================ + // Continuous Scrolling with requestAnimationFrame + // ============================================ + + private startScrolling(direction: 'up' | 'down'): void { + if (this.scrollDirection === direction) { + return; // Already scrolling in this direction + } + + this.scrollDirection = direction; + this.scrollLoop(); + } + + private stopScrolling(): void { + this.scrollDirection = null; + if (this.animationFrame !== null) { + cancelScheduledAnimationFrame(this.animationFrame); + this.animationFrame = null; + } + } + + private scrollLoop = (): void => { + if (this.scrollDirection === null || this.disposed) return; + + const messagesEl = this.deps.getMessagesEl(); + if (!messagesEl) { + // Element was destroyed - stop scrolling silently (expected on cleanup) + this.stopScrolling(); + return; + } + + const scrollAmount = this.scrollDirection === 'up' ? -SCROLL_SPEED : SCROLL_SPEED; + messagesEl.scrollTop += scrollAmount; + + this.animationFrame = scheduleAnimationFrame( + this.scrollLoop, + messagesEl.ownerDocument.defaultView ?? null, + ); + }; + + // ============================================ + // Public API + // ============================================ + + /** Focuses the messages panel. */ + focusMessages(): void { + this.deps.getMessagesEl().focus(); + } + + /** Focuses the input. */ + focusInput(): void { + this.deps.getInputEl().focus(); + } +} diff --git a/src/features/chat/controllers/SelectionController.ts b/src/features/chat/controllers/SelectionController.ts new file mode 100644 index 0000000..4366cd1 --- /dev/null +++ b/src/features/chat/controllers/SelectionController.ts @@ -0,0 +1,430 @@ +import type { App } from 'obsidian'; +import { MarkdownView } from 'obsidian'; + +import { hideSelectionHighlight, showSelectionHighlight } from '../../../shared/components/SelectionHighlight'; +import { type EditorSelectionContext, getEditorView } from '../../../utils/editor'; +import type { StoredSelection } from '../state/types'; +import type { ComposerContextTray } from '../ui/ComposerContextTray'; + +const SELECTION_POLL_INTERVAL = 250; +const INPUT_HANDOFF_GRACE_MS = 1500; +const HIGHLIGHT_KEY = 'claudian-selection'; + +type CustomHighlightRegistry = { + delete: (name: string) => boolean; + set: (name: string, highlight: unknown) => void; +}; +type CustomHighlightConstructor = new (...ranges: Range[]) => unknown; +type FocusScopeInput = HTMLElement | HTMLElement[]; + +export class SelectionController { + private app: App; + private contextTray: ComposerContextTray; + private inputEl: HTMLElement; + private focusScopeEls: HTMLElement[]; + private onVisibilityChange: (() => void) | null; + private storedSelection: StoredSelection | null = null; + private inputHandoffGraceUntil: number | null = null; + private pollInterval: number | null = null; + private readonly focusScopePointerDownHandler = () => { + if (!this.storedSelection) return; + this.inputHandoffGraceUntil = Date.now() + INPUT_HANDOFF_GRACE_MS; + }; + private readonly focusScopeFocusInHandler = (event: FocusEvent) => { + const relatedTarget = event.relatedTarget as Node | null; + if (relatedTarget && this.isNodeWithinFocusScopes(relatedTarget)) return; + this.showHighlight(); + }; + + constructor( + app: App, + contextTray: ComposerContextTray, + inputEl: HTMLElement, + onVisibilityChange?: () => void, + focusScopeEl?: FocusScopeInput + ) { + this.app = app; + this.contextTray = contextTray; + this.inputEl = inputEl; + this.focusScopeEls = this.normalizeFocusScopes(focusScopeEl); + this.onVisibilityChange = onVisibilityChange ?? null; + } + + start(): void { + if (this.pollInterval) return; + this.inputEl.addEventListener('pointerdown', this.focusScopePointerDownHandler); + for (const focusScopeEl of this.focusScopeEls) { + if (focusScopeEl !== this.inputEl) { + focusScopeEl.addEventListener('pointerdown', this.focusScopePointerDownHandler); + } + focusScopeEl.addEventListener('focusin', this.focusScopeFocusInHandler); + } + this.pollInterval = window.setInterval(() => this.poll(), SELECTION_POLL_INTERVAL); + } + + stop(): void { + if (this.pollInterval) { + window.clearInterval(this.pollInterval); + this.pollInterval = null; + } + this.inputEl.removeEventListener('pointerdown', this.focusScopePointerDownHandler); + for (const focusScopeEl of this.focusScopeEls) { + if (focusScopeEl !== this.inputEl) { + focusScopeEl.removeEventListener('pointerdown', this.focusScopePointerDownHandler); + } + focusScopeEl.removeEventListener('focusin', this.focusScopeFocusInHandler); + } + this.clear(); + } + + dispose(): void { + this.stop(); + } + + // ============================================ + // Selection Polling + // ============================================ + + private poll(): void { + const view = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!view) { + // Keep the captured selection only while focus is transitioning into + // the chat UI; any other leaf switch should drop stale prompt context. + this.clearWhenMarkdownContextIsUnavailable(); + return; + } + + // Reading/preview mode has no usable CM6 selection — use DOM selection instead + if (view.getMode() === 'preview') { + this.pollReadingMode(view); + return; + } + + const editor = view.editor; + const editorView = getEditorView(editor); + if (!editorView) { + this.clearWhenMarkdownContextIsUnavailable(); + return; + } + + const selectedText = editor.getSelection(); + + if (selectedText.trim()) { + this.inputHandoffGraceUntil = null; + const fromPos = editor.getCursor('from'); + const toPos = editor.getCursor('to'); + const from = editor.posToOffset(fromPos); + const to = editor.posToOffset(toPos); + const startLine = fromPos.line + 1; // 1-indexed for display + + const notePath = view.file?.path || 'unknown'; + const lineCount = selectedText.split(/\r?\n/).length; + + const s = this.storedSelection; + const sameRange = s + && s.editorView === editorView + && s.from === from + && s.to === to + && s.notePath === notePath; + const unchanged = sameRange + && s.selectedText === selectedText + && s.lineCount === lineCount + && s.startLine === startLine; + + if (!unchanged) { + if (s && !sameRange) { + this.clearHighlight(); + } + this.storedSelection = { notePath, selectedText, lineCount, startLine, from, to, editorView }; + this.updateIndicator(); + } + } else { + this.handleDeselection(); + } + } + + private pollReadingMode(view: MarkdownView): void { + const containerEl = view.containerEl; + if (!containerEl) { + this.clearWhenMarkdownContextIsUnavailable(); + return; + } + + const selection = this.getDocumentSelection(containerEl.ownerDocument); + const selectedText = selection?.toString() ?? ''; + + if (selectedText.trim()) { + const anchorNode = selection?.anchorNode; + const focusNode = selection?.focusNode; + if ( + (!anchorNode || !containerEl.contains(anchorNode)) + && (!focusNode || !containerEl.contains(focusNode)) + ) { + this.handleDeselection(); + return; + } + + this.inputHandoffGraceUntil = null; + const notePath = view.file?.path || 'unknown'; + const lineCount = selectedText.split(/\r?\n/).length; + const domRanges = this.cloneDOMRanges(selection); + + const unchanged = this.storedSelection + && this.storedSelection.editorView === undefined + && this.storedSelection.notePath === notePath + && this.storedSelection.selectedText === selectedText + && this.storedSelection.lineCount === lineCount + && this.rangeListsMatch(this.storedSelection.domRanges, domRanges); + + if (!unchanged) { + this.clearHighlight(); + this.storedSelection = { notePath, selectedText, lineCount, domRanges }; + this.updateIndicator(); + } + } else { + this.handleDeselection(); + } + } + + private get cssHighlights(): CustomHighlightRegistry | null { + const css = typeof CSS === 'undefined' + ? null + : CSS as unknown as { highlights?: CustomHighlightRegistry }; + return css?.highlights ?? null; + } + + private get highlightConstructor(): CustomHighlightConstructor | null { + const ownerWindow = this.inputEl.ownerDocument.defaultView as unknown as { + Highlight?: CustomHighlightConstructor; + } | null; + const rendererWindow = typeof window === 'undefined' + ? null + : window as unknown as { Highlight?: CustomHighlightConstructor }; + return ownerWindow?.Highlight ?? rendererWindow?.Highlight ?? null; + } + + private rangesMatch(a: Range, b: Range): boolean { + return a.startContainer === b.startContainer + && a.startOffset === b.startOffset + && a.endContainer === b.endContainer + && a.endOffset === b.endOffset; + } + + private rangeListsMatch(left: Range[] | undefined, right: Range[]): boolean { + return left !== undefined + && left.length === right.length + && left.every((range, index) => this.rangesMatch(range, right[index])); + } + + private selectionMatchesRanges(selection: Selection | null, ranges: Range[]): boolean { + if (!selection || selection.rangeCount !== ranges.length) return false; + for (let i = 0; i < ranges.length; i++) { + if (!this.rangesMatch(selection.getRangeAt(i), ranges[i])) { + return false; + } + } + return true; + } + + private cloneDOMRanges(selection: Selection | null): Range[] { + if (!selection) return []; + const ranges: Range[] = []; + for (let i = 0; i < selection.rangeCount; i++) { + ranges.push(selection.getRangeAt(i).cloneRange()); + } + return ranges; + } + + private getDocumentSelection(ownerDocument?: Document | null): Selection | null { + if (ownerDocument && typeof ownerDocument.getSelection === 'function') { + return ownerDocument.getSelection(); + } + + const fallbackDocument = this.inputEl.ownerDocument; + if (fallbackDocument && typeof fallbackDocument.getSelection === 'function') { + return fallbackDocument.getSelection(); + } + + return null; + } + + private getActiveElement(ownerDocument?: Document | null): Element | null { + return ownerDocument?.activeElement ?? this.inputEl.ownerDocument?.activeElement ?? null; + } + + private normalizeFocusScopes(focusScopeEl?: FocusScopeInput): HTMLElement[] { + const focusScopes = Array.isArray(focusScopeEl) + ? focusScopeEl + : [focusScopeEl ?? this.inputEl]; + return Array.from(new Set(focusScopes.filter(Boolean))); + } + + private getFocusScopeOwnerDocument(): Document | null { + return this.focusScopeEls[0]?.ownerDocument ?? this.inputEl.ownerDocument ?? null; + } + + private isNodeWithinFocusScopes(node: Node): boolean { + return this.focusScopeEls.some((focusScopeEl) => + node === focusScopeEl || focusScopeEl.contains(node) + ); + } + + private isFocusWithinChatSidebar(): boolean { + const activeElement = this.getActiveElement(this.getFocusScopeOwnerDocument()) as Node | null; + return activeElement !== null && this.isNodeWithinFocusScopes(activeElement); + } + + private isNativeEditorSelectionVisible(sel: StoredSelection): boolean { + if (!sel.editorView || sel.from === undefined || sel.to === undefined) { + return false; + } + + const activeElement = this.getActiveElement(sel.editorView.dom.ownerDocument) as Node | null; + if (activeElement === null || !sel.editorView.dom.contains(activeElement)) { + return false; + } + + const cmSel = sel.editorView.state.selection.main; + return cmSel.from === sel.from && cmSel.to === sel.to; + } + + private isNativePreviewSelectionVisible(ranges: Range[]): boolean { + if (this.isFocusWithinChatSidebar()) { + return false; + } + + return this.selectionMatchesRanges(this.getDocumentSelection(this.getFocusScopeOwnerDocument()), ranges); + } + + private clearWhenMarkdownContextIsUnavailable(): void { + if (!this.storedSelection) return; + if (this.isFocusWithinChatSidebar()) { + this.inputHandoffGraceUntil = null; + return; + } + if (this.inputHandoffGraceUntil !== null && Date.now() <= this.inputHandoffGraceUntil) { + return; + } + + this.inputHandoffGraceUntil = null; + this.clearHighlight(); + this.storedSelection = null; + this.updateIndicator(); + } + + private handleDeselection(): void { + if (!this.storedSelection) return; + if (this.isFocusWithinChatSidebar()) { + this.inputHandoffGraceUntil = null; + return; + } + + if (this.inputHandoffGraceUntil !== null && Date.now() <= this.inputHandoffGraceUntil) { + return; + } + + this.inputHandoffGraceUntil = null; + this.clearHighlight(); + this.storedSelection = null; + this.updateIndicator(); + } + + // ============================================ + // Highlight Management + // ============================================ + + showHighlight(): void { + const sel = this.storedSelection; + if (!sel) return; + + // Edit mode: prefer native CM6 unfocused selection (.cm-selectionBackground) + if (sel.editorView && sel.from !== undefined && sel.to !== undefined) { + if (this.isNativeEditorSelectionVisible(sel)) { + // Native is showing — clear any stale mock + hideSelectionHighlight(sel.editorView); + return; + } + // Native selection not visible (e.g., input has focus) — show mock + showSelectionHighlight(sel.editorView, sel.from, sel.to); + return; + } + + // Preview mode: prefer native DOM selection (::selection) + if (sel.domRanges?.length) { + if (this.isNativePreviewSelectionVisible(sel.domRanges)) { + // Native is showing — clear any stale mock + this.cssHighlights?.delete(HIGHLIGHT_KEY); + return; + } + // Native selection not visible (e.g., input has focus) — show mock + const validRanges = sel.domRanges.filter(r => r.startContainer.isConnected); + const HighlightCtor = this.highlightConstructor; + if (validRanges.length && HighlightCtor) { + this.cssHighlights?.set(HIGHLIGHT_KEY, new HighlightCtor(...validRanges)); + } + } + } + + private clearHighlight(): void { + if (this.storedSelection?.editorView) { + hideSelectionHighlight(this.storedSelection.editorView); + } + this.cssHighlights?.delete(HIGHLIGHT_KEY); + } + + // ============================================ + // Indicator + // ============================================ + + private updateIndicator(): void { + if (this.storedSelection) { + const lineText = this.storedSelection.lineCount === 1 ? 'line' : 'lines'; + const label = `${this.storedSelection.lineCount} ${lineText} selected`; + this.contextTray.setItems('editor-selection', [{ + id: 'editor-selection', + kind: 'selection', + label, + icon: 'text-select', + ariaLabel: label, + onRemove: () => this.clear(), + }]); + } else { + this.contextTray.clearItems('editor-selection'); + } + this.updateContextRowVisibility(); + } + + updateContextRowVisibility(): void { + this.onVisibilityChange?.(); + } + + // ============================================ + // Context Access + // ============================================ + + getContext(): EditorSelectionContext | null { + if (!this.storedSelection) return null; + return { + notePath: this.storedSelection.notePath, + mode: 'selection', + selectedText: this.storedSelection.selectedText, + lineCount: this.storedSelection.lineCount, + ...(this.storedSelection.startLine !== undefined && { startLine: this.storedSelection.startLine }), + }; + } + + hasSelection(): boolean { + return this.storedSelection !== null; + } + + // ============================================ + // Clear + // ============================================ + + clear(): void { + this.inputHandoffGraceUntil = null; + this.clearHighlight(); + this.storedSelection = null; + this.updateIndicator(); + } +} diff --git a/src/features/chat/controllers/StreamController.ts b/src/features/chat/controllers/StreamController.ts new file mode 100644 index 0000000..b1c9f58 --- /dev/null +++ b/src/features/chat/controllers/StreamController.ts @@ -0,0 +1,1578 @@ +import { TFile } from 'obsidian'; + +import { resolveConversationModel } from '../../../core/providers/conversationModel'; +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import { + DEFAULT_CHAT_PROVIDER_ID, + type ProviderId, + type ProviderSubagentLifecycleAdapter, +} from '../../../core/providers/types'; +import type { ChatRuntime } from '../../../core/runtime/ChatRuntime'; +import { parseTodoInput } from '../../../core/tools/todo'; +import { extractResolvedAnswers, extractResolvedAnswersFromResultText } from '../../../core/tools/toolInput'; +import { + isEditTool, + isSubagentToolName, + isWriteEditTool, + skipsBlockedDetection, + TOOL_AGENT_OUTPUT, + TOOL_APPLY_PATCH, + TOOL_ASK_USER_QUESTION, + TOOL_TASK, + TOOL_TODO_WRITE, + TOOL_WRITE, +} from '../../../core/tools/toolNames'; +import { extractToolResultContent } from '../../../core/tools/toolResultContent'; +import type { ChatMessage, StreamChunk, SubagentInfo, ToolCallInfo } from '../../../core/types'; +import type { SDKToolUseResult } from '../../../core/types/diff'; +import { + cancelScheduledAnimationFrame, + scheduleAnimationFrame, + type ScheduledAnimationFrame, +} from '../../../utils/animationFrame'; +import { formatDurationMmSs } from '../../../utils/date'; +import { extractDiffData } from '../../../utils/diff'; +import { hasStreamingMathDelimiters } from '../../../utils/markdownMath'; +import { getVaultPath, normalizePathForVault } from '../../../utils/path'; +import type { FeatureHost } from '../../FeatureHost'; +import { FLAVOR_TEXTS } from '../constants'; +import type { MessageRenderer, RenderContentOptions } from '../rendering/MessageRenderer'; +import { resolveSubagentLifecycleAdapter } from '../rendering/subagentLifecycleResolution'; +import { + createSubagentBlock, + finalizeSubagentBlock, + type SubagentState, +} from '../rendering/SubagentRenderer'; +import { + createThinkingBlock, + finalizeThinkingBlock, +} from '../rendering/ThinkingBlockRenderer'; +import { + getToolName, + getToolSummary, + isBlockedToolResult, + renderToolCall, + updateToolCallResult, +} from '../rendering/ToolCallRenderer'; +import { + createWriteEditBlock, + finalizeWriteEditBlock, + updateWriteEditWithDiff, +} from '../rendering/WriteEditRenderer'; +import type { SubagentManager } from '../services/SubagentManager'; +import type { ChatState } from '../state/ChatState'; +import type { FileContextManager } from '../ui/FileContext'; + +export interface StreamControllerDeps { + plugin: FeatureHost; + state: ChatState; + renderer: MessageRenderer; + subagentManager: SubagentManager; + getMessagesEl: () => HTMLElement; + getFileContextManager: () => FileContextManager | null; + updateQueueIndicator: () => void; + /** Get the agent service from the tab. */ + getAgentService?: () => ChatRuntime | null; +} + +export class StreamController { + private static readonly ASYNC_SUBAGENT_RESULT_RETRY_DELAYS_MS = [200, 600, 1500] as const; + + private deps: StreamControllerDeps; + private pendingTextRenderFrame: ScheduledAnimationFrame | null = null; + private pendingTextRenderPromise: Promise | null = null; + private resolvePendingTextRender: (() => void) | null = null; + private isTextRenderRunning = false; + private pendingThinkingRenderFrame: ScheduledAnimationFrame | null = null; + private pendingThinkingRenderPromise: Promise | null = null; + private resolvePendingThinkingRender: (() => void) | null = null; + private isThinkingRenderRunning = false; + private pendingToolOutputFrames = new Map(); + private pendingScrollFrame: ScheduledAnimationFrame | null = null; + + // Provider lifecycle agent tracking (spawn → wait/close lifecycle) + private lifecycleSubagentStates = new Map(); // spawn callId → SubagentState + private lifecycleAgentIdToSpawnId = new Map(); // agentId → spawn callId + + constructor(deps: StreamControllerDeps) { + this.deps = deps; + } + + private getActiveProviderId(): ProviderId { + return this.deps.getAgentService?.()?.providerId ?? DEFAULT_CHAT_PROVIDER_ID; + } + + private getSubagentLifecycleAdapter(toolName?: string): ProviderSubagentLifecycleAdapter | null { + return resolveSubagentLifecycleAdapter(this.getActiveProviderId(), toolName); + } + + private normalizeToolResultContent(content: unknown): string { + return extractToolResultContent(content, { fallbackIndent: 2 }); + } + + // ============================================ + // Stream Chunk Handling + // ============================================ + + async handleStreamChunk(chunk: StreamChunk, msg: ChatMessage): Promise { + const { state } = this.deps; + + switch (chunk.type) { + case 'thinking': + // Flush pending tools before rendering new content type + this.flushPendingTools(); + if (state.currentTextEl) { + await this.finalizeCurrentTextBlock(msg); + } + await this.appendThinking(chunk.content); + break; + + case 'text': + // Flush pending tools before rendering new content type + this.flushPendingTools(); + if (state.currentThinkingState) { + await this.finalizeCurrentThinkingBlock(msg); + } + msg.content += chunk.content; + await this.appendText(chunk.content); + break; + + case 'tool_use': { + if (state.currentThinkingState) { + await this.finalizeCurrentThinkingBlock(msg); + } + await this.finalizeCurrentTextBlock(msg); + + if (isSubagentToolName(chunk.name)) { + // Flush pending tools before Agent + this.flushPendingTools(); + this.handleTaskToolUseViaManager(chunk, msg); + break; + } + + if (chunk.name === TOOL_AGENT_OUTPUT) { + this.handleAgentOutputToolUse(chunk, msg); + break; + } + + const subagentLifecycleAdapter = this.getSubagentLifecycleAdapter(chunk.name); + if (subagentLifecycleAdapter?.isSpawnTool(chunk.name)) { + this.handleProviderSubagentSpawn(chunk, msg, subagentLifecycleAdapter); + break; + } + if (subagentLifecycleAdapter?.isHiddenTool(chunk.name)) { + this.handleProviderHiddenSubagentTool(chunk, msg); + break; + } + + this.handleRegularToolUse(chunk, msg); + break; + } + + case 'tool_result': { + await this.handleToolResult(chunk, msg); + break; + } + + case 'subagent_tool_use': + case 'subagent_tool_result': + await this.handleSubagentChunk(chunk, msg); + break; + + case 'async_subagent_result': + await this.handleAsyncSubagentResult(chunk); + break; + + case 'tool_output': + this.handleToolOutput(chunk, msg); + break; + + case 'notice': + this.flushPendingTools(); + await this.appendText(`\n\n⚠️ **${chunk.level === 'warning' ? 'Blocked' : 'Notice'}:** ${chunk.content}`); + break; + + case 'error': + // Flush pending tools before rendering error message + this.flushPendingTools(); + await this.appendText(`\n\n❌ **Error:** ${chunk.content}`); + break; + + case 'done': + // Flush any remaining pending tools + this.flushPendingTools(); + break; + + case 'context_compacted': { + this.flushPendingTools(); + if (state.currentThinkingState) { + await this.finalizeCurrentThinkingBlock(msg); + } + await this.finalizeCurrentTextBlock(msg); + msg.contentBlocks = msg.contentBlocks || []; + msg.contentBlocks.push({ type: 'context_compacted' }); + this.renderCompactBoundary(); + break; + } + + case 'usage': { + // Skip usage updates from other sessions or when flagged (during session reset) + const currentSessionId = this.deps.getAgentService?.()?.getSessionId() ?? null; + const chunkSessionId = chunk.sessionId ?? null; + if ( + (chunkSessionId && currentSessionId && chunkSessionId !== currentSessionId) || + (chunkSessionId && !currentSessionId) + ) { + break; + } + // Skip usage updates when subagents ran (SDK reports cumulative usage including subagents) + if (this.deps.subagentManager.subagentsSpawnedThisStream > 0) { + break; + } + if (!state.ignoreUsageUpdates) { + const activeModel = this.getActiveProviderModel(); + state.usage = activeModel && !chunk.usage.model + ? { ...chunk.usage, model: activeModel } + : chunk.usage; + } + break; + } + + default: + break; + } + + this.scrollToBottom(); + } + + // ============================================ + // Tool Use Handling + // ============================================ + + /** + * Handles regular tool_use chunks by buffering them. + * Tools are rendered when flushPendingTools is called (on next content type or tool_result). + */ + private handleRegularToolUse( + chunk: { type: 'tool_use'; id: string; name: string; input: Record }, + msg: ChatMessage + ): void { + const { state } = this.deps; + + // Check if this is an update to an existing tool call + const existingToolCall = msg.toolCalls?.find(tc => tc.id === chunk.id); + if (existingToolCall) { + const newInput = chunk.input || {}; + if (Object.keys(newInput).length > 0) { + existingToolCall.input = { ...existingToolCall.input, ...newInput }; + + // Re-parse TodoWrite on input updates (streaming may complete the input) + if (existingToolCall.name === TOOL_TODO_WRITE) { + const todos = parseTodoInput(existingToolCall.input); + if (todos) { + this.deps.state.currentTodos = todos; + } + } + + // Capture plan file path on input updates (file_path may arrive in a later chunk) + if (existingToolCall.name === TOOL_WRITE) { + this.capturePlanFilePath(existingToolCall.input); + } + + // If already rendered, update the header name + summary + const toolEl = state.toolCallElements.get(chunk.id); + if (toolEl) { + const nameEl = toolEl.querySelector('.claudian-tool-name') + ?? toolEl.querySelector('.claudian-write-edit-name'); + if (nameEl) { + nameEl.setText(getToolName(existingToolCall.name, existingToolCall.input)); + } + const summaryEl = toolEl.querySelector('.claudian-tool-summary') + ?? toolEl.querySelector('.claudian-write-edit-summary'); + if (summaryEl) { + summaryEl.setText(getToolSummary(existingToolCall.name, existingToolCall.input)); + } + } + // If still pending, the updated input is already in the toolCall object + } + return; + } + + // Create new tool call + const toolCall: ToolCallInfo = { + id: chunk.id, + name: chunk.name, + input: chunk.input, + status: 'running', + isExpanded: false, + }; + msg.toolCalls = msg.toolCalls || []; + msg.toolCalls.push(toolCall); + + // Add to contentBlocks for ordering + msg.contentBlocks = msg.contentBlocks || []; + msg.contentBlocks.push({ type: 'tool_use', toolId: chunk.id }); + + // TodoWrite: update panel state immediately (side effect), but still buffer render + if (chunk.name === TOOL_TODO_WRITE) { + const todos = parseTodoInput(chunk.input); + if (todos) { + this.deps.state.currentTodos = todos; + } + } + + // Track Write to provider plan directory for plan mode (used by approve-new-session) + if (chunk.name === TOOL_WRITE) { + this.capturePlanFilePath(chunk.input); + } + + // Buffer the tool call instead of rendering immediately + if (state.currentContentEl) { + state.pendingTools.set(chunk.id, { + toolCall, + parentEl: state.currentContentEl, + }); + this.showThinkingIndicator(); + } + } + + private getActiveProviderModel(): string | undefined { + const conversation = this.deps.state.currentConversationId + ? this.deps.plugin.getConversationSync(this.deps.state.currentConversationId) + : null; + if (conversation) { + return resolveConversationModel( + this.deps.plugin.settings, + conversation.providerId, + conversation, + ).model; + } + + const service = this.deps.getAgentService?.(); + const serviceModel = service?.getAuxiliaryModel?.(); + if (serviceModel) { + return serviceModel; + } + + const providerId = service?.providerId; + if (!providerId) { + return undefined; + } + + const settings = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + this.deps.plugin.settings, + providerId, + ); + return typeof settings.model === 'string' ? settings.model : undefined; + } + + private shouldDeferMathRendering(): boolean { + return this.deps.plugin.settings.deferMathRenderingDuringStreaming !== false; + } + + private shouldExpandFileEditsByDefault(): boolean { + return this.deps.plugin.settings.expandFileEditsByDefault === true; + } + + private getStreamingRenderOptions(content: string): RenderContentOptions | undefined { + return this.shouldDeferMathRendering() && hasStreamingMathDelimiters(content) + ? { deferMath: true } + : undefined; + } + + private capturePlanFilePath(input: Record): void { + const filePath = input.file_path as string | undefined; + if (!filePath) return; + + const planPathPrefix = this.deps.getAgentService?.()?.getCapabilities().planPathPrefix; + if (planPathPrefix && filePath.replace(/\\/g, '/').includes(planPathPrefix)) { + this.deps.state.planFilePath = filePath; + } + } + + /** + * Flushes all pending tool calls by rendering them. + * Called when a different content type arrives or stream ends. + */ + private flushPendingTools(): void { + const { state } = this.deps; + + if (state.pendingTools.size === 0) { + return; + } + + // Render pending tools in order (Map preserves insertion order) + for (const toolId of state.pendingTools.keys()) { + this.renderPendingTool(toolId); + } + + state.pendingTools.clear(); + } + + /** + * Renders a single pending tool call and moves it from pending to rendered state. + */ + private renderPendingTool(toolId: string): void { + const { state } = this.deps; + const pending = state.pendingTools.get(toolId); + if (!pending) return; + + const { toolCall, parentEl } = pending; + if (!parentEl) return; + if (isWriteEditTool(toolCall.name)) { + const writeEditState = createWriteEditBlock(parentEl, toolCall, { + initiallyExpanded: this.shouldExpandFileEditsByDefault(), + }); + state.writeEditStates.set(toolId, writeEditState); + state.toolCallElements.set(toolId, writeEditState.wrapperEl); + } else { + renderToolCall(parentEl, toolCall, state.toolCallElements, { + initiallyExpanded: toolCall.name === TOOL_APPLY_PATCH && this.shouldExpandFileEditsByDefault(), + }); + } + state.pendingTools.delete(toolId); + } + + private handleToolOutput( + chunk: { type: 'tool_output'; id: string; content: string }, + msg: ChatMessage, + ): void { + const { state } = this.deps; + + if (state.pendingTools.has(chunk.id)) { + this.renderPendingTool(chunk.id); + } + + const existingToolCall = msg.toolCalls?.find(tc => tc.id === chunk.id); + if (!existingToolCall) { + return; + } + + existingToolCall.result = (existingToolCall.result ?? '') + chunk.content; + this.scheduleToolOutputRender(chunk.id, existingToolCall); + this.showThinkingIndicator(); + } + + // ============================================ + // Provider lifecycle subagents (spawn → wait/close) + // ============================================ + + private handleProviderSubagentSpawn( + chunk: { type: 'tool_use'; id: string; name: string; input: Record }, + msg: ChatMessage, + adapter: ProviderSubagentLifecycleAdapter, + ): void { + const { state } = this.deps; + + const toolCall: ToolCallInfo = { + id: chunk.id, + name: chunk.name, + input: chunk.input, + status: 'running', + isExpanded: false, + }; + msg.toolCalls = msg.toolCalls || []; + msg.toolCalls.push(toolCall); + msg.contentBlocks = msg.contentBlocks || []; + msg.contentBlocks.push({ type: 'tool_use', toolId: chunk.id }); + + // Render as subagent block immediately + if (state.currentContentEl) { + this.flushPendingTools(); + const subagentInfo = adapter.buildSubagentInfo(toolCall, msg.toolCalls); + + const subagentState = createSubagentBlock(state.currentContentEl, chunk.id, { + description: subagentInfo.description, + prompt: subagentInfo.prompt, + }); + this.lifecycleSubagentStates.set(chunk.id, subagentState); + } + } + + private handleProviderHiddenSubagentTool( + chunk: { type: 'tool_use'; id: string; name: string; input: Record }, + msg: ChatMessage + ): void { + // Track in toolCalls for data completeness, but don't create DOM or content block + const toolCall: ToolCallInfo = { + id: chunk.id, + name: chunk.name, + input: chunk.input, + status: 'running', + isExpanded: false, + }; + msg.toolCalls = msg.toolCalls || []; + msg.toolCalls.push(toolCall); + } + + /** + * Handles tool_result for provider lifecycle subagent tools. + * Returns true if the result was consumed (caller should return early). + */ + private handleProviderSubagentResult( + chunk: { type: 'tool_result'; id: string; content: string; isError?: boolean }, + msg: ChatMessage + ): boolean { + const existingToolCall = msg.toolCalls?.find(tc => tc.id === chunk.id); + if (!existingToolCall) return false; + const normalizedContent = this.normalizeToolResultContent(chunk.content); + + const adapter = this.getSubagentLifecycleAdapter(existingToolCall.name); + if (!adapter) return false; + + if (adapter.isSpawnTool(existingToolCall.name)) { + existingToolCall.status = chunk.isError ? 'error' : 'completed'; + existingToolCall.result = normalizedContent; + + const spawnResult = adapter.extractSpawnResult(normalizedContent); + if (spawnResult.agentId) { + this.lifecycleAgentIdToSpawnId.set(spawnResult.agentId, chunk.id); + } + + const subagentInfo = adapter.buildSubagentInfo(existingToolCall, msg.toolCalls ?? []); + const subagentState = this.lifecycleSubagentStates.get(chunk.id); + if (subagentState) { + subagentState.info.description = subagentInfo.description; + subagentState.info.prompt = subagentInfo.prompt; + subagentState.labelEl.setText( + subagentInfo.description.length > 40 + ? subagentInfo.description.substring(0, 40) + '...' + : subagentInfo.description + ); + } + + if (chunk.isError) { + if (subagentState) { + finalizeSubagentBlock(subagentState, normalizedContent || 'Error', true); + } + } + return true; + } + + if (adapter.isWaitTool(existingToolCall.name)) { + existingToolCall.status = chunk.isError ? 'error' : 'completed'; + existingToolCall.result = normalizedContent; + + for (const spawnId of adapter.resolveSpawnToolIds( + existingToolCall, + this.lifecycleAgentIdToSpawnId, + )) { + const spawnToolCall = msg.toolCalls?.find(tc => tc.id === spawnId); + const subagentState = this.lifecycleSubagentStates.get(spawnId); + if (!spawnToolCall || !subagentState) continue; + + const subagentInfo = adapter.buildSubagentInfo(spawnToolCall, msg.toolCalls ?? []); + subagentState.info.description = subagentInfo.description; + subagentState.info.prompt = subagentInfo.prompt; + + if (subagentInfo.status === 'completed' || subagentInfo.status === 'error') { + finalizeSubagentBlock( + subagentState, + subagentInfo.result || (subagentInfo.status === 'error' ? 'Error' : 'DONE'), + subagentInfo.status === 'error' + ); + } + } + return true; + } + + if (adapter.isCloseTool(existingToolCall.name)) { + existingToolCall.status = chunk.isError ? 'error' : 'completed'; + existingToolCall.result = normalizedContent; + return true; + } + + return false; + } + + private async handleToolResult( + chunk: { type: 'tool_result'; id: string; content: string; isError?: boolean; toolUseResult?: SDKToolUseResult }, + msg: ChatMessage + ): Promise { + const { state, subagentManager } = this.deps; + const normalizedContent = this.normalizeToolResultContent(chunk.content); + + // Resolve pending Task before processing result. + if (subagentManager.hasPendingTask(chunk.id)) { + this.renderPendingTaskFromTaskResultViaManager(chunk, msg); + } + + // Check if it's a sync subagent result + const subagentState = subagentManager.getSyncSubagent(chunk.id); + if (subagentState) { + this.finalizeSubagent(chunk, msg); + return; + } + + // Check if it's an async task result + if (this.handleAsyncTaskToolResult(chunk)) { + this.showThinkingIndicator(); + return; + } + + // Check if it's an agent output result + if (await this.handleAgentOutputToolResult(chunk)) { + this.showThinkingIndicator(); + return; + } + + if (this.handleProviderSubagentResult(chunk, msg)) { + this.showThinkingIndicator(); + return; + } + + // Check if tool is still pending (buffered) - render it now before applying result + if (state.pendingTools.has(chunk.id)) { + this.renderPendingTool(chunk.id); + } + + const existingToolCall = msg.toolCalls?.find(tc => tc.id === chunk.id); + + // Regular tool result + const isBlocked = isBlockedToolResult(normalizedContent, chunk.isError); + + if (existingToolCall) { + // Tools that resolve via dedicated callbacks (not content-based) skip + // blocked detection — their status is determined solely by isError + if (chunk.isError) { + existingToolCall.status = 'error'; + } else if (!skipsBlockedDetection(existingToolCall.name) && isBlocked) { + existingToolCall.status = 'blocked'; + } else { + existingToolCall.status = 'completed'; + } + existingToolCall.result = normalizedContent; + + if (existingToolCall.name === TOOL_ASK_USER_QUESTION) { + const answers = + extractResolvedAnswers(chunk.toolUseResult) ?? + extractResolvedAnswersFromResultText(normalizedContent); + if (answers) existingToolCall.resolvedAnswers = answers; + } + + const writeEditState = state.writeEditStates.get(chunk.id); + if (writeEditState && isWriteEditTool(existingToolCall.name)) { + if (!chunk.isError && !isBlocked) { + const diffData = extractDiffData(chunk.toolUseResult, existingToolCall); + if (diffData) { + existingToolCall.diffData = diffData; + updateWriteEditWithDiff(writeEditState, diffData); + } + } + finalizeWriteEditBlock(writeEditState, chunk.isError || isBlocked); + } else { + this.cancelPendingToolOutputRender(chunk.id); + updateToolCallResult(chunk.id, existingToolCall, state.toolCallElements); + } + + // Notify Obsidian vault so the file tree refreshes after Write/Edit/NotebookEdit + if (!chunk.isError && !isBlocked && isEditTool(existingToolCall.name)) { + this.notifyVaultFileChange(existingToolCall.input); + } + + // Runtime apply_patch: refresh each changed file path + if (!chunk.isError && !isBlocked && existingToolCall.name === TOOL_APPLY_PATCH) { + this.notifyApplyPatchFileChanges(existingToolCall.input); + } + } + + this.showThinkingIndicator(); + } + + // ============================================ + // Text Block Management + // ============================================ + + async appendText(text: string): Promise { + const { state } = this.deps; + if (!state.currentContentEl) return; + + this.hideThinkingIndicator(); + + if (!state.currentTextEl) { + state.currentTextEl = state.currentContentEl.createDiv({ cls: 'claudian-text-block' }); + state.currentTextContent = ''; + } + + state.currentTextContent += text; + void this.scheduleCurrentTextRender(); + } + + async finalizeCurrentTextBlock(msg?: ChatMessage): Promise { + const { state, renderer } = this.deps; + await this.flushPendingTextRender(); + + if (msg && state.currentTextContent) { + if ( + state.currentTextEl + && this.shouldDeferMathRendering() + && hasStreamingMathDelimiters(state.currentTextContent) + ) { + await renderer.renderContent(state.currentTextEl, state.currentTextContent); + } + msg.contentBlocks = msg.contentBlocks || []; + msg.contentBlocks.push({ type: 'text', content: state.currentTextContent }); + // Copy button added here (not during streaming) to match history-loaded messages + if (state.currentTextEl) { + renderer.addTextCopyButton(state.currentTextEl, state.currentTextContent); + } + } + state.currentTextEl = null; + state.currentTextContent = ''; + } + + private scheduleCurrentTextRender(): Promise { + if (!this.pendingTextRenderPromise) { + this.pendingTextRenderPromise = new Promise(resolve => { + this.resolvePendingTextRender = resolve; + }); + } + + if (this.pendingTextRenderFrame === null && !this.isTextRenderRunning) { + this.pendingTextRenderFrame = scheduleAnimationFrame(() => { + this.pendingTextRenderFrame = null; + void this.renderPendingText(); + }, this.getStreamingRenderWindow()); + } + + return this.pendingTextRenderPromise; + } + + private async flushPendingTextRender(): Promise { + const pendingRender = this.pendingTextRenderPromise; + if (!pendingRender) return; + + if (this.pendingTextRenderFrame !== null) { + cancelScheduledAnimationFrame(this.pendingTextRenderFrame); + this.pendingTextRenderFrame = null; + void this.renderPendingText(); + } + + await pendingRender; + } + + private async renderPendingText(): Promise { + if (this.isTextRenderRunning) return; + this.isTextRenderRunning = true; + + const { state, renderer } = this.deps; + const textEl = state.currentTextEl; + const content = state.currentTextContent; + + try { + if (textEl) { + const options = this.getStreamingRenderOptions(content); + if (options) { + await renderer.renderContent(textEl, content, options); + } else { + await renderer.renderContent(textEl, content); + } + this.scrollToBottom(); + } + } catch { + // MessageRenderer owns user-visible render fallback; keep stream state moving. + } finally { + this.isTextRenderRunning = false; + } + + if (state.currentTextEl === textEl && state.currentTextContent !== content) { + this.pendingTextRenderFrame = scheduleAnimationFrame(() => { + this.pendingTextRenderFrame = null; + void this.renderPendingText(); + }, this.getStreamingRenderWindow()); + return; + } + + const resolve = this.resolvePendingTextRender; + this.pendingTextRenderPromise = null; + this.resolvePendingTextRender = null; + resolve?.(); + } + + private cancelPendingTextRender(): void { + if (this.pendingTextRenderFrame !== null) { + cancelScheduledAnimationFrame(this.pendingTextRenderFrame); + this.pendingTextRenderFrame = null; + } + + const resolve = this.resolvePendingTextRender; + this.pendingTextRenderPromise = null; + this.resolvePendingTextRender = null; + resolve?.(); + } + + private scheduleToolOutputRender(toolId: string, toolCall: ToolCallInfo): void { + if (this.pendingToolOutputFrames.has(toolId)) return; + + const frame = scheduleAnimationFrame(() => { + this.pendingToolOutputFrames.delete(toolId); + updateToolCallResult(toolId, toolCall, this.deps.state.toolCallElements); + this.scrollToBottom(); + }, this.getMessagesWindow()); + this.pendingToolOutputFrames.set(toolId, frame); + } + + private cancelPendingToolOutputRender(toolId: string): void { + const frame = this.pendingToolOutputFrames.get(toolId); + if (!frame) return; + + cancelScheduledAnimationFrame(frame); + this.pendingToolOutputFrames.delete(toolId); + } + + private cancelPendingToolOutputRenders(): void { + for (const frame of this.pendingToolOutputFrames.values()) { + cancelScheduledAnimationFrame(frame); + } + this.pendingToolOutputFrames.clear(); + } + + // ============================================ + // Thinking Block Management + // ============================================ + + async appendThinking(content: string): Promise { + const { state, renderer } = this.deps; + if (!state.currentContentEl) return; + + this.hideThinkingIndicator(); + if (!state.currentThinkingState) { + state.currentThinkingState = createThinkingBlock( + state.currentContentEl, + (el, md) => renderer.renderContent(el, md) + ); + } + + state.currentThinkingState.content += content; + void this.scheduleCurrentThinkingRender(); + } + + async finalizeCurrentThinkingBlock(msg?: ChatMessage): Promise { + const { state, renderer } = this.deps; + if (!state.currentThinkingState) return; + await this.flushPendingThinkingRender(); + + const thinkingState = state.currentThinkingState; + if (this.getStreamingRenderOptions(thinkingState.content)) { + await renderer.renderContent(thinkingState.contentEl, thinkingState.content); + } + + const durationSeconds = finalizeThinkingBlock(thinkingState); + + if (msg && thinkingState.content) { + msg.contentBlocks = msg.contentBlocks || []; + msg.contentBlocks.push({ + type: 'thinking', + content: thinkingState.content, + durationSeconds, + }); + } + + state.currentThinkingState = null; + } + + private scheduleCurrentThinkingRender(): Promise { + if (!this.pendingThinkingRenderPromise) { + this.pendingThinkingRenderPromise = new Promise(resolve => { + this.resolvePendingThinkingRender = resolve; + }); + } + + if (this.pendingThinkingRenderFrame === null && !this.isThinkingRenderRunning) { + this.pendingThinkingRenderFrame = scheduleAnimationFrame(() => { + this.pendingThinkingRenderFrame = null; + void this.renderPendingThinking(); + }, this.getThinkingRenderWindow()); + } + + return this.pendingThinkingRenderPromise; + } + + private async flushPendingThinkingRender(): Promise { + const pendingRender = this.pendingThinkingRenderPromise; + if (!pendingRender) return; + + if (this.pendingThinkingRenderFrame !== null) { + cancelScheduledAnimationFrame(this.pendingThinkingRenderFrame); + this.pendingThinkingRenderFrame = null; + void this.renderPendingThinking(); + } + + await pendingRender; + } + + private async renderPendingThinking(): Promise { + if (this.isThinkingRenderRunning) return; + this.isThinkingRenderRunning = true; + + const { state, renderer } = this.deps; + const thinkingState = state.currentThinkingState; + const content = thinkingState?.content ?? ''; + + try { + if (thinkingState) { + const options = this.getStreamingRenderOptions(content); + if (options) { + await renderer.renderContent(thinkingState.contentEl, content, options); + } else { + await renderer.renderContent(thinkingState.contentEl, content); + } + this.scrollToBottom(); + } + } catch { + // MessageRenderer owns user-visible render fallback; keep stream state moving. + } finally { + this.isThinkingRenderRunning = false; + } + + if (state.currentThinkingState === thinkingState && thinkingState && thinkingState.content !== content) { + this.pendingThinkingRenderFrame = scheduleAnimationFrame(() => { + this.pendingThinkingRenderFrame = null; + void this.renderPendingThinking(); + }, this.getThinkingRenderWindow()); + return; + } + + const resolve = this.resolvePendingThinkingRender; + this.pendingThinkingRenderPromise = null; + this.resolvePendingThinkingRender = null; + resolve?.(); + } + + private cancelPendingThinkingRender(): void { + if (this.pendingThinkingRenderFrame !== null) { + cancelScheduledAnimationFrame(this.pendingThinkingRenderFrame); + this.pendingThinkingRenderFrame = null; + } + + const resolve = this.resolvePendingThinkingRender; + this.pendingThinkingRenderPromise = null; + this.resolvePendingThinkingRender = null; + resolve?.(); + } + + // ============================================ + // Subagent Tool Handling (via SubagentManager) + // ============================================ + + /** Delegates Agent tool_use to SubagentManager and updates message based on result. */ + private handleTaskToolUseViaManager( + chunk: { type: 'tool_use'; id: string; name: string; input: Record }, + msg: ChatMessage + ): void { + const { state, subagentManager } = this.deps; + this.ensureTaskToolCall(msg, chunk.id, chunk.input); + + const result = subagentManager.handleTaskToolUse(chunk.id, chunk.input, state.currentContentEl); + + switch (result.action) { + case 'created_sync': + this.recordSubagentInMessage(msg, result.subagentState.info, chunk.id); + this.showThinkingIndicator(); + break; + case 'created_async': + this.recordSubagentInMessage(msg, result.info, chunk.id, 'async'); + this.showThinkingIndicator(); + break; + case 'buffered': + this.showThinkingIndicator(); + break; + case 'label_updated': + break; + } + } + + /** Renders a pending Agent tool call via SubagentManager and updates message. */ + private renderPendingTaskViaManager(toolId: string, msg: ChatMessage): void { + const result = this.deps.subagentManager.renderPendingTask(toolId, this.deps.state.currentContentEl); + if (!result) return; + + if (result.mode === 'sync') { + this.recordSubagentInMessage(msg, result.subagentState.info, toolId); + } else { + this.recordSubagentInMessage(msg, result.info, toolId, 'async'); + } + } + + /** Resolves a pending Agent tool call when its own tool_result arrives. */ + private renderPendingTaskFromTaskResultViaManager( + chunk: { id: string; content: string; isError?: boolean; toolUseResult?: unknown }, + msg: ChatMessage + ): void { + const result = this.deps.subagentManager.renderPendingTaskFromTaskResult( + chunk.id, + chunk.content, + chunk.isError || false, + this.deps.state.currentContentEl, + chunk.toolUseResult + ); + if (!result) return; + + if (result.mode === 'sync') { + this.recordSubagentInMessage(msg, result.subagentState.info, chunk.id); + } else { + this.recordSubagentInMessage(msg, result.info, chunk.id, 'async'); + } + } + + private recordSubagentInMessage( + msg: ChatMessage, + info: SubagentInfo, + toolId: string, + mode?: 'async' + ): void { + const taskToolCall = this.ensureTaskToolCall(msg, toolId); + this.applySubagentToTaskToolCall(taskToolCall, info); + + msg.contentBlocks = msg.contentBlocks || []; + const existingBlock = msg.contentBlocks.find( + block => block.type === 'subagent' && block.subagentId === toolId + ); + if (existingBlock && mode && existingBlock.type === 'subagent') { + existingBlock.mode = mode; + } else if (!existingBlock) { + msg.contentBlocks.push(mode + ? { type: 'subagent', subagentId: toolId, mode } + : { type: 'subagent', subagentId: toolId } + ); + } + } + + private async handleSubagentChunk( + chunk: Extract, + msg: ChatMessage, + ): Promise { + const parentToolUseId = chunk.subagentId; + const { subagentManager } = this.deps; + + // If parent Agent call is still pending, child chunk confirms it's sync - render now + if (subagentManager.hasPendingTask(parentToolUseId)) { + this.renderPendingTaskViaManager(parentToolUseId, msg); + } + + const subagentState = subagentManager.getSyncSubagent(parentToolUseId); + + if (!subagentState) { + return; + } + + switch (chunk.type) { + case 'subagent_tool_use': { + const toolCall: ToolCallInfo = { + id: chunk.id, + name: chunk.name, + input: chunk.input, + status: 'running', + isExpanded: false, + }; + subagentManager.addSyncToolCall(parentToolUseId, toolCall); + this.showThinkingIndicator(); + break; + } + + case 'subagent_tool_result': { + const toolCall = subagentState.info.toolCalls.find((tc: ToolCallInfo) => tc.id === chunk.id); + if (toolCall) { + const normalizedContent = this.normalizeToolResultContent(chunk.content); + const isBlocked = isBlockedToolResult(normalizedContent, chunk.isError); + toolCall.status = isBlocked ? 'blocked' : (chunk.isError ? 'error' : 'completed'); + toolCall.result = normalizedContent; + subagentManager.updateSyncToolResult(parentToolUseId, chunk.id, toolCall); + } + break; + } + + default: + break; + } + } + + /** Finalizes a sync subagent when its Agent tool_result is received. */ + private finalizeSubagent( + chunk: { type: 'tool_result'; id: string; content: string; isError?: boolean; toolUseResult?: unknown }, + msg: ChatMessage + ): void { + const isError = chunk.isError || false; + const normalizedContent = this.normalizeToolResultContent(chunk.content); + const finalized = this.deps.subagentManager.finalizeSyncSubagent( + chunk.id, chunk.content, isError, chunk.toolUseResult + ); + + const extractedResult = finalized?.result ?? normalizedContent; + + const taskToolCall = this.ensureTaskToolCall(msg, chunk.id); + taskToolCall.status = isError ? 'error' : 'completed'; + taskToolCall.result = extractedResult; + if (taskToolCall.subagent) { + taskToolCall.subagent.status = isError ? 'error' : 'completed'; + taskToolCall.subagent.result = extractedResult; + } + + if (finalized) { + this.applySubagentToTaskToolCall(taskToolCall, finalized); + } + + this.showThinkingIndicator(); + } + + // ============================================ + // Async Subagent Handling + // ============================================ + + /** Handles TaskOutput tool_use (invisible, links to async subagent). */ + private handleAgentOutputToolUse( + chunk: { type: 'tool_use'; id: string; name: string; input: Record }, + _msg: ChatMessage + ): void { + const toolCall: ToolCallInfo = { + id: chunk.id, + name: chunk.name, + input: chunk.input, + status: 'running', + isExpanded: false, + }; + + this.deps.subagentManager.handleAgentOutputToolUse(toolCall); + + // Show flavor text while waiting for TaskOutput result + this.showThinkingIndicator(); + } + + private handleAsyncTaskToolResult( + chunk: { type: 'tool_result'; id: string; content: string; isError?: boolean; toolUseResult?: unknown } + ): boolean { + const { subagentManager } = this.deps; + if (!subagentManager.isPendingAsyncTask(chunk.id)) { + return false; + } + + subagentManager.handleTaskToolResult(chunk.id, chunk.content, chunk.isError, chunk.toolUseResult); + return true; + } + + /** Handles TaskOutput result to finalize async subagent. */ + private async handleAgentOutputToolResult( + chunk: { type: 'tool_result'; id: string; content: string; isError?: boolean; toolUseResult?: unknown } + ): Promise { + const { subagentManager } = this.deps; + const isLinked = subagentManager.isLinkedAgentOutputTool(chunk.id); + + const handled = subagentManager.handleAgentOutputToolResult( + chunk.id, + chunk.content, + chunk.isError || false, + chunk.toolUseResult + ); + + await this.hydrateAsyncSubagentToolCalls(handled); + + return isLinked || handled !== undefined; + } + + private async handleAsyncSubagentResult( + chunk: Extract + ): Promise { + const handled = this.deps.subagentManager.handleAsyncSubagentResult( + chunk.agentId, + chunk.status, + chunk.result + ); + + await this.hydrateAsyncSubagentToolCalls(handled); + if (handled) { + this.showThinkingIndicator(); + } + } + + private async hydrateAsyncSubagentToolCalls(subagent: SubagentInfo | undefined): Promise { + if (!subagent) return; + if (subagent.mode !== 'async') return; + if (!subagent.agentId) return; + + const asyncStatus = subagent.asyncStatus ?? subagent.status; + if (asyncStatus !== 'completed' && asyncStatus !== 'error') return; + + const runtime = this.deps.getAgentService?.(); + if (!runtime) return; + + const { hasHydrated, finalResultHydrated } = await this.tryHydrateAsyncSubagent( + subagent, + runtime, + true + ); + + if (hasHydrated) { + this.deps.subagentManager.refreshAsyncSubagent(subagent); + } + + if (!finalResultHydrated) { + this.scheduleAsyncSubagentResultRetry(subagent, runtime, 0); + } + } + + private async tryHydrateAsyncSubagent( + subagent: SubagentInfo, + runtime: ChatRuntime, + hydrateToolCalls: boolean + ): Promise<{ hasHydrated: boolean; finalResultHydrated: boolean }> { + let hasHydrated = false; + let finalResultHydrated = false; + + if (hydrateToolCalls && !subagent.toolCalls?.length) { + const recoveredToolCalls = await runtime.loadSubagentToolCalls?.( + subagent.agentId || '' + ) ?? []; + if (recoveredToolCalls.length > 0) { + subagent.toolCalls = recoveredToolCalls.map((toolCall) => ({ + ...toolCall, + input: { ...toolCall.input }, + })); + hasHydrated = true; + } + } + + const recoveredFinalResult = await runtime.loadSubagentFinalResult?.( + subagent.agentId || '' + ) ?? null; + if (recoveredFinalResult && recoveredFinalResult.trim().length > 0) { + finalResultHydrated = true; + if (recoveredFinalResult !== subagent.result) { + subagent.result = recoveredFinalResult; + hasHydrated = true; + } + } + + return { hasHydrated, finalResultHydrated }; + } + + private scheduleAsyncSubagentResultRetry( + subagent: SubagentInfo, + runtime: ChatRuntime, + attempt: number + ): void { + if (!subagent.agentId) return; + if (attempt >= StreamController.ASYNC_SUBAGENT_RESULT_RETRY_DELAYS_MS.length) return; + + const delay = StreamController.ASYNC_SUBAGENT_RESULT_RETRY_DELAYS_MS[attempt]; + window.setTimeout(() => { + void this.retryAsyncSubagentResult(subagent, runtime, attempt); + }, delay); + } + + private async retryAsyncSubagentResult( + subagent: SubagentInfo, + runtime: ChatRuntime, + attempt: number + ): Promise { + if (!subagent.agentId) return; + const asyncStatus = subagent.asyncStatus ?? subagent.status; + if (asyncStatus !== 'completed' && asyncStatus !== 'error') return; + + const { hasHydrated, finalResultHydrated } = await this.tryHydrateAsyncSubagent( + subagent, + runtime, + false + ); + if (hasHydrated) { + this.deps.subagentManager.refreshAsyncSubagent(subagent); + } + + if (!finalResultHydrated) { + this.scheduleAsyncSubagentResultRetry(subagent, runtime, attempt + 1); + } + } + + /** Callback from SubagentManager when async state changes. Updates messages only (DOM handled by manager). */ + onAsyncSubagentStateChange(subagent: SubagentInfo): void { + this.updateSubagentInMessages(subagent); + this.scrollToBottom(); + } + + private updateSubagentInMessages(subagent: SubagentInfo): void { + const { state } = this.deps; + for (let i = state.messages.length - 1; i >= 0; i--) { + const msg = state.messages[i]; + if (msg.role !== 'assistant') continue; + if (this.linkTaskToolCallToSubagent(msg, subagent)) { + return; + } + } + } + + private ensureTaskToolCall( + msg: ChatMessage, + toolId: string, + input?: Record + ): ToolCallInfo { + msg.toolCalls = msg.toolCalls || []; + const existing = msg.toolCalls.find( + tc => tc.id === toolId && isSubagentToolName(tc.name) + ); + if (existing) { + if (input && Object.keys(input).length > 0) { + existing.input = { ...existing.input, ...input }; + } + return existing; + } + + const taskToolCall: ToolCallInfo = { + id: toolId, + name: TOOL_TASK, + input: input ? { ...input } : {}, + status: 'running', + isExpanded: false, + }; + msg.toolCalls.push(taskToolCall); + return taskToolCall; + } + + private applySubagentToTaskToolCall(taskToolCall: ToolCallInfo, subagent: SubagentInfo): void { + taskToolCall.subagent = subagent; + if (subagent.status === 'completed') taskToolCall.status = 'completed'; + else if (subagent.status === 'error') taskToolCall.status = 'error'; + else taskToolCall.status = 'running'; + if (subagent.result !== undefined) { + taskToolCall.result = subagent.result; + } + } + + private linkTaskToolCallToSubagent(msg: ChatMessage, subagent: SubagentInfo): boolean { + const taskToolCall = msg.toolCalls?.find( + tc => tc.id === subagent.id && isSubagentToolName(tc.name) + ); + if (!taskToolCall) return false; + this.applySubagentToTaskToolCall(taskToolCall, subagent); + return true; + } + + // ============================================ + // Thinking Indicator + // ============================================ + + /** Debounce delay before showing thinking indicator (ms). */ + private static readonly THINKING_INDICATOR_DELAY = 400; + + /** + * Schedules showing the thinking indicator after a delay. + * If content arrives before the delay, the indicator won't show. + * This prevents the indicator from appearing during active streaming. + * Note: Flavor text is hidden when model thinking block is active (thinking takes priority). + */ + showThinkingIndicator(overrideText?: string, overrideCls?: string): void { + const { state } = this.deps; + + // Early return if no content element + if (!state.currentContentEl) return; + + // Clear any existing timeout + if (state.thinkingIndicatorTimeout) { + const timerWindow = state.currentContentEl.ownerDocument.defaultView ?? window; + state.clearThinkingIndicatorTimeout(timerWindow); + } + + // Don't show flavor text while model thinking block is active + if (state.currentThinkingState) { + return; + } + + // If indicator already exists, just re-append it to the bottom + if (state.thinkingEl) { + state.currentContentEl.appendChild(state.thinkingEl); + this.deps.updateQueueIndicator(); + return; + } + + // Schedule showing the indicator after a delay + const timerWindow = state.currentContentEl.ownerDocument.defaultView ?? window; + state.setThinkingIndicatorTimeout(timerWindow.setTimeout(() => { + state.setThinkingIndicatorTimeout(null, null); + // Double-check we still have a content element, no indicator exists, and no thinking block + if (!state.currentContentEl || state.thinkingEl || state.currentThinkingState) return; + + const cls = overrideCls + ? `claudian-thinking ${overrideCls}` + : 'claudian-thinking'; + state.thinkingEl = state.currentContentEl.createDiv({ cls }); + const text = overrideText || FLAVOR_TEXTS[Math.floor(Math.random() * FLAVOR_TEXTS.length)]; + state.thinkingEl.createSpan({ text }); + + // Create timer span with initial value + const timerSpan = state.thinkingEl.createSpan({ cls: 'claudian-thinking-hint' }); + const updateTimer = () => { + if (!state.responseStartTime) return; + // Check if element is still connected to DOM (prevents orphaned interval updates) + if (!timerSpan.isConnected) { + if (state.flavorTimerInterval) { + state.clearFlavorTimerInterval(); + } + return; + } + const elapsedSeconds = Math.floor((performance.now() - state.responseStartTime) / 1000); + timerSpan.setText(` (esc to interrupt · ${formatDurationMmSs(elapsedSeconds)})`); + }; + updateTimer(); // Initial update + + // Start interval to update timer every second + if (state.flavorTimerInterval) { + state.clearFlavorTimerInterval(); + } + const thinkingWindow = state.currentContentEl.ownerDocument.defaultView ?? timerWindow; + state.setFlavorTimerInterval(thinkingWindow.setInterval(updateTimer, 1000), thinkingWindow); + + }, StreamController.THINKING_INDICATOR_DELAY), timerWindow); + } + + /** Hides the thinking indicator and cancels any pending show timeout. */ + hideThinkingIndicator(): void { + const { state } = this.deps; + + // Cancel any pending show timeout + if (state.thinkingIndicatorTimeout) { + const activeWindow = this.deps.getMessagesEl().ownerDocument.defaultView ?? window; + state.clearThinkingIndicatorTimeout(activeWindow); + } + + // Clear timer interval (but preserve responseStartTime for duration capture) + state.clearFlavorTimerInterval(); + + if (state.thinkingEl) { + state.thinkingEl.remove(); + state.thinkingEl = null; + } + } + + // ============================================ + // Compact Boundary + // ============================================ + + private renderCompactBoundary(): void { + const { state } = this.deps; + if (!state.currentContentEl) return; + this.hideThinkingIndicator(); + const el = state.currentContentEl.createDiv({ cls: 'claudian-compact-boundary' }); + el.createSpan({ cls: 'claudian-compact-boundary-label', text: 'Conversation compacted' }); + } + + // ============================================ + // Utilities + // ============================================ + + /** + * Nudges Obsidian's vault after a Write/Edit/NotebookEdit so the file tree + * refreshes. Direct `fs` writes bypass the Vault API, and macOS + iCloud + * FSWatcher often misses the event. + */ + private notifyVaultFileChange(input: Record): void { + const rawPathValue = input.file_path ?? input.notebook_path; + const rawPath = typeof rawPathValue === 'string' ? rawPathValue : undefined; + const vaultPath = getVaultPath(this.deps.plugin.app); + const relativePath = normalizePathForVault(rawPath, vaultPath); + if (!relativePath || relativePath.startsWith('/')) return; + + window.setTimeout(() => { + const { vault } = this.deps.plugin.app; + const file = vault.getAbstractFileByPath(relativePath); + if (file instanceof TFile) { + // Existing file — tell listeners the content changed + vault.trigger('modify', file); + } else { + // New file — scan parent directory so Obsidian discovers it + const parentDir = relativePath.includes('/') + ? relativePath.substring(0, relativePath.lastIndexOf('/')) + : ''; + vault.adapter.list(parentDir).catch(() => { /* ignore */ }); + } + }, 200); + } + + /** Refreshes vault for each file path in an apply_patch changes array or patch text. */ + private notifyApplyPatchFileChanges(input: Record): void { + const notified = new Set(); + + // Legacy changes array + const changes = input.changes; + if (Array.isArray(changes)) { + for (const change of changes) { + if (change && typeof change === 'object' && !Array.isArray(change)) { + const changeRecord = change as Record; + if (typeof changeRecord.path === 'string') { + notified.add(changeRecord.path); + this.notifyVaultFileChange({ file_path: changeRecord.path }); + } + } + } + } + + // Parse file paths from patch text markers (current custom_tool_call format) + const patchText = typeof input.patch === 'string' ? input.patch : ''; + if (patchText) { + for (const match of patchText.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)) { + const filePath = match[1]?.trim(); + if (filePath && !notified.has(filePath)) { + this.notifyVaultFileChange({ file_path: filePath }); + } + } + } + } + + /** Scrolls messages to bottom if auto-scroll is enabled. */ + private scrollToBottom(): void { + if (this.pendingScrollFrame !== null) return; + + this.pendingScrollFrame = scheduleAnimationFrame(() => { + this.pendingScrollFrame = null; + this.applyScrollToBottom(); + }, this.getMessagesWindow()); + } + + private applyScrollToBottom(): void { + const { state, plugin } = this.deps; + if (!(plugin.settings.enableAutoScroll ?? true)) return; + if (!state.autoScrollEnabled) return; + + const messagesEl = this.deps.getMessagesEl(); + messagesEl.scrollTop = messagesEl.scrollHeight; + } + + private cancelPendingScroll(): void { + if (this.pendingScrollFrame === null) return; + + cancelScheduledAnimationFrame(this.pendingScrollFrame); + this.pendingScrollFrame = null; + } + + private getMessagesWindow(): Window | null { + return this.deps.getMessagesEl().ownerDocument.defaultView ?? null; + } + + private getStreamingRenderWindow(): Window | null { + const { state } = this.deps; + return state.currentTextEl?.ownerDocument?.defaultView + ?? state.currentContentEl?.ownerDocument?.defaultView + ?? this.getMessagesWindow(); + } + + private getThinkingRenderWindow(): Window | null { + const { state } = this.deps; + return state.currentThinkingState?.contentEl.ownerDocument?.defaultView + ?? state.currentContentEl?.ownerDocument?.defaultView + ?? this.getMessagesWindow(); + } + + resetStreamingState(): void { + const { state } = this.deps; + this.cancelPendingTextRender(); + this.cancelPendingThinkingRender(); + this.cancelPendingToolOutputRenders(); + this.cancelPendingScroll(); + this.hideThinkingIndicator(); + state.currentContentEl = null; + state.currentTextEl = null; + state.currentTextContent = ''; + state.currentThinkingState = null; + this.deps.subagentManager.resetStreamingState(); + state.pendingTools.clear(); + // Reset response timer (duration already captured at this point) + state.responseStartTime = null; + } +} diff --git a/src/features/chat/controllers/TurnCoordinator.ts b/src/features/chat/controllers/TurnCoordinator.ts new file mode 100644 index 0000000..e9897a2 --- /dev/null +++ b/src/features/chat/controllers/TurnCoordinator.ts @@ -0,0 +1,30 @@ +export interface ActiveTurnOwner { + activeTurn: Promise | null; +} + +/** Owns the lifetime of the existing turn orchestration without changing its phases. */ +export class TurnCoordinator { + private activeTurn: Promise | null = null; + + constructor( + private readonly executeTurn: (request?: TRequest) => Promise, + private readonly owner?: ActiveTurnOwner, + ) {} + + get current(): Promise | null { + return this.activeTurn; + } + + async run(request?: TRequest): Promise { + const execution = this.executeTurn(request); + this.activeTurn = execution; + if (this.owner) this.owner.activeTurn = execution; + + try { + await execution; + } finally { + if (this.activeTurn === execution) this.activeTurn = null; + if (this.owner?.activeTurn === execution) this.owner.activeTurn = null; + } + } +} diff --git a/src/features/chat/rendering/DiffRenderer.ts b/src/features/chat/rendering/DiffRenderer.ts new file mode 100644 index 0000000..83faeb8 --- /dev/null +++ b/src/features/chat/rendering/DiffRenderer.ts @@ -0,0 +1,134 @@ +import type { DiffLine, DiffStats } from '../../../core/types/diff'; + +export interface DiffHunk { + lines: DiffLine[]; + oldStart: number; + newStart: number; +} + +export function renderDiffStats(statsEl: HTMLElement, stats: DiffStats): void { + if (stats.added > 0) { + const addedEl = statsEl.createSpan({ cls: 'added' }); + addedEl.setText(`+${stats.added}`); + } + if (stats.removed > 0) { + if (stats.added > 0) { + statsEl.createSpan({ text: ' ' }); + } + const removedEl = statsEl.createSpan({ cls: 'removed' }); + removedEl.setText(`-${stats.removed}`); + } +} + +export function splitIntoHunks(diffLines: DiffLine[], contextLines = 3): DiffHunk[] { + if (diffLines.length === 0) return []; + + // Find indices of all changed lines + const changedIndices: number[] = []; + for (let i = 0; i < diffLines.length; i++) { + if (diffLines[i].type !== 'equal') { + changedIndices.push(i); + } + } + + // If no changes, return empty + if (changedIndices.length === 0) return []; + + // Group changed lines into ranges with context + const ranges: Array<{ start: number; end: number }> = []; + + for (const idx of changedIndices) { + const start = Math.max(0, idx - contextLines); + const end = Math.min(diffLines.length - 1, idx + contextLines); + + // Merge with previous range if overlapping or adjacent + if (ranges.length > 0 && start <= ranges[ranges.length - 1].end + 1) { + ranges[ranges.length - 1].end = end; + } else { + ranges.push({ start, end }); + } + } + + // Convert ranges to hunks + const hunks: DiffHunk[] = []; + + for (const range of ranges) { + const lines = diffLines.slice(range.start, range.end + 1); + + // Find the starting line numbers for this hunk + let oldStart = 1; + let newStart = 1; + + // Count lines before this range + for (let i = 0; i < range.start; i++) { + const line = diffLines[i]; + if (line.type === 'equal' || line.type === 'delete') oldStart++; + if (line.type === 'equal' || line.type === 'insert') newStart++; + } + + hunks.push({ lines, oldStart, newStart }); + } + + return hunks; +} + +/** Max lines to render for all-inserts diffs (new file creation). */ +const NEW_FILE_DISPLAY_CAP = 20; + +export function renderDiffContent( + containerEl: HTMLElement, + diffLines: DiffLine[], + contextLines = 3 +): void { + containerEl.empty(); + + // New file creation: all lines are inserts — cap display to avoid large DOM + const allInserts = diffLines.length > 0 && diffLines.every(l => l.type === 'insert'); + if (allInserts && diffLines.length > NEW_FILE_DISPLAY_CAP) { + const hunkEl = containerEl.createDiv({ cls: 'claudian-diff-hunk' }); + for (const line of diffLines.slice(0, NEW_FILE_DISPLAY_CAP)) { + const lineEl = hunkEl.createDiv({ cls: 'claudian-diff-line claudian-diff-insert' }); + const prefixEl = lineEl.createSpan({ cls: 'claudian-diff-prefix' }); + prefixEl.setText('+'); + const contentEl = lineEl.createSpan({ cls: 'claudian-diff-text' }); + contentEl.setText(line.text || ' '); + } + const remaining = diffLines.length - NEW_FILE_DISPLAY_CAP; + const separator = containerEl.createDiv({ cls: 'claudian-diff-separator' }); + separator.setText(`... ${remaining} more lines`); + return; + } + + const hunks = splitIntoHunks(diffLines, contextLines); + + if (hunks.length === 0) { + // No changes + const noChanges = containerEl.createDiv({ cls: 'claudian-diff-no-changes' }); + noChanges.setText('No changes'); + return; + } + + hunks.forEach((hunk, hunkIndex) => { + // Add separator between hunks + if (hunkIndex > 0) { + const separator = containerEl.createDiv({ cls: 'claudian-diff-separator' }); + separator.setText('...'); + } + + // Render hunk lines + const hunkEl = containerEl.createDiv({ cls: 'claudian-diff-hunk' }); + + for (const line of hunk.lines) { + const lineEl = hunkEl.createDiv({ cls: `claudian-diff-line claudian-diff-${line.type}` }); + + // Line prefix + const prefix = line.type === 'insert' ? '+' : line.type === 'delete' ? '-' : ' '; + const prefixEl = lineEl.createSpan({ cls: 'claudian-diff-prefix' }); + prefixEl.setText(prefix); + + // Line content + const contentEl = lineEl.createSpan({ cls: 'claudian-diff-text' }); + contentEl.setText(line.text || ' '); // Show space for empty lines + } + }); +} diff --git a/src/features/chat/rendering/InlineAskUserQuestion.ts b/src/features/chat/rendering/InlineAskUserQuestion.ts new file mode 100644 index 0000000..5a6cd96 --- /dev/null +++ b/src/features/chat/rendering/InlineAskUserQuestion.ts @@ -0,0 +1,702 @@ +import type { AskUserQuestionItem, AskUserQuestionOption } from '../../../core/types/tools'; + +const HINTS_TEXT = 'Enter to select \u00B7 Tab/Arrow keys to navigate \u00B7 Esc to cancel'; +const HINTS_TEXT_IMMEDIATE = 'Enter to select \u00B7 Arrow keys to navigate \u00B7 Esc to cancel'; + +export interface InlineAskQuestionConfig { + title?: string; + headerEl?: HTMLElement; + showCustomInput?: boolean; + immediateSelect?: boolean; +} + +export class InlineAskUserQuestion { + private containerEl: HTMLElement; + private input: Record; + private resolveCallback: (result: Record | null) => void; + private resolved = false; + private signal?: AbortSignal; + private config: Required> & { headerEl?: HTMLElement }; + + private questions: AskUserQuestionItem[] = []; + private answers = new Map>(); + private customInputs = new Map(); + + private activeTabIndex = 0; + private focusedItemIndex = 0; + private isInputFocused = false; + + private rootEl!: HTMLElement; + private tabBar!: HTMLElement; + private contentArea!: HTMLElement; + private tabElements: HTMLElement[] = []; + private currentItems: HTMLElement[] = []; + private boundKeyDown: (e: KeyboardEvent) => void; + private abortHandler: (() => void) | null = null; + + constructor( + containerEl: HTMLElement, + input: Record, + resolve: (result: Record | null) => void, + signal?: AbortSignal, + config?: InlineAskQuestionConfig, + ) { + this.containerEl = containerEl; + this.input = input; + this.resolveCallback = resolve; + this.signal = signal; + this.config = { + title: config?.title ?? 'Question', + headerEl: config?.headerEl, + showCustomInput: config?.showCustomInput ?? true, + immediateSelect: config?.immediateSelect ?? false, + }; + this.boundKeyDown = (event) => this.handleKeyDown(event); + } + + render(): void { + this.rootEl = this.containerEl.createDiv({ cls: 'claudian-ask-question-inline' }); + + const titleEl = this.rootEl.createDiv({ cls: 'claudian-ask-inline-title' }); + titleEl.setText(this.config.title); + + if (this.config.headerEl) { + this.rootEl.appendChild(this.config.headerEl); + } + + this.questions = this.parseQuestions(); + + if (this.questions.length === 0) { + this.handleResolve(null); + return; + } + + if (this.config.immediateSelect && this.questions.length !== 1) { + this.config.immediateSelect = false; + } + + for (let i = 0; i < this.questions.length; i++) { + this.answers.set(i, new Set()); + this.customInputs.set(i, ''); + } + + if (!this.config.immediateSelect) { + this.tabBar = this.rootEl.createDiv({ cls: 'claudian-ask-tab-bar' }); + this.renderTabBar(); + } + this.contentArea = this.rootEl.createDiv({ cls: 'claudian-ask-content' }); + this.renderTabContent(); + + this.rootEl.setAttribute('tabindex', '0'); + this.rootEl.addEventListener('keydown', this.boundKeyDown); + + // Defer focus to after the element is in the DOM and laid out + window.requestAnimationFrame(() => { + this.rootEl.focus(); + this.rootEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }); + + if (this.signal) { + this.abortHandler = () => this.handleResolve(null); + this.signal.addEventListener('abort', this.abortHandler, { once: true }); + } + } + + destroy(): void { + this.handleResolve(null); + } + + private parseQuestions(): AskUserQuestionItem[] { + const raw = this.input.questions; + if (!Array.isArray(raw)) return []; + + return (raw as unknown[]) + .filter( + (q): q is { + question: string; + header?: string; + options?: unknown[] | null; + multiSelect?: boolean; + isOther?: boolean; + isSecret?: boolean; + id?: string; + } => { + if (!q || typeof q !== 'object' || Array.isArray(q)) { + return false; + } + const record = q as Record; + return typeof record.question === 'string' + && ((Array.isArray(record.options) && record.options.length > 0) || record.isOther === true); + }, + ) + .map((q, idx) => ({ + question: q.question, + id: typeof (q as Record).id === 'string' ? (q as Record).id as string : undefined, + header: typeof q.header === 'string' ? q.header.slice(0, 12) : `Q${idx + 1}`, + options: this.deduplicateOptions((q.options ?? []).map((o) => this.coerceOption(o))), + multiSelect: q.multiSelect === true, + isOther: q.isOther === true, + isSecret: q.isSecret === true, + })); + } + + private coerceOption(opt: unknown): AskUserQuestionOption { + if (typeof opt === 'object' && opt !== null) { + const obj = opt as Record; + const label = this.extractLabel(obj); + const description = typeof obj.description === 'string' ? obj.description : ''; + const value = this.extractValue(obj, label); + return { label, description, ...(value !== label ? { value } : {}) }; + } + return { label: this.stringifyOptionValue(opt), description: '' }; + } + + private deduplicateOptions(options: AskUserQuestionOption[]): AskUserQuestionOption[] { + const seen = new Set(); + return options.filter((o) => { + if (seen.has(o.label)) return false; + seen.add(o.label); + return true; + }); + } + + private extractLabel(obj: Record): string { + if (typeof obj.label === 'string') return obj.label; + if (typeof obj.value === 'string') return obj.value; + if (typeof obj.text === 'string') return obj.text; + if (typeof obj.name === 'string') return obj.name; + return 'Option'; + } + + private stringifyOptionValue(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return `${value}`; + } + return 'Option'; + } + + private extractValue(obj: Record, fallback: string): string { + if (typeof obj.value === 'string') return obj.value; + if (typeof obj.id === 'string') return obj.id; + return fallback; + } + + private renderTabBar(): void { + this.tabBar.empty(); + this.tabElements = []; + + for (let idx = 0; idx < this.questions.length; idx++) { + const answered = this.isQuestionAnswered(idx); + const tab = this.tabBar.createSpan({ cls: 'claudian-ask-tab' }); + tab.createSpan({ text: this.questions[idx].header, cls: 'claudian-ask-tab-label' }); + tab.createSpan({ text: answered ? ' \u2713' : '', cls: 'claudian-ask-tab-tick' }); + tab.setAttribute('title', this.questions[idx].question); + + if (idx === this.activeTabIndex) tab.addClass('is-active'); + if (answered) tab.addClass('is-answered'); + tab.addEventListener('click', () => this.switchTab(idx)); + this.tabElements.push(tab); + } + + const allAnswered = this.questions.every((_, i) => this.isQuestionAnswered(i)); + const submitTab = this.tabBar.createSpan({ cls: 'claudian-ask-tab' }); + submitTab.createSpan({ text: allAnswered ? '\u2713 ' : '', cls: 'claudian-ask-tab-submit-check' }); + submitTab.createSpan({ text: 'Submit', cls: 'claudian-ask-tab-label' }); + if (this.activeTabIndex === this.questions.length) submitTab.addClass('is-active'); + submitTab.addEventListener('click', () => this.switchTab(this.questions.length)); + this.tabElements.push(submitTab); + } + + private isQuestionAnswered(idx: number): boolean { + return this.answers.get(idx)!.size > 0 || this.customInputs.get(idx)!.trim().length > 0; + } + + private switchTab(index: number): void { + const clamped = Math.max(0, Math.min(index, this.questions.length)); + if (clamped === this.activeTabIndex) return; + this.activeTabIndex = clamped; + this.focusedItemIndex = 0; + this.isInputFocused = false; + if (!this.config.immediateSelect) { + this.renderTabBar(); + } + this.renderTabContent(); + this.rootEl.focus(); + } + + private renderTabContent(): void { + this.contentArea.empty(); + this.currentItems = []; + + if (this.activeTabIndex < this.questions.length) { + this.renderQuestionTab(this.activeTabIndex); + } else { + this.renderSubmitTab(); + } + } + + private renderQuestionTab(idx: number): void { + const q = this.questions[idx]; + const isMulti = q.multiSelect; + const selected = this.answers.get(idx)!; + + this.contentArea.createDiv({ + text: q.question, + cls: 'claudian-ask-question-text', + }); + + const listEl = this.contentArea.createDiv({ cls: 'claudian-ask-list' }); + + for (let optIdx = 0; optIdx < q.options.length; optIdx++) { + const option = q.options[optIdx]; + const isFocused = optIdx === this.focusedItemIndex; + const optionValue = this.getOptionValue(option); + const isSelected = selected.has(optionValue); + + const row = listEl.createDiv({ cls: 'claudian-ask-item' }); + if (isFocused) row.addClass('is-focused'); + if (isSelected) row.addClass('is-selected'); + + row.createSpan({ text: isFocused ? '\u203A' : '\u00A0', cls: 'claudian-ask-cursor' }); + row.createSpan({ text: `${optIdx + 1}. `, cls: 'claudian-ask-item-num' }); + + if (isMulti) { + this.renderMultiSelectCheckbox(row, isSelected); + } + + const labelBlock = row.createDiv({ cls: 'claudian-ask-item-content' }); + const labelRow = labelBlock.createDiv({ cls: 'claudian-ask-label-row' }); + labelRow.createSpan({ text: option.label, cls: 'claudian-ask-item-label' }); + + if (!isMulti && isSelected) { + labelRow.createSpan({ text: ' \u2713', cls: 'claudian-ask-check-mark' }); + } + + if (option.description) { + labelBlock.createDiv({ text: option.description, cls: 'claudian-ask-item-desc' }); + } + + row.addEventListener('click', () => { + this.focusedItemIndex = optIdx; + this.updateFocusIndicator(); + this.selectOption(idx, option); + }); + + this.currentItems.push(row); + } + + if (this.canShowCustomInputForQuestion(q)) { + const customIdx = q.options.length; + const customFocused = customIdx === this.focusedItemIndex; + const customText = this.customInputs.get(idx) ?? ''; + const hasCustomText = customText.trim().length > 0; + + const customRow = listEl.createDiv({ cls: 'claudian-ask-item claudian-ask-custom-item' }); + if (customFocused) customRow.addClass('is-focused'); + + customRow.createSpan({ text: customFocused ? '\u203A' : '\u00A0', cls: 'claudian-ask-cursor' }); + customRow.createSpan({ text: `${customIdx + 1}. `, cls: 'claudian-ask-item-num' }); + + if (isMulti) { + this.renderMultiSelectCheckbox(customRow, hasCustomText); + } + + const inputEl = customRow.createEl('input', { + cls: 'claudian-ask-custom-text', + value: customText, + }); + inputEl.setAttribute('type', q.isSecret ? 'password' : 'text'); + inputEl.setAttribute('placeholder', q.isSecret ? 'Enter secret.' : 'Type something.'); + + inputEl.addEventListener('input', () => { + this.customInputs.set(idx, inputEl.value); + if (!isMulti && inputEl.value.trim()) { + selected.clear(); + this.updateOptionVisuals(idx); + } + this.updateTabIndicators(); + }); + inputEl.addEventListener('focus', () => { + this.isInputFocused = true; + }); + inputEl.addEventListener('blur', () => { + this.isInputFocused = false; + }); + + customRow.addEventListener('click', () => { + this.focusedItemIndex = customIdx; + this.updateFocusIndicator(); + inputEl.focus(); + }); + + this.currentItems.push(customRow); + } + + this.contentArea.createDiv({ + text: this.config.immediateSelect ? HINTS_TEXT_IMMEDIATE : HINTS_TEXT, + cls: 'claudian-ask-hints', + }); + } + + private renderSubmitTab(): void { + this.contentArea.createDiv({ + text: 'Review your answers', + cls: 'claudian-ask-review-title', + }); + + const reviewEl = this.contentArea.createDiv({ cls: 'claudian-ask-review' }); + + for (let idx = 0; idx < this.questions.length; idx++) { + const q = this.questions[idx]; + const answerText = this.getAnswerText(idx); + + const pairEl = reviewEl.createDiv({ cls: 'claudian-ask-review-pair' }); + pairEl.createDiv({ text: `${idx + 1}.`, cls: 'claudian-ask-review-num' }); + const bodyEl = pairEl.createDiv({ cls: 'claudian-ask-review-body' }); + bodyEl.createDiv({ text: q.question, cls: 'claudian-ask-review-q-text' }); + bodyEl.createDiv({ + text: answerText || 'Not answered', + cls: answerText ? 'claudian-ask-review-a-text' : 'claudian-ask-review-empty', + }); + pairEl.addEventListener('click', () => this.switchTab(idx)); + } + + this.contentArea.createDiv({ + text: 'Ready to submit your answers?', + cls: 'claudian-ask-review-prompt', + }); + + const actionsEl = this.contentArea.createDiv({ cls: 'claudian-ask-list' }); + const allAnswered = this.questions.every((_, i) => this.isQuestionAnswered(i)); + + const submitRow = actionsEl.createDiv({ cls: 'claudian-ask-item' }); + if (this.focusedItemIndex === 0) submitRow.addClass('is-focused'); + if (!allAnswered) submitRow.addClass('is-disabled'); + submitRow.createSpan({ text: this.focusedItemIndex === 0 ? '\u203A' : '\u00A0', cls: 'claudian-ask-cursor' }); + submitRow.createSpan({ text: '1. ', cls: 'claudian-ask-item-num' }); + submitRow.createSpan({ text: 'Submit answers', cls: 'claudian-ask-item-label' }); + submitRow.addEventListener('click', () => { + this.focusedItemIndex = 0; + this.updateFocusIndicator(); + this.handleSubmit(); + }); + this.currentItems.push(submitRow); + + const cancelRow = actionsEl.createDiv({ cls: 'claudian-ask-item' }); + if (this.focusedItemIndex === 1) cancelRow.addClass('is-focused'); + cancelRow.createSpan({ text: this.focusedItemIndex === 1 ? '\u203A' : '\u00A0', cls: 'claudian-ask-cursor' }); + cancelRow.createSpan({ text: '2. ', cls: 'claudian-ask-item-num' }); + cancelRow.createSpan({ text: 'Cancel', cls: 'claudian-ask-item-label' }); + cancelRow.addEventListener('click', () => { + this.focusedItemIndex = 1; + this.handleResolve(null); + }); + this.currentItems.push(cancelRow); + + this.contentArea.createDiv({ + text: HINTS_TEXT, + cls: 'claudian-ask-hints', + }); + } + + private getAnswerText(idx: number): string { + const selected = this.getSelectedLabels(idx); + const custom = this.customInputs.get(idx)!; + const parts: string[] = []; + if (selected.length > 0) parts.push(selected.join(', ')); + if (custom.trim()) parts.push(custom.trim()); + return parts.join(', '); + } + + private selectOption(qIdx: number, option: AskUserQuestionOption): void { + const q = this.questions[qIdx]; + const selected = this.answers.get(qIdx)!; + const isMulti = q.multiSelect; + const optionValue = this.getOptionValue(option); + + if (isMulti) { + if (selected.has(optionValue)) { + selected.delete(optionValue); + } else { + selected.add(optionValue); + } + } else { + selected.clear(); + selected.add(optionValue); + this.customInputs.set(qIdx, ''); + } + + this.updateOptionVisuals(qIdx); + + if (this.config.immediateSelect) { + const key = q.id ?? q.question; + const result: Record = {}; + result[key] = optionValue; + this.handleResolve(result); + return; + } + + this.updateTabIndicators(); + + if (!isMulti) { + this.switchTab(this.activeTabIndex + 1); + } + } + + private renderMultiSelectCheckbox(parent: HTMLElement, checked: boolean): void { + parent.createSpan({ + text: checked ? '[\u2713] ' : '[ ] ', + cls: `claudian-ask-check${checked ? ' is-checked' : ''}`, + }); + } + + private updateOptionVisuals(qIdx: number): void { + const q = this.questions[qIdx]; + const selected = this.answers.get(qIdx)!; + const isMulti = q.multiSelect; + + for (let i = 0; i < q.options.length; i++) { + const item = this.currentItems[i]; + const isSelected = selected.has(this.getOptionValue(q.options[i])); + + item.toggleClass('is-selected', isSelected); + + if (isMulti) { + const checkSpan = item.querySelector('.claudian-ask-check'); + if (checkSpan) { + checkSpan.textContent = isSelected ? '[\u2713] ' : '[ ] '; + checkSpan.toggleClass('is-checked', isSelected); + } + } else { + const labelRow = item.querySelector('.claudian-ask-label-row'); + const existingMark = item.querySelector('.claudian-ask-check-mark'); + if (isSelected && !existingMark && labelRow) { + labelRow.createSpan({ text: ' \u2713', cls: 'claudian-ask-check-mark' }); + } else if (!isSelected && existingMark) { + existingMark.remove(); + } + } + } + } + + private updateFocusIndicator(): void { + for (let i = 0; i < this.currentItems.length; i++) { + const item = this.currentItems[i]; + const cursor = item.querySelector('.claudian-ask-cursor'); + if (i === this.focusedItemIndex) { + item.addClass('is-focused'); + if (cursor) cursor.textContent = '\u203A'; + item.scrollIntoView({ block: 'nearest' }); + } else { + item.removeClass('is-focused'); + if (cursor) cursor.textContent = '\u00A0'; + } + } + } + + private updateTabIndicators(): void { + for (let idx = 0; idx < this.questions.length; idx++) { + const tab = this.tabElements[idx]; + const tick = tab.querySelector('.claudian-ask-tab-tick'); + const answered = this.isQuestionAnswered(idx); + tab.toggleClass('is-answered', answered); + if (tick) tick.textContent = answered ? ' \u2713' : ''; + } + const submitTab = this.tabElements[this.questions.length]; + if (submitTab) { + const submitCheck = submitTab.querySelector('.claudian-ask-tab-submit-check'); + const allAnswered = this.questions.every((_, i) => this.isQuestionAnswered(i)); + if (submitCheck) submitCheck.textContent = allAnswered ? '\u2713 ' : ''; + } + } + + private handleNavigationKey(e: KeyboardEvent, maxFocusIndex: number): boolean { + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + e.stopPropagation(); + this.focusedItemIndex = Math.min(this.focusedItemIndex + 1, maxFocusIndex); + this.updateFocusIndicator(); + return true; + case 'ArrowUp': + e.preventDefault(); + e.stopPropagation(); + this.focusedItemIndex = Math.max(this.focusedItemIndex - 1, 0); + this.updateFocusIndicator(); + return true; + case 'ArrowLeft': + if (this.config.immediateSelect) return false; + e.preventDefault(); + e.stopPropagation(); + this.switchTab(this.activeTabIndex - 1); + return true; + case 'Tab': + if (this.config.immediateSelect) return false; + e.preventDefault(); + e.stopPropagation(); + if (e.shiftKey) { + this.switchTab(this.activeTabIndex - 1); + } else { + this.switchTab(this.activeTabIndex + 1); + } + return true; + case 'Escape': + e.preventDefault(); + e.stopPropagation(); + this.handleResolve(null); + return true; + default: + return false; + } + } + + private handleKeyDown(e: KeyboardEvent): void { + if (this.isInputFocused) { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + this.isInputFocused = false; + (this.rootEl.ownerDocument.activeElement as HTMLElement | null)?.blur(); + this.rootEl.focus(); + return; + } + if (e.key === 'Tab' || e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + this.isInputFocused = false; + (this.rootEl.ownerDocument.activeElement as HTMLElement | null)?.blur(); + if (e.key === 'Tab' && e.shiftKey) { + this.switchTab(this.activeTabIndex - 1); + } else { + this.switchTab(this.activeTabIndex + 1); + } + return; + } + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + (this.rootEl.ownerDocument.activeElement as HTMLElement | null)?.blur(); + this.isInputFocused = false; + const q = this.questions[this.activeTabIndex]; + const maxIdx = this.canShowCustomInputForQuestion(q) ? q.options.length : q.options.length - 1; + if (e.key === 'ArrowUp') { + this.focusedItemIndex = Math.max(this.focusedItemIndex - 1, 0); + } else { + this.focusedItemIndex = Math.min(this.focusedItemIndex + 1, maxIdx); + } + this.updateFocusIndicator(); + this.rootEl.focus(); + return; + } + return; + } + + if (this.config.immediateSelect) { + const q = this.questions[this.activeTabIndex]; + const maxIdx = q.options.length - 1; + if (this.handleNavigationKey(e, maxIdx)) return; + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + if (this.focusedItemIndex <= maxIdx) { + this.selectOption(this.activeTabIndex, q.options[this.focusedItemIndex]); + } + } + return; + } + + const isSubmitTab = this.activeTabIndex === this.questions.length; + const q = this.questions[this.activeTabIndex]; + const maxFocusIndex = isSubmitTab + ? 1 + : (this.canShowCustomInputForQuestion(q) ? q.options.length : q.options.length - 1); + + if (this.handleNavigationKey(e, maxFocusIndex)) return; + + if (isSubmitTab) { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + if (this.focusedItemIndex === 0) this.handleSubmit(); + else this.handleResolve(null); + } + return; + } + + // Question tab: ArrowRight and Enter + switch (e.key) { + case 'ArrowRight': + e.preventDefault(); + e.stopPropagation(); + this.switchTab(this.activeTabIndex + 1); + break; + case 'Enter': + e.preventDefault(); + e.stopPropagation(); + if (this.focusedItemIndex < q.options.length) { + this.selectOption(this.activeTabIndex, q.options[this.focusedItemIndex]); + } else if (this.canShowCustomInputForQuestion(q)) { + this.isInputFocused = true; + const customRow = this.currentItems[this.focusedItemIndex]; + const input = customRow?.querySelector('.claudian-ask-custom-text') as HTMLInputElement; + input?.focus(); + } + break; + } + } + + private handleSubmit(): void { + const allAnswered = this.questions.every((_, i) => this.isQuestionAnswered(i)); + if (!allAnswered) return; + + const result: Record = {}; + for (let i = 0; i < this.questions.length; i++) { + const question = this.questions[i]; + const key = question.id ?? question.question; + const selectedValues = [...this.answers.get(i)!]; + const customInput = this.customInputs.get(i)!.trim(); + + if (question.multiSelect) { + const answers = [...selectedValues]; + if (customInput) { + answers.push(customInput); + } + result[key] = answers; + continue; + } + + result[key] = customInput || selectedValues[0] || ''; + } + this.handleResolve(result); + } + + private canShowCustomInputForQuestion(question: AskUserQuestionItem): boolean { + return this.config.showCustomInput && question.isOther === true; + } + + private getOptionValue(option: AskUserQuestionOption): string { + return option.value ?? option.label; + } + + private getSelectedLabels(idx: number): string[] { + const selected = this.answers.get(idx)!; + const question = this.questions[idx]; + return question.options + .filter(option => selected.has(this.getOptionValue(option))) + .map(option => option.label); + } + + private handleResolve(result: Record | null): void { + if (!this.resolved) { + this.resolved = true; + this.rootEl?.removeEventListener('keydown', this.boundKeyDown); + if (this.signal && this.abortHandler) { + this.signal.removeEventListener('abort', this.abortHandler); + this.abortHandler = null; + } + this.rootEl?.remove(); + this.resolveCallback(result); + } + } +} diff --git a/src/features/chat/rendering/InlineExitPlanMode.ts b/src/features/chat/rendering/InlineExitPlanMode.ts new file mode 100644 index 0000000..3847af2 --- /dev/null +++ b/src/features/chat/rendering/InlineExitPlanMode.ts @@ -0,0 +1,263 @@ +import * as fs from 'fs'; +import * as nodePath from 'path'; + +import type { ExitPlanModeDecision } from '../../../core/types/tools'; +import type { RenderContentFn } from './MessageRenderer'; + +const HINTS_TEXT = 'Arrow keys to navigate \u00B7 Enter to select \u00B7 Esc to cancel'; + +export class InlineExitPlanMode { + private containerEl: HTMLElement; + private input: Record; + private resolveCallback: (decision: ExitPlanModeDecision | null) => void; + private resolved = false; + private signal?: AbortSignal; + private renderContent?: RenderContentFn; + private planPathPrefix?: string; + private planContent: string | null = null; + private planReadError: string | null = null; + + private rootEl!: HTMLElement; + private focusedIndex = 0; + private items: HTMLElement[] = []; + private feedbackInput!: HTMLInputElement; + private isInputFocused = false; + private boundKeyDown: (e: KeyboardEvent) => void; + private abortHandler: (() => void) | null = null; + + constructor( + containerEl: HTMLElement, + input: Record, + resolve: (decision: ExitPlanModeDecision | null) => void, + signal?: AbortSignal, + renderContent?: RenderContentFn, + planPathPrefix?: string, + ) { + this.containerEl = containerEl; + this.input = input; + this.resolveCallback = resolve; + this.signal = signal; + this.renderContent = renderContent; + this.planPathPrefix = planPathPrefix; + this.boundKeyDown = (event) => this.handleKeyDown(event); + } + + render(): void { + this.rootEl = this.containerEl.createDiv({ cls: 'claudian-plan-approval-inline' }); + + const titleEl = this.rootEl.createDiv({ cls: 'claudian-plan-inline-title' }); + titleEl.setText('Plan complete'); + + this.planContent = this.readPlanContent(); + if (this.planContent) { + const contentEl = this.rootEl.createDiv({ cls: 'claudian-plan-content-preview' }); + if (this.renderContent) { + void this.renderContent(contentEl, this.planContent); + } else { + contentEl.createDiv({ cls: 'claudian-plan-content-text', text: this.planContent }); + } + } else if (this.planReadError) { + this.rootEl.createDiv({ + cls: 'claudian-plan-content-preview claudian-plan-read-error', + text: `Could not read plan file: ${this.planReadError}. "Approve (new session)" will not include plan details.`, + }); + } + + const allowedPrompts = this.input.allowedPrompts as Array<{ tool: string; prompt: string }> | undefined; + if (allowedPrompts && Array.isArray(allowedPrompts) && allowedPrompts.length > 0) { + const permEl = this.rootEl.createDiv({ cls: 'claudian-plan-permissions' }); + permEl.createDiv({ text: 'Requested permissions:', cls: 'claudian-plan-permissions-label' }); + const listEl = permEl.createEl('ul', { cls: 'claudian-plan-permissions-list' }); + for (const perm of allowedPrompts) { + listEl.createEl('li', { text: perm.prompt }); + } + } + + const actionsEl = this.rootEl.createDiv({ cls: 'claudian-ask-list' }); + + const newSessionRow = actionsEl.createDiv({ cls: 'claudian-ask-item' }); + newSessionRow.addClass('is-focused'); + newSessionRow.createSpan({ text: '\u203A', cls: 'claudian-ask-cursor' }); + newSessionRow.createSpan({ text: '1. ', cls: 'claudian-ask-item-num' }); + newSessionRow.createSpan({ text: 'Approve (new session)', cls: 'claudian-ask-item-label' }); + newSessionRow.addEventListener('click', () => { + this.focusedIndex = 0; + this.updateFocus(); + this.handleResolve({ + type: 'approve-new-session', + planContent: this.extractPlanContent(), + }); + }); + this.items.push(newSessionRow); + + const approveRow = actionsEl.createDiv({ cls: 'claudian-ask-item' }); + approveRow.createSpan({ text: '\u00A0', cls: 'claudian-ask-cursor' }); + approveRow.createSpan({ text: '2. ', cls: 'claudian-ask-item-num' }); + approveRow.createSpan({ text: 'Approve (current session)', cls: 'claudian-ask-item-label' }); + approveRow.addEventListener('click', () => { + this.focusedIndex = 1; + this.updateFocus(); + this.handleResolve({ type: 'approve' }); + }); + this.items.push(approveRow); + + const feedbackRow = actionsEl.createDiv({ cls: 'claudian-ask-item claudian-ask-custom-item' }); + feedbackRow.createSpan({ text: '\u00A0', cls: 'claudian-ask-cursor' }); + feedbackRow.createSpan({ text: '3. ', cls: 'claudian-ask-item-num' }); + this.feedbackInput = feedbackRow.createEl('input', { + type: 'text', + cls: 'claudian-ask-custom-text', + placeholder: 'Enter feedback to continue planning...', + }); + this.feedbackInput.addEventListener('focus', () => { this.isInputFocused = true; }); + this.feedbackInput.addEventListener('blur', () => { this.isInputFocused = false; }); + feedbackRow.addEventListener('click', () => { + this.focusedIndex = 2; + this.updateFocus(); + }); + this.items.push(feedbackRow); + + this.rootEl.createDiv({ text: HINTS_TEXT, cls: 'claudian-ask-hints' }); + + this.rootEl.setAttribute('tabindex', '0'); + this.rootEl.addEventListener('keydown', this.boundKeyDown); + + window.requestAnimationFrame(() => { + this.rootEl.focus(); + this.rootEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }); + + if (this.signal) { + this.abortHandler = () => this.handleResolve(null); + this.signal.addEventListener('abort', this.abortHandler, { once: true }); + } + } + + destroy(): void { + this.handleResolve(null); + } + + private readPlanContent(): string | null { + const planFilePath = this.input.planFilePath as string | undefined; + if (!planFilePath) return null; + + const resolved = nodePath.resolve(planFilePath).replace(/\\/g, '/'); + if (!this.planPathPrefix || !resolved.includes(this.planPathPrefix)) { + this.planReadError = 'path outside allowed plan directory'; + return null; + } + + try { + const content = fs.readFileSync(planFilePath, 'utf-8'); + return content.trim() || null; + } catch (err) { + this.planReadError = err instanceof Error ? err.message : 'unknown error'; + return null; + } + } + + private extractPlanContent(): string { + if (this.planContent) { + return `Implement this plan:\n\n${this.planContent}`; + } + return 'Implement the approved plan.'; + } + + private handleKeyDown(e: KeyboardEvent): void { + if (this.isInputFocused) { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + this.isInputFocused = false; + this.feedbackInput.blur(); + this.rootEl.focus(); + return; + } + if (e.key === 'Enter' && this.feedbackInput.value.trim()) { + e.preventDefault(); + e.stopPropagation(); + this.handleResolve({ type: 'feedback', text: this.feedbackInput.value.trim() }); + return; + } + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + e.stopPropagation(); + this.focusedIndex = Math.min(this.focusedIndex + 1, this.items.length - 1); + this.updateFocus(); + break; + case 'ArrowUp': + e.preventDefault(); + e.stopPropagation(); + this.focusedIndex = Math.max(this.focusedIndex - 1, 0); + this.updateFocus(); + break; + case 'Enter': + e.preventDefault(); + e.stopPropagation(); + if (this.focusedIndex === 0) { + this.handleResolve({ + type: 'approve-new-session', + planContent: this.extractPlanContent(), + }); + } else if (this.focusedIndex === 1) { + this.handleResolve({ type: 'approve' }); + } else if (this.focusedIndex === 2) { + this.feedbackInput.focus(); + } + break; + case 'Escape': + e.preventDefault(); + e.stopPropagation(); + this.handleResolve(null); + break; + } + } + + private updateFocus(): void { + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + const cursor = item.querySelector('.claudian-ask-cursor'); + if (i === this.focusedIndex) { + item.addClass('is-focused'); + if (cursor) cursor.textContent = '\u203A'; + item.scrollIntoView({ block: 'nearest' }); + + if (item.hasClass('claudian-ask-custom-item')) { + const input = item.querySelector('.claudian-ask-custom-text') as HTMLInputElement; + if (input) { + input.focus(); + this.isInputFocused = true; + } + } + } else { + item.removeClass('is-focused'); + if (cursor) cursor.textContent = '\u00A0'; + + if (item.hasClass('claudian-ask-custom-item')) { + const input = item.querySelector('.claudian-ask-custom-text') as HTMLInputElement; + if (input && this.rootEl.ownerDocument.activeElement === input) { + input.blur(); + this.isInputFocused = false; + } + } + } + } + } + + private handleResolve(decision: ExitPlanModeDecision | null): void { + if (!this.resolved) { + this.resolved = true; + this.rootEl?.removeEventListener('keydown', this.boundKeyDown); + if (this.signal && this.abortHandler) { + this.signal.removeEventListener('abort', this.abortHandler); + this.abortHandler = null; + } + this.rootEl?.remove(); + this.resolveCallback(decision); + } + } +} diff --git a/src/features/chat/rendering/InlinePlanApproval.ts b/src/features/chat/rendering/InlinePlanApproval.ts new file mode 100644 index 0000000..4d14e77 --- /dev/null +++ b/src/features/chat/rendering/InlinePlanApproval.ts @@ -0,0 +1,183 @@ +export type PlanApprovalDecision = + | { type: 'implement' } + | { type: 'revise'; text: string } + | { type: 'cancel' }; + +const HINTS_TEXT = 'Arrow keys to navigate \u00B7 Enter to select \u00B7 Esc to cancel'; + +export class InlinePlanApproval { + private containerEl: HTMLElement; + private resolveCallback: (decision: PlanApprovalDecision | null) => void; + private resolved = false; + + private rootEl!: HTMLElement; + private focusedIndex = 0; + private items: HTMLElement[] = []; + private feedbackInput!: HTMLInputElement; + private isInputFocused = false; + private boundKeyDown: (e: KeyboardEvent) => void; + + constructor( + containerEl: HTMLElement, + resolve: (decision: PlanApprovalDecision | null) => void, + ) { + this.containerEl = containerEl; + this.resolveCallback = resolve; + this.boundKeyDown = (event) => this.handleKeyDown(event); + } + + render(): void { + this.rootEl = this.containerEl.createDiv({ cls: 'claudian-plan-approval-inline' }); + + this.rootEl.createDiv({ cls: 'claudian-plan-inline-title', text: 'Plan complete' }); + + const actionsEl = this.rootEl.createDiv({ cls: 'claudian-ask-list' }); + + // 1. Implement + const implementRow = actionsEl.createDiv({ cls: 'claudian-ask-item' }); + implementRow.addClass('is-focused'); + implementRow.createSpan({ text: '\u203A', cls: 'claudian-ask-cursor' }); + implementRow.createSpan({ text: '1. ', cls: 'claudian-ask-item-num' }); + implementRow.createSpan({ text: 'Implement', cls: 'claudian-ask-item-label' }); + implementRow.addEventListener('click', () => { + this.focusedIndex = 0; + this.updateFocus(); + this.handleResolve({ type: 'implement' }); + }); + this.items.push(implementRow); + + // 2. Revise (with feedback input) + const reviseRow = actionsEl.createDiv({ cls: 'claudian-ask-item claudian-ask-custom-item' }); + reviseRow.createSpan({ text: '\u00A0', cls: 'claudian-ask-cursor' }); + reviseRow.createSpan({ text: '2. ', cls: 'claudian-ask-item-num' }); + this.feedbackInput = reviseRow.createEl('input', { + type: 'text', + cls: 'claudian-ask-custom-text', + placeholder: 'Enter feedback to revise plan...', + }); + this.feedbackInput.addEventListener('focus', () => { this.isInputFocused = true; }); + this.feedbackInput.addEventListener('blur', () => { this.isInputFocused = false; }); + reviseRow.addEventListener('click', () => { + this.focusedIndex = 1; + this.updateFocus(); + }); + this.items.push(reviseRow); + + // 3. Cancel + const cancelRow = actionsEl.createDiv({ cls: 'claudian-ask-item' }); + cancelRow.createSpan({ text: '\u00A0', cls: 'claudian-ask-cursor' }); + cancelRow.createSpan({ text: '3. ', cls: 'claudian-ask-item-num' }); + cancelRow.createSpan({ text: 'Cancel', cls: 'claudian-ask-item-label' }); + cancelRow.addEventListener('click', () => { + this.focusedIndex = 2; + this.updateFocus(); + this.handleResolve({ type: 'cancel' }); + }); + this.items.push(cancelRow); + + this.rootEl.createDiv({ text: HINTS_TEXT, cls: 'claudian-ask-hints' }); + + this.rootEl.setAttribute('tabindex', '0'); + this.rootEl.addEventListener('keydown', this.boundKeyDown); + + window.requestAnimationFrame(() => { + this.rootEl.focus(); + this.rootEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }); + } + + destroy(): void { + this.handleResolve(null); + } + + private handleKeyDown(e: KeyboardEvent): void { + if (this.isInputFocused) { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + this.isInputFocused = false; + this.feedbackInput.blur(); + this.rootEl.focus(); + return; + } + if (e.key === 'Enter' && this.feedbackInput.value.trim()) { + e.preventDefault(); + e.stopPropagation(); + this.handleResolve({ type: 'revise', text: this.feedbackInput.value.trim() }); + return; + } + return; + } + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + e.stopPropagation(); + this.focusedIndex = Math.min(this.focusedIndex + 1, this.items.length - 1); + this.updateFocus(); + break; + case 'ArrowUp': + e.preventDefault(); + e.stopPropagation(); + this.focusedIndex = Math.max(this.focusedIndex - 1, 0); + this.updateFocus(); + break; + case 'Enter': + e.preventDefault(); + e.stopPropagation(); + if (this.focusedIndex === 0) { + this.handleResolve({ type: 'implement' }); + } else if (this.focusedIndex === 1) { + this.feedbackInput.focus(); + } else if (this.focusedIndex === 2) { + this.handleResolve({ type: 'cancel' }); + } + break; + case 'Escape': + e.preventDefault(); + e.stopPropagation(); + this.handleResolve(null); + break; + } + } + + private updateFocus(): void { + for (let i = 0; i < this.items.length; i++) { + const item = this.items[i]; + const cursor = item.querySelector('.claudian-ask-cursor'); + if (i === this.focusedIndex) { + item.addClass('is-focused'); + if (cursor) cursor.textContent = '\u203A'; + item.scrollIntoView({ block: 'nearest' }); + + if (item.hasClass('claudian-ask-custom-item')) { + const input = item.querySelector('.claudian-ask-custom-text') as HTMLInputElement; + if (input) { + input.focus(); + this.isInputFocused = true; + } + } + } else { + item.removeClass('is-focused'); + if (cursor) cursor.textContent = '\u00A0'; + + if (item.hasClass('claudian-ask-custom-item') && this.isInputFocused) { + const input = item.querySelector('.claudian-ask-custom-text') as HTMLInputElement; + if (input) { + input.blur(); + this.isInputFocused = false; + } + } + } + } + } + + private handleResolve(decision: PlanApprovalDecision | null): void { + if (!this.resolved) { + this.resolved = true; + this.rootEl?.removeEventListener('keydown', this.boundKeyDown); + this.rootEl?.remove(); + this.resolveCallback(decision); + } + } +} diff --git a/src/features/chat/rendering/MessageRenderer.ts b/src/features/chat/rendering/MessageRenderer.ts new file mode 100644 index 0000000..d92c17b --- /dev/null +++ b/src/features/chat/rendering/MessageRenderer.ts @@ -0,0 +1,938 @@ +import type { App, Component } from 'obsidian'; +import { MarkdownRenderer, Menu, Notice, setIcon } from 'obsidian'; + +import { DEFAULT_CHAT_PROVIDER_ID, type ProviderCapabilities } from '../../../core/providers/types'; +import type { ChatRewindMode } from '../../../core/runtime/types'; +import { + isSubagentToolName, + isWriteEditTool, + TOOL_AGENT_OUTPUT, + TOOL_APPLY_PATCH, + TOOL_WRITE_STDIN, +} from '../../../core/tools/toolNames'; +import { extractToolResultContent } from '../../../core/tools/toolResultContent'; +import type { ChatMessage, ImageAttachment, SubagentInfo, ToolCallInfo } from '../../../core/types'; +import { t } from '../../../i18n/i18n'; +import { extractUserDisplayContent } from '../../../utils/context'; +import { formatDurationMmSs } from '../../../utils/date'; +import { processFileLinks, registerFileLinkHandler } from '../../../utils/fileLink'; +import { replaceImageEmbedsWithHtml } from '../../../utils/imageEmbed'; +import { escapeMathDelimitersForStreaming } from '../../../utils/markdownMath'; +import type { FeatureHost } from '../../FeatureHost'; +import { findRewindContext } from '../rewind'; +import { formatConversationDirectoryTitle } from '../utils/conversationDirectoryTitle'; +import { resolveSubagentLifecycleAdapter } from './subagentLifecycleResolution'; +import { + renderStoredAsyncSubagent, + renderStoredSubagent, +} from './SubagentRenderer'; +import { renderStoredThinkingBlock } from './ThinkingBlockRenderer'; +import { renderStoredToolCall } from './ToolCallRenderer'; +import { renderStoredWriteEdit } from './WriteEditRenderer'; + +export interface RenderContentOptions { + deferMath?: boolean; +} + +export type RenderContentFn = ( + el: HTMLElement, + markdown: string, + options?: RenderContentOptions +) => Promise; + +function runRendererAction(action: () => Promise): void { + void action().catch(() => { + // UI actions already surface expected failures locally. + }); +} + +export class MessageRenderer { + private app: App; + private plugin: FeatureHost; + private component: Component; + private messagesEl: HTMLElement; + private rewindCallback?: (messageId: string, mode?: ChatRewindMode) => Promise; + private getCapabilities: () => ProviderCapabilities; + private forkCallback?: (messageId: string) => Promise; + private liveMessageEls = new Map(); + + constructor( + plugin: FeatureHost, + component: Component, + messagesEl: HTMLElement, + rewindCallback?: (messageId: string, mode?: ChatRewindMode) => Promise, + forkCallback?: (messageId: string) => Promise, + getCapabilities?: () => ProviderCapabilities, + ) { + this.app = plugin.app; + this.plugin = plugin; + this.component = component; + this.messagesEl = messagesEl; + this.rewindCallback = rewindCallback; + this.forkCallback = forkCallback; + this.getCapabilities = getCapabilities ?? (() => ({ + providerId: DEFAULT_CHAT_PROVIDER_ID, + supportsPersistentRuntime: false, + supportsNativeHistory: false, + supportsPlanMode: false, + supportsRewind: false, + supportsFork: false, + supportsProviderCommands: false, + supportsImageAttachments: false, + supportsInstructionMode: false, + supportsMcpTools: false, + supportsTurnSteer: false, + reasoningControl: 'none' as const, + })); + + // Register delegated click handler for file links + registerFileLinkHandler(this.app, this.messagesEl, this.component); + } + + /** Sets the messages container element. */ + setMessagesEl(el: HTMLElement): void { + this.messagesEl = el; + } + + private getSubagentLifecycleAdapter(toolName?: string) { + return resolveSubagentLifecycleAdapter(this.getCapabilities().providerId, toolName); + } + + private shouldExpandFileEditsByDefault(): boolean { + return this.plugin.settings?.expandFileEditsByDefault === true; + } + + private getUserMessageTextToShow(msg: ChatMessage): string { + return msg.displayContent ?? extractUserDisplayContent(msg.content) ?? msg.content; + } + + private applyTocTitle(msgEl: HTMLElement, text: string): void { + const tocTitle = formatConversationDirectoryTitle(text); + if (tocTitle) { + msgEl.setAttribute('data-toc-title', tocTitle); + } else { + msgEl.removeAttribute('data-toc-title'); + } + } + + // ============================================ + // Streaming Message Rendering + // ============================================ + + /** + * Adds a new message to the chat during streaming. + * Returns the message element for content updates. + */ + addMessage(msg: ChatMessage): HTMLElement { + // Render images above message bubble for user messages + if (msg.role === 'user' && msg.images && msg.images.length > 0) { + this.renderMessageImages(this.messagesEl, msg.images); + } + + // Skip empty bubble for image-only messages + if (msg.role === 'user') { + const textToShow = this.getUserMessageTextToShow(msg); + if (!textToShow) { + this.scrollToBottom(); + const lastChild = this.messagesEl.lastElementChild as HTMLElement; + return lastChild ?? this.messagesEl; + } + } + + const msgEl = this.messagesEl.createDiv({ + cls: `claudian-message claudian-message-${msg.role}`, + attr: { + 'data-message-id': msg.id, + 'data-role': msg.role, + }, + }); + + const contentEl = msgEl.createDiv({ cls: 'claudian-message-content', attr: { dir: 'auto' } }); + + if (msg.role === 'user') { + const textToShow = this.getUserMessageTextToShow(msg); + if (textToShow) { + const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); + void this.renderContent(textEl, textToShow); + this.addUserCopyButton(msgEl, textToShow); + this.applyTocTitle(msgEl, textToShow); + } + if (this.rewindCallback || this.forkCallback) { + this.liveMessageEls.set(msg.id, msgEl); + } + } + + this.scrollToBottom(); + return msgEl; + } + + updateLiveUserMessage(msg: ChatMessage): void { + if (msg.role !== 'user') { + return; + } + + const msgEl = this.liveMessageEls.get(msg.id) + ?? this.messagesEl.querySelector(`[data-message-id="${msg.id}"]`); + if (!msgEl) { + return; + } + + const contentEl = msgEl.querySelector('.claudian-message-content'); + if (!contentEl) { + return; + } + + contentEl.empty(); + + const textToShow = this.getUserMessageTextToShow(msg); + if (textToShow) { + const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); + void this.renderContent(textEl, textToShow); + this.applyTocTitle(msgEl, textToShow); + } else { + msgEl.removeAttribute('data-toc-title'); + } + + const toolbar = msgEl.querySelector('.claudian-user-msg-actions'); + if (toolbar) { + toolbar.querySelectorAll('.claudian-user-msg-copy-btn').forEach((el) => el.remove()); + } + + if (textToShow) { + this.addUserCopyButton(msgEl, textToShow); + } + } + + removeMessage(messageId: string): void { + const msgEl = this.liveMessageEls.get(messageId) + ?? this.messagesEl.querySelector(`[data-message-id="${messageId}"]`); + if (!msgEl) { + return; + } + + msgEl.remove(); + this.liveMessageEls.delete(messageId); + } + + // ============================================ + // Stored Message Rendering (Batch/Replay) + // ============================================ + + /** + * Renders all messages for conversation load/switch. + * @param messages Array of messages to render + * @param getGreeting Function to get greeting text + * @returns The newly created welcome element + */ + renderMessages( + messages: ChatMessage[], + getGreeting: () => string + ): HTMLElement { + this.messagesEl.empty(); + this.liveMessageEls.clear(); + + // Recreate welcome element after clearing + const newWelcomeEl = this.messagesEl.createDiv({ cls: 'claudian-welcome' }); + newWelcomeEl.createDiv({ cls: 'claudian-welcome-greeting', text: getGreeting() }); + + for (let i = 0; i < messages.length; i++) { + this.renderStoredMessage(messages[i], messages, i); + } + + this.scrollToBottom(); + return newWelcomeEl; + } + + renderStoredMessage(msg: ChatMessage, allMessages?: ChatMessage[], index?: number): void { + // Bare interrupt marker: user-role interrupts (Claude bracket markers) always render + // as a standalone indicator. Assistant-role interrupts (Codex partial responses) + // only use the bare marker when there's no content to preserve. + if (msg.isInterrupt && (msg.role === 'user' || !this.hasVisibleContent(msg))) { + this.renderInterruptMessage(); + return; + } + + // Skip rebuilt context messages (history sent to SDK on session reset) + // These are internal context for the AI, not actual user messages to display + if (msg.isRebuiltContext) { + return; + } + + // Render images above bubble for user messages + if (msg.role === 'user' && msg.images && msg.images.length > 0) { + this.renderMessageImages(this.messagesEl, msg.images); + } + + // Skip empty bubble for image-only messages + if (msg.role === 'user') { + const textToShow = this.getUserMessageTextToShow(msg); + if (!textToShow) { + return; + } + } + if (msg.role === 'assistant' && !this.hasVisibleContent(msg)) { + return; + } + + const msgEl = this.messagesEl.createDiv({ + cls: `claudian-message claudian-message-${msg.role}`, + attr: { + 'data-message-id': msg.id, + 'data-role': msg.role, + }, + }); + + const contentEl = msgEl.createDiv({ cls: 'claudian-message-content', attr: { dir: 'auto' } }); + + if (msg.role === 'user') { + const textToShow = this.getUserMessageTextToShow(msg); + if (textToShow) { + const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); + void this.renderContent(textEl, textToShow); + this.addUserCopyButton(msgEl, textToShow); + this.applyTocTitle(msgEl, textToShow); + } + if (msg.userMessageId) { + if (this.rewindCallback && this.isRewindEligible(allMessages, index)) { + this.addRewindButton(msgEl, msg.id); + } + if (this.forkCallback && this.isForkEligible(allMessages, index)) { + this.addForkButton(msgEl, msg.id); + } + } + } else if (msg.role === 'assistant') { + this.renderAssistantContent(msg, contentEl); + if (msg.isInterrupt) { + this.appendInterruptIndicator(contentEl); + } + } + } + + private hasVisibleContent(msg: ChatMessage): boolean { + if (msg.content && msg.content.trim().length > 0) return true; + if (msg.contentBlocks && msg.contentBlocks.length > 0) { + for (const block of msg.contentBlocks) { + if (block.type === 'thinking' && block.content.trim().length > 0) return true; + if (block.type === 'text' && block.content.trim().length > 0) return true; + if (block.type === 'context_compacted') return true; + if (block.type === 'subagent') return true; + if (block.type === 'tool_use') { + const toolCall = msg.toolCalls?.find(tc => tc.id === block.toolId); + if (toolCall && this.shouldRenderToolCall(toolCall)) return true; + } + } + } + if (msg.toolCalls?.some(toolCall => this.shouldRenderToolCall(toolCall))) return true; + return false; + } + + private isRewindEligible(allMessages?: ChatMessage[], index?: number): boolean { + if (!allMessages || index === undefined) return false; + const ctx = findRewindContext(allMessages, index); + return ctx.hasResponse; + } + + private isForkEligible(allMessages?: ChatMessage[], index?: number): boolean { + if (!allMessages || index === undefined) return false; + const ctx = findRewindContext(allMessages, index); + return !!ctx.prevAssistantUuid && ctx.hasResponse; + } + + private renderInterruptMessage(): void { + const msgEl = this.messagesEl.createDiv({ cls: 'claudian-message claudian-message-assistant' }); + const contentEl = msgEl.createDiv({ cls: 'claudian-message-content', attr: { dir: 'auto' } }); + this.appendInterruptIndicator(contentEl); + } + + private appendInterruptIndicator(contentEl: HTMLElement): void { + const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); + textEl.createSpan({ cls: 'claudian-interrupted', text: 'Interrupted' }); + textEl.appendText(' '); + textEl.createSpan({ + cls: 'claudian-interrupted-hint', + text: '\u00B7 What should Claudian do instead?', + }); + } + + /** + * Renders assistant message content (content blocks or fallback). + */ + private renderAssistantContent(msg: ChatMessage, contentEl: HTMLElement): void { + if (msg.contentBlocks && msg.contentBlocks.length > 0) { + const renderedToolIds = new Set(); + for (const block of msg.contentBlocks) { + if (block.type === 'thinking') { + renderStoredThinkingBlock( + contentEl, + block.content, + block.durationSeconds, + (el, md) => this.renderContent(el, md) + ); + } else if (block.type === 'text') { + // Skip empty or whitespace-only text blocks to avoid extra gaps + if (!block.content || !block.content.trim()) { + continue; + } + const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); + void this.renderContent(textEl, block.content); + this.addTextCopyButton(textEl, block.content); + } else if (block.type === 'tool_use') { + const toolCall = msg.toolCalls?.find(tc => tc.id === block.toolId); + if (toolCall) { + this.renderToolCall(contentEl, toolCall, msg); + renderedToolIds.add(toolCall.id); + } + } else if (block.type === 'context_compacted') { + const boundaryEl = contentEl.createDiv({ cls: 'claudian-compact-boundary' }); + boundaryEl.createSpan({ cls: 'claudian-compact-boundary-label', text: 'Conversation compacted' }); + } else if (block.type === 'subagent') { + const taskToolCall = msg.toolCalls?.find( + tc => tc.id === block.subagentId && isSubagentToolName(tc.name) + ); + if (!taskToolCall) continue; + + this.renderTaskSubagent(contentEl, taskToolCall, block.mode); + renderedToolIds.add(taskToolCall.id); + } + } + + // Defensive fallback: preserve tool visibility when contentBlocks/toolCalls drift on reload. + if (msg.toolCalls && msg.toolCalls.length > 0) { + for (const toolCall of msg.toolCalls) { + if (renderedToolIds.has(toolCall.id)) continue; + this.renderToolCall(contentEl, toolCall, msg); + renderedToolIds.add(toolCall.id); + } + } + } else { + // Fallback for old conversations without contentBlocks + if (msg.content) { + const textEl = contentEl.createDiv({ cls: 'claudian-text-block' }); + void this.renderContent(textEl, msg.content); + this.addTextCopyButton(textEl, msg.content); + } + if (msg.toolCalls) { + for (const toolCall of msg.toolCalls) { + this.renderToolCall(contentEl, toolCall, msg); + } + } + } + + // Render response duration footer (skip when message contains a compaction boundary) + const hasCompactBoundary = msg.contentBlocks?.some(b => b.type === 'context_compacted'); + if (msg.durationSeconds && msg.durationSeconds > 0 && !hasCompactBoundary) { + const flavorWord = msg.durationFlavorWord || 'Baked'; + const footerEl = contentEl.createDiv({ cls: 'claudian-response-footer' }); + footerEl.createSpan({ + text: `* ${flavorWord} for ${formatDurationMmSs(msg.durationSeconds)}`, + cls: 'claudian-baked-duration', + }); + } + } + + /** + * Renders a tool call with special handling for Write/Edit, Agent (subagent), + * and Codex collab agent lifecycle tools. + */ + private renderToolCall(contentEl: HTMLElement, toolCall: ToolCallInfo, msg?: ChatMessage): void { + if (!this.shouldRenderToolCall(toolCall)) return; + const subagentLifecycleAdapter = this.getSubagentLifecycleAdapter(toolCall.name); + + if (isWriteEditTool(toolCall.name)) { + renderStoredWriteEdit(contentEl, toolCall, { + initiallyExpanded: this.shouldExpandFileEditsByDefault(), + }); + } else if (isSubagentToolName(toolCall.name)) { + this.renderTaskSubagent(contentEl, toolCall); + } else if (subagentLifecycleAdapter?.isSpawnTool(toolCall.name) && msg) { + this.renderProviderLifecycleSubagent(contentEl, toolCall, msg); + } else { + renderStoredToolCall(contentEl, toolCall, { + initiallyExpanded: toolCall.name === TOOL_APPLY_PATCH && this.shouldExpandFileEditsByDefault(), + }); + } + } + + private shouldRenderToolCall(toolCall: ToolCallInfo): boolean { + if (toolCall.name === TOOL_AGENT_OUTPUT) return false; + if (toolCall.name === TOOL_WRITE_STDIN && this.isSilentWriteStdinTool(toolCall)) return false; + if (toolCall.name === 'custom_tool_call_output') return false; + + const subagentLifecycleAdapter = this.getSubagentLifecycleAdapter(toolCall.name); + if (subagentLifecycleAdapter?.isHiddenTool(toolCall.name)) return false; + + return true; + } + + private isSilentWriteStdinTool(toolCall: ToolCallInfo): boolean { + return typeof toolCall.input.chars !== 'string' || toolCall.input.chars.length === 0; + } + + private renderTaskSubagent( + contentEl: HTMLElement, + toolCall: ToolCallInfo, + modeHint?: 'sync' | 'async' + ): void { + const subagentInfo = this.resolveTaskSubagent(toolCall, modeHint); + if (subagentInfo.mode === 'async') { + renderStoredAsyncSubagent(contentEl, subagentInfo); + return; + } + renderStoredSubagent(contentEl, subagentInfo); + } + + /** + * Consolidates provider lifecycle tools (spawn + wait/close) + * into a single subagent block with prompt and result. + */ + private renderProviderLifecycleSubagent( + contentEl: HTMLElement, + spawnToolCall: ToolCallInfo, + msg: ChatMessage, + ): void { + const subagentLifecycleAdapter = this.getSubagentLifecycleAdapter(spawnToolCall.name); + if (!subagentLifecycleAdapter) { + renderStoredToolCall(contentEl, spawnToolCall); + return; + } + + const subagentInfo = subagentLifecycleAdapter.buildSubagentInfo( + spawnToolCall, + msg.toolCalls ?? [], + ); + renderStoredSubagent(contentEl, subagentInfo); + } + + private resolveTaskSubagent(toolCall: ToolCallInfo, modeHint?: 'sync' | 'async'): SubagentInfo { + if (toolCall.subagent) { + if (!modeHint || toolCall.subagent.mode === modeHint) { + return toolCall.subagent; + } + return { + ...toolCall.subagent, + mode: modeHint, + }; + } + + const description = (toolCall.input?.description as string) || 'Subagent task'; + const prompt = (toolCall.input?.prompt as string) || ''; + const mode = modeHint ?? (toolCall.input?.run_in_background === true ? 'async' : 'sync'); + + if (mode !== 'async') { + return { + id: toolCall.id, + description, + prompt, + status: this.mapToolStatusToSubagentStatus(toolCall.status), + toolCalls: [], + isExpanded: false, + result: toolCall.result, + }; + } + + const asyncStatus = this.inferAsyncStatusFromTaskTool(toolCall); + return { + id: toolCall.id, + description, + prompt, + mode: 'async', + status: asyncStatus, + asyncStatus, + toolCalls: [], + isExpanded: false, + result: toolCall.result, + }; + } + + private mapToolStatusToSubagentStatus( + status: ToolCallInfo['status'] + ): 'completed' | 'error' | 'running' { + switch (status) { + case 'completed': + return 'completed'; + case 'error': + case 'blocked': + return 'error'; + default: + return 'running'; + } + } + + private inferAsyncStatusFromTaskTool(toolCall: ToolCallInfo): 'running' | 'completed' | 'error' { + if (toolCall.status === 'error' || toolCall.status === 'blocked') return 'error'; + if (toolCall.status === 'running') return 'running'; + + const lowerResult = extractToolResultContent(toolCall.result, { fallbackIndent: 2 }).toLowerCase(); + if ( + lowerResult.includes('not_ready') || + lowerResult.includes('not ready') || + lowerResult.includes('"status":"running"') || + lowerResult.includes('"status":"pending"') || + lowerResult.includes('"retrieval_status":"running"') || + lowerResult.includes('"retrieval_status":"not_ready"') + ) { + return 'running'; + } + + return 'completed'; + } + + // ============================================ + // Image Rendering + // ============================================ + + /** + * Renders image attachments above a message. + */ + renderMessageImages(containerEl: HTMLElement, images: ImageAttachment[]): void { + const imagesEl = containerEl.createDiv({ cls: 'claudian-message-images' }); + + for (const image of images) { + const imageWrapper = imagesEl.createDiv({ cls: 'claudian-message-image' }); + const imgEl = imageWrapper.createEl('img', { + attr: { + alt: image.name, + }, + }); + + void this.setImageSrc(imgEl, image); + + // Click to view full size + imgEl.addEventListener('click', () => { + void this.showFullImage(image); + }); + } + } + + /** + * Shows full-size image in modal overlay. + */ + showFullImage(image: ImageAttachment): void { + const dataUri = `data:${image.mediaType};base64,${image.data}`; + + const ownerDocument = this.messagesEl.ownerDocument ?? window.document; + const overlay = ownerDocument.body.createDiv({ cls: 'claudian-image-modal-overlay' }); + const modal = overlay.createDiv({ cls: 'claudian-image-modal' }); + + modal.createEl('img', { + attr: { + src: dataUri, + alt: image.name, + }, + }); + + const closeBtn = modal.createDiv({ cls: 'claudian-image-modal-close' }); + closeBtn.setText('\u00D7'); + + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + close(); + } + }; + + const close = () => { + ownerDocument.removeEventListener('keydown', handleEsc); + overlay.remove(); + }; + + closeBtn.addEventListener('click', close); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + ownerDocument.addEventListener('keydown', handleEsc); + } + + /** + * Sets image src from attachment data. + */ + setImageSrc(imgEl: HTMLImageElement, image: ImageAttachment): void { + const dataUri = `data:${image.mediaType};base64,${image.data}`; + imgEl.setAttribute('src', dataUri); + } + + // ============================================ + // Content Rendering + // ============================================ + + /** + * Renders markdown content with code block enhancements. + */ + async renderContent( + el: HTMLElement, + markdown: string, + options?: RenderContentOptions + ): Promise { + el.empty(); + + try { + const renderMarkdown = options?.deferMath + ? escapeMathDelimitersForStreaming(markdown) + : markdown; + // Normalize embeds before MarkdownRenderer consumes them. + const processedMarkdown = replaceImageEmbedsWithHtml( + renderMarkdown, + this.app, + { mediaFolder: this.plugin.settings.mediaFolder } + ); + await MarkdownRenderer.render( + this.app, + processedMarkdown, + el, + '', + this.component + ); + + // Wrap pre elements and move buttons outside scroll area + el.querySelectorAll('pre').forEach((pre) => { + // Skip if already wrapped + if (pre.parentElement?.classList.contains('claudian-code-wrapper')) return; + + // Create wrapper + const wrapper = createEl('div', { cls: 'claudian-code-wrapper' }); + pre.parentElement?.insertBefore(wrapper, pre); + wrapper.appendChild(pre); + + // Check for language class and add label + const code = pre.querySelector('code[class*="language-"]'); + if (code) { + const match = code.className.match(/language-(\w+)/); + if (match) { + wrapper.classList.add('has-language'); + const label = createEl('span', { + cls: 'claudian-code-lang-label', + text: match[1], + }); + wrapper.appendChild(label); + label.addEventListener('click', () => { + runRendererAction(async () => { + const originalLabel = match[1]; + if (!originalLabel) return; + + try { + await navigator.clipboard.writeText(code.textContent || ''); + label.setText('Copied!'); + window.setTimeout(() => label.setText(originalLabel), 1500); + } catch { + // Clipboard API may fail in non-secure contexts + } + }); + }); + } + } + + // Move Obsidian's copy button outside pre into wrapper + const copyBtn = pre.querySelector('.copy-code-button'); + if (copyBtn) { + wrapper.appendChild(copyBtn); + } + }); + + // Process wikilinks only when the source can contain them; the DOM pass is expensive. + if (processedMarkdown.includes('[[')) { + processFileLinks(this.app, el); + } + } catch { + el.createDiv({ + cls: 'claudian-render-error', + text: 'Failed to render message content.', + }); + } + } + + // ============================================ + // Copy Button + // ============================================ + + /** + * Adds a copy button to a text block. + * Button shows clipboard icon on hover, changes to "copied!" on click. + * @param textEl The rendered text element + * @param markdown The original markdown content to copy + */ + addTextCopyButton(textEl: HTMLElement, markdown: string): void { + const copyBtn = textEl.createSpan({ cls: 'claudian-text-copy-btn' }); + setIcon(copyBtn, 'copy'); + + let feedbackTimeout: number | null = null; + + copyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + runRendererAction(async () => { + + try { + await navigator.clipboard.writeText(markdown); + } catch { + // Clipboard API may fail in non-secure contexts + return; + } + + // Clear any pending timeout from rapid clicks + if (feedbackTimeout) { + window.clearTimeout(feedbackTimeout); + } + + // Show "copied!" feedback + copyBtn.empty(); + copyBtn.setText('Copied!'); + copyBtn.classList.add('copied'); + + feedbackTimeout = window.setTimeout(() => { + copyBtn.empty(); + setIcon(copyBtn, 'copy'); + copyBtn.classList.remove('copied'); + feedbackTimeout = null; + }, 1500); + }); + }); + } + + refreshActionButtons(msg: ChatMessage, allMessages?: ChatMessage[], index?: number): void { + if (!msg.userMessageId) return; + const canRewind = this.isRewindEligible(allMessages, index); + const canFork = this.isForkEligible(allMessages, index); + if (!canRewind && !canFork) return; + const msgEl = this.liveMessageEls.get(msg.id); + if (!msgEl) return; + + if (canRewind && this.rewindCallback && !msgEl.querySelector('.claudian-message-rewind-btn')) { + this.addRewindButton(msgEl, msg.id); + } + if (canFork && this.forkCallback && !msgEl.querySelector('.claudian-message-fork-btn')) { + this.addForkButton(msgEl, msg.id); + } + this.cleanupLiveMessageEl(msg.id, msgEl, { canRewind, canFork }); + } + + private cleanupLiveMessageEl( + msgId: string, + msgEl: HTMLElement, + expectedActions: { canRewind: boolean; canFork: boolean }, + ): void { + const needsRewind = expectedActions.canRewind + && this.rewindCallback + && !msgEl.querySelector('.claudian-message-rewind-btn'); + const needsFork = expectedActions.canFork + && this.forkCallback + && !msgEl.querySelector('.claudian-message-fork-btn'); + if (!needsRewind && !needsFork) { + this.liveMessageEls.delete(msgId); + } + } + + private getOrCreateActionsToolbar(msgEl: HTMLElement): HTMLElement { + const existing = msgEl.querySelector('.claudian-user-msg-actions'); + if (existing) return existing; + return msgEl.createDiv({ cls: 'claudian-user-msg-actions' }); + } + + private addUserCopyButton(msgEl: HTMLElement, content: string): void { + const toolbar = this.getOrCreateActionsToolbar(msgEl); + const copyBtn = toolbar.createSpan({ cls: 'claudian-user-msg-copy-btn' }); + setIcon(copyBtn, 'copy'); + copyBtn.setAttribute('aria-label', 'Copy message'); + + let feedbackTimeout: number | null = null; + + copyBtn.addEventListener('click', (e) => { + e.stopPropagation(); + runRendererAction(async () => { + try { + await navigator.clipboard.writeText(content); + } catch { + return; + } + if (feedbackTimeout) window.clearTimeout(feedbackTimeout); + copyBtn.empty(); + copyBtn.setText('Copied!'); + copyBtn.classList.add('copied'); + feedbackTimeout = window.setTimeout(() => { + copyBtn.empty(); + setIcon(copyBtn, 'copy'); + copyBtn.classList.remove('copied'); + feedbackTimeout = null; + }, 1500); + }); + }); + } + + private addRewindButton(msgEl: HTMLElement, messageId: string): void { + if (!this.getCapabilities().supportsRewind) return; + const toolbar = this.getOrCreateActionsToolbar(msgEl); + const btn = toolbar.createSpan({ cls: 'claudian-message-rewind-btn' }); + if (toolbar.firstChild !== btn) toolbar.insertBefore(btn, toolbar.firstChild); + setIcon(btn, 'rotate-ccw'); + btn.setAttribute('aria-label', t('chat.rewind.ariaLabel')); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.showRewindMenu(e, messageId); + }); + } + + private showRewindMenu(event: MouseEvent, messageId: string): void { + const menu = new Menu(); + this.addRewindMenuItem(menu, messageId, 'conversation'); + this.addRewindMenuItem(menu, messageId, 'code-and-conversation'); + menu.showAtMouseEvent(event); + } + + private addRewindMenuItem(menu: Menu, messageId: string, mode: ChatRewindMode): void { + menu.addItem((item) => { + item + .setTitle( + mode === 'conversation' + ? t('chat.rewind.menuConversationOnly') + : t('chat.rewind.menuCodeAndConversation') + ) + .setIcon(mode === 'conversation' ? 'message-square' : 'rotate-ccw') + .onClick(() => { + runRendererAction(async () => { + try { + await this.rewindCallback?.(messageId, mode); + } catch (err) { + new Notice(t('chat.rewind.failed', { error: err instanceof Error ? err.message : 'Unknown error' })); + } + }); + }); + }); + } + + private addForkButton(msgEl: HTMLElement, messageId: string): void { + if (!this.getCapabilities().supportsFork) return; + const toolbar = this.getOrCreateActionsToolbar(msgEl); + const btn = toolbar.createSpan({ cls: 'claudian-message-fork-btn' }); + if (toolbar.firstChild !== btn) toolbar.insertBefore(btn, toolbar.firstChild); + setIcon(btn, 'git-fork'); + btn.setAttribute('aria-label', t('chat.fork.ariaLabel')); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + runRendererAction(async () => { + try { + await this.forkCallback?.(messageId); + } catch (err) { + new Notice(t('chat.fork.failed', { error: err instanceof Error ? err.message : 'Unknown error' })); + } + }); + }); + } + + // ============================================ + // Utilities + // ============================================ + + /** Scrolls messages container to bottom. */ + scrollToBottom(): void { + this.messagesEl.scrollTop = this.messagesEl.scrollHeight; + } + + /** Scrolls to bottom if already near bottom (within threshold). */ + scrollToBottomIfNeeded(threshold = 100): void { + const { scrollTop, scrollHeight, clientHeight } = this.messagesEl; + const isNearBottom = scrollHeight - scrollTop - clientHeight < threshold; + if (isNearBottom) { + window.requestAnimationFrame(() => { + this.messagesEl.scrollTop = this.messagesEl.scrollHeight; + }); + } + } + +} diff --git a/src/features/chat/rendering/SubagentRenderer.ts b/src/features/chat/rendering/SubagentRenderer.ts new file mode 100644 index 0000000..0fc2f0d --- /dev/null +++ b/src/features/chat/rendering/SubagentRenderer.ts @@ -0,0 +1,678 @@ +import { setIcon } from 'obsidian'; + +import { getToolIcon } from '../../../core/tools/toolIcons'; +import { TOOL_TASK } from '../../../core/tools/toolNames'; +import type { SubagentInfo, ToolCallInfo } from '../../../core/types'; +import { setupCollapsible } from './collapsible'; +import { + getToolLabel, + getToolName, + getToolSummary, + renderExpandedContent, + setToolIcon, +} from './ToolCallRenderer'; + +interface SubagentToolView { + wrapperEl: HTMLElement; + nameEl: HTMLElement; + summaryEl: HTMLElement; + statusEl: HTMLElement; + contentEl: HTMLElement; +} + +interface SubagentSection { + wrapperEl: HTMLElement; + bodyEl: HTMLElement; +} + +export interface SubagentState { + wrapperEl: HTMLElement; + contentEl: HTMLElement; + headerEl: HTMLElement; + labelEl: HTMLElement; + statusEl: HTMLElement; + promptSectionEl: HTMLElement; + promptBodyEl: HTMLElement; + toolsContainerEl: HTMLElement; + resultSectionEl: HTMLElement | null; + resultBodyEl: HTMLElement | null; + toolElements: Map; + info: SubagentInfo; +} + +const SUBAGENT_TOOL_STATUS_ICONS: Partial> = { + completed: 'check', + error: 'x', + blocked: 'shield-off', +}; + +function extractTaskDescription(input: Record): string { + return (input.description as string) || 'Subagent task'; +} + +function extractTaskPrompt(input: Record): string { + return (input.prompt as string) || ''; +} + +function truncateDescription(description: string, maxLength = 40): string { + if (description.length <= maxLength) return description; + return description.substring(0, maxLength) + '...'; +} + +function createSection(parentEl: HTMLElement, title: string, bodyClass?: string): SubagentSection { + const wrapperEl = parentEl.createDiv({ cls: 'claudian-subagent-section' }); + + const headerEl = wrapperEl.createDiv({ cls: 'claudian-subagent-section-header' }); + headerEl.setAttribute('tabindex', '0'); + headerEl.setAttribute('role', 'button'); + + const titleEl = headerEl.createDiv({ cls: 'claudian-subagent-section-title' }); + titleEl.setText(title); + + const bodyEl = wrapperEl.createDiv({ cls: 'claudian-subagent-section-body' }); + if (bodyClass) bodyEl.addClass(bodyClass); + + const state = { isExpanded: false }; + setupCollapsible(wrapperEl, headerEl, bodyEl, state, { + baseAriaLabel: title, + }); + + return { wrapperEl, bodyEl }; +} + +function setPromptText(promptBodyEl: HTMLElement, prompt: string): void { + promptBodyEl.empty(); + const textEl = promptBodyEl.createDiv({ cls: 'claudian-subagent-prompt-text' }); + textEl.setText(prompt || 'No prompt provided'); +} + +function updateSyncHeaderAria(state: SubagentState): void { + state.headerEl.setAttribute( + 'aria-label', + `Subagent task: ${truncateDescription(state.info.description)} - Status: ${state.info.status} - click to expand` + ); + state.statusEl.setAttribute('aria-label', `Status: ${state.info.status}`); +} + +function renderSubagentToolContent(contentEl: HTMLElement, toolCall: ToolCallInfo): void { + contentEl.empty(); + + if (!toolCall.result && toolCall.status === 'running') { + const emptyEl = contentEl.createDiv({ cls: 'claudian-subagent-tool-empty' }); + emptyEl.setText('Running...'); + return; + } + + renderExpandedContent(contentEl, toolCall.name, toolCall.result, toolCall.input); +} + +function setSubagentToolStatus(view: SubagentToolView, status: ToolCallInfo['status']): void { + view.statusEl.className = 'claudian-subagent-tool-status'; + view.statusEl.addClass(`status-${status}`); + view.statusEl.empty(); + view.statusEl.setAttribute('aria-label', `Status: ${status}`); + + const statusIcon = SUBAGENT_TOOL_STATUS_ICONS[status]; + if (statusIcon) { + setIcon(view.statusEl, statusIcon); + } +} + +function updateSubagentToolView(view: SubagentToolView, toolCall: ToolCallInfo): void { + view.wrapperEl.className = `claudian-subagent-tool-item claudian-subagent-tool-${toolCall.status}`; + view.nameEl.setText(getToolName(toolCall.name, toolCall.input)); + view.summaryEl.setText(getToolSummary(toolCall.name, toolCall.input)); + setSubagentToolStatus(view, toolCall.status); + renderSubagentToolContent(view.contentEl, toolCall); +} + +function createSubagentToolView(parentEl: HTMLElement, toolCall: ToolCallInfo): SubagentToolView { + const wrapperEl = parentEl.createDiv({ + cls: `claudian-subagent-tool-item claudian-subagent-tool-${toolCall.status}`, + }); + wrapperEl.dataset.toolId = toolCall.id; + + const headerEl = wrapperEl.createDiv({ cls: 'claudian-subagent-tool-header' }); + headerEl.setAttribute('tabindex', '0'); + headerEl.setAttribute('role', 'button'); + + const iconEl = headerEl.createDiv({ cls: 'claudian-subagent-tool-icon' }); + iconEl.setAttribute('aria-hidden', 'true'); + setToolIcon(iconEl, toolCall.name); + + const nameEl = headerEl.createDiv({ cls: 'claudian-subagent-tool-name' }); + const summaryEl = headerEl.createDiv({ cls: 'claudian-subagent-tool-summary' }); + const statusEl = headerEl.createDiv({ cls: 'claudian-subagent-tool-status' }); + + const contentEl = wrapperEl.createDiv({ cls: 'claudian-subagent-tool-content' }); + + const collapseState = { isExpanded: toolCall.isExpanded ?? false }; + setupCollapsible(wrapperEl, headerEl, contentEl, collapseState, { + initiallyExpanded: toolCall.isExpanded ?? false, + onToggle: (expanded) => { + toolCall.isExpanded = expanded; + }, + baseAriaLabel: getToolLabel(toolCall.name, toolCall.input), + }); + + const view: SubagentToolView = { + wrapperEl, + nameEl, + summaryEl, + statusEl, + contentEl, + }; + updateSubagentToolView(view, toolCall); + + return view; +} + +function ensureResultSection(state: SubagentState): SubagentSection { + if (state.resultSectionEl && state.resultBodyEl) { + return { wrapperEl: state.resultSectionEl, bodyEl: state.resultBodyEl }; + } + + const section = createSection(state.contentEl, 'Result', 'claudian-subagent-result-body'); + section.wrapperEl.addClass('claudian-subagent-section-result'); + state.resultSectionEl = section.wrapperEl; + state.resultBodyEl = section.bodyEl; + return section; +} + +function setResultText(state: SubagentState, text: string): void { + const section = ensureResultSection(state); + section.bodyEl.empty(); + const resultEl = section.bodyEl.createDiv({ cls: 'claudian-subagent-result-output' }); + resultEl.setText(text); +} + +function hydrateSyncSubagentStateFromStored(state: SubagentState, subagent: SubagentInfo): void { + state.info.description = subagent.description; + state.info.prompt = subagent.prompt; + state.info.mode = subagent.mode; + state.info.status = subagent.status; + state.info.result = subagent.result; + + state.labelEl.setText(truncateDescription(subagent.description)); + setPromptText(state.promptBodyEl, subagent.prompt || ''); + + for (const originalToolCall of subagent.toolCalls) { + const toolCall: ToolCallInfo = { + ...originalToolCall, + input: { ...originalToolCall.input }, + }; + addSubagentToolCall(state, toolCall); + if (toolCall.status !== 'running' || toolCall.result) { + updateSubagentToolResult(state, toolCall.id, toolCall); + } + } + + if (subagent.status === 'completed' || subagent.status === 'error') { + const fallback = subagent.status === 'error' ? 'ERROR' : 'DONE'; + finalizeSubagentBlock(state, subagent.result || fallback, subagent.status === 'error'); + } else { + state.statusEl.className = 'claudian-subagent-status status-running'; + state.statusEl.empty(); + updateSyncHeaderAria(state); + } +} + +export function createSubagentBlock( + parentEl: HTMLElement, + taskToolId: string, + taskInput: Record +): SubagentState { + const description = extractTaskDescription(taskInput); + const prompt = extractTaskPrompt(taskInput); + + const info: SubagentInfo = { + id: taskToolId, + description, + prompt, + status: 'running', + toolCalls: [], + isExpanded: false, + }; + + const wrapperEl = parentEl.createDiv({ cls: 'claudian-subagent-list' }); + wrapperEl.dataset.subagentId = taskToolId; + + const headerEl = wrapperEl.createDiv({ cls: 'claudian-subagent-header' }); + headerEl.setAttribute('tabindex', '0'); + headerEl.setAttribute('role', 'button'); + + const iconEl = headerEl.createDiv({ cls: 'claudian-subagent-icon' }); + iconEl.setAttribute('aria-hidden', 'true'); + setIcon(iconEl, getToolIcon(TOOL_TASK)); + + const labelEl = headerEl.createDiv({ cls: 'claudian-subagent-label' }); + labelEl.setText(truncateDescription(description)); + + const statusEl = headerEl.createDiv({ cls: 'claudian-subagent-status status-running' }); + statusEl.setAttribute('aria-label', 'Status: running'); + + const contentEl = wrapperEl.createDiv({ cls: 'claudian-subagent-content' }); + + const promptSection = createSection(contentEl, 'Prompt', 'claudian-subagent-prompt-body'); + promptSection.wrapperEl.addClass('claudian-subagent-section-prompt'); + setPromptText(promptSection.bodyEl, prompt); + + const toolsContainerEl = contentEl.createDiv({ cls: 'claudian-subagent-tools' }); + + setupCollapsible(wrapperEl, headerEl, contentEl, info); + + const state: SubagentState = { + wrapperEl, + contentEl, + headerEl, + labelEl, + statusEl, + promptSectionEl: promptSection.wrapperEl, + promptBodyEl: promptSection.bodyEl, + toolsContainerEl, + resultSectionEl: null, + resultBodyEl: null, + toolElements: new Map(), + info, + }; + + updateSyncHeaderAria(state); + return state; +} + +export function addSubagentToolCall( + state: SubagentState, + toolCall: ToolCallInfo +): void { + const existingIndex = state.info.toolCalls.findIndex(tc => tc.id === toolCall.id); + if (existingIndex >= 0) { + const existingToolCall = state.info.toolCalls[existingIndex]; + const mergedToolCall: ToolCallInfo = { + ...existingToolCall, + ...toolCall, + input: { + ...existingToolCall.input, + ...toolCall.input, + }, + result: toolCall.result ?? existingToolCall.result, + isExpanded: toolCall.isExpanded ?? existingToolCall.isExpanded, + }; + + state.info.toolCalls[existingIndex] = mergedToolCall; + + const existingView = state.toolElements.get(toolCall.id); + if (existingView) { + updateSubagentToolView(existingView, mergedToolCall); + } + + updateSyncHeaderAria(state); + return; + } + + state.info.toolCalls.push(toolCall); + + const toolView = createSubagentToolView(state.toolsContainerEl, toolCall); + state.toolElements.set(toolCall.id, toolView); + + updateSyncHeaderAria(state); +} + +export function updateSubagentToolResult( + state: SubagentState, + toolId: string, + toolCall: ToolCallInfo +): void { + const idx = state.info.toolCalls.findIndex(tc => tc.id === toolId); + if (idx !== -1) { + state.info.toolCalls[idx] = toolCall; + } + + const toolView = state.toolElements.get(toolId); + if (!toolView) { + return; + } + + updateSubagentToolView(toolView, toolCall); +} + +export function finalizeSubagentBlock( + state: SubagentState, + result: string, + isError: boolean +): void { + state.info.status = isError ? 'error' : 'completed'; + state.info.result = result; + + state.labelEl.setText(truncateDescription(state.info.description)); + + state.statusEl.className = 'claudian-subagent-status'; + state.statusEl.addClass(`status-${state.info.status}`); + state.statusEl.empty(); + if (state.info.status === 'completed') { + setIcon(state.statusEl, 'check'); + state.wrapperEl.removeClass('error'); + state.wrapperEl.addClass('done'); + } else { + setIcon(state.statusEl, 'x'); + state.wrapperEl.removeClass('done'); + state.wrapperEl.addClass('error'); + } + + const finalText = result?.trim() ? result : (isError ? 'ERROR' : 'DONE'); + setResultText(state, finalText); + + updateSyncHeaderAria(state); +} + +export function renderStoredSubagent( + parentEl: HTMLElement, + subagent: SubagentInfo +): HTMLElement { + const state = createSubagentBlock(parentEl, subagent.id, { + description: subagent.description, + prompt: subagent.prompt, + }); + + hydrateSyncSubagentStateFromStored(state, subagent); + return state.wrapperEl; +} + +export interface AsyncSubagentState { + wrapperEl: HTMLElement; + contentEl: HTMLElement; + headerEl: HTMLElement; + labelEl: HTMLElement; + statusTextEl: HTMLElement; // Running / Completed / Error / Orphaned + statusEl: HTMLElement; + info: SubagentInfo; +} + +function setAsyncWrapperStatus(wrapperEl: HTMLElement, status: string): void { + const classes = ['pending', 'running', 'awaiting', 'completed', 'error', 'orphaned', 'async']; + classes.forEach(cls => wrapperEl.removeClass(cls)); + wrapperEl.addClass('async'); + wrapperEl.addClass(status); +} + +function getAsyncDisplayStatus(asyncStatus: string | undefined): 'running' | 'completed' | 'error' | 'orphaned' { + switch (asyncStatus) { + case 'completed': return 'completed'; + case 'error': return 'error'; + case 'orphaned': return 'orphaned'; + default: return 'running'; + } +} + +function getAsyncStatusText(asyncStatus: string | undefined): string { + switch (asyncStatus) { + case 'pending': return 'Initializing'; + case 'completed': return ''; // Just show tick icon, no text + case 'error': return 'Error'; + case 'orphaned': return 'Orphaned'; + default: return 'Running in background'; + } +} + +function getAsyncStatusAriaLabel(asyncStatus: string | undefined): string { + switch (asyncStatus) { + case 'pending': return 'Initializing'; + case 'completed': return 'Completed'; + case 'error': return 'Error'; + case 'orphaned': return 'Orphaned'; + default: return 'Running in background'; + } +} + +function updateAsyncLabel(state: AsyncSubagentState): void { + state.labelEl.setText(truncateDescription(state.info.description)); + + const statusLabel = getAsyncStatusAriaLabel(state.info.asyncStatus); + state.headerEl.setAttribute( + 'aria-label', + `Background task: ${truncateDescription(state.info.description)} - ${statusLabel} - click to expand` + ); +} + +function renderAsyncContentLikeSync( + contentEl: HTMLElement, + subagent: SubagentInfo, + displayStatus: 'running' | 'completed' | 'error' | 'orphaned' +): void { + contentEl.empty(); + + const promptSection = createSection(contentEl, 'Prompt', 'claudian-subagent-prompt-body'); + promptSection.wrapperEl.addClass('claudian-subagent-section-prompt'); + setPromptText(promptSection.bodyEl, subagent.prompt || ''); + + const toolsContainerEl = contentEl.createDiv({ cls: 'claudian-subagent-tools' }); + for (const originalToolCall of subagent.toolCalls) { + const toolCall: ToolCallInfo = { + ...originalToolCall, + input: { ...originalToolCall.input }, + }; + createSubagentToolView(toolsContainerEl, toolCall); + } + + if (displayStatus === 'running') { + return; + } + + const resultSection = createSection(contentEl, 'Result', 'claudian-subagent-result-body'); + resultSection.wrapperEl.addClass('claudian-subagent-section-result'); + const resultEl = resultSection.bodyEl.createDiv({ cls: 'claudian-subagent-result-output' }); + + if (displayStatus === 'orphaned') { + resultEl.setText(subagent.result || 'Conversation ended before task completed'); + return; + } + + const fallback = displayStatus === 'error' ? 'ERROR' : 'DONE'; + const finalText = subagent.result?.trim() ? subagent.result : fallback; + resultEl.setText(finalText); +} + +/** + * Create an async subagent block for a background Agent tool call. + * Expandable to show the task prompt. Collapsed by default. + */ +export function createAsyncSubagentBlock( + parentEl: HTMLElement, + taskToolId: string, + taskInput: Record +): AsyncSubagentState { + const description = (taskInput.description as string) || 'Background task'; + const prompt = (taskInput.prompt as string) || ''; + + const info: SubagentInfo = { + id: taskToolId, + description, + prompt, + mode: 'async', + status: 'running', + toolCalls: [], + isExpanded: false, + asyncStatus: 'pending', + }; + + const wrapperEl = parentEl.createDiv({ cls: 'claudian-subagent-list' }); + setAsyncWrapperStatus(wrapperEl, 'pending'); + wrapperEl.dataset.asyncSubagentId = taskToolId; + + const headerEl = wrapperEl.createDiv({ cls: 'claudian-subagent-header' }); + headerEl.setAttribute('tabindex', '0'); + headerEl.setAttribute('role', 'button'); + headerEl.setAttribute('aria-expanded', 'false'); + headerEl.setAttribute('aria-label', `Background task: ${description} - Initializing - click to expand`); + + const iconEl = headerEl.createDiv({ cls: 'claudian-subagent-icon' }); + iconEl.setAttribute('aria-hidden', 'true'); + setIcon(iconEl, getToolIcon(TOOL_TASK)); + + const labelEl = headerEl.createDiv({ cls: 'claudian-subagent-label' }); + labelEl.setText(truncateDescription(description)); + + const statusTextEl = headerEl.createDiv({ cls: 'claudian-subagent-status-text' }); + statusTextEl.setText('Initializing'); + + const statusEl = headerEl.createDiv({ cls: 'claudian-subagent-status status-running' }); + statusEl.setAttribute('aria-label', 'Status: running'); + + const contentEl = wrapperEl.createDiv({ cls: 'claudian-subagent-content' }); + renderAsyncContentLikeSync(contentEl, info, 'running'); + + setupCollapsible(wrapperEl, headerEl, contentEl, info); + + return { + wrapperEl, + contentEl, + headerEl, + labelEl, + statusTextEl, + statusEl, + info, + }; +} + +export function updateAsyncSubagentRunning( + state: AsyncSubagentState, + agentId: string +): void { + state.info.asyncStatus = 'running'; + state.info.agentId = agentId; + + setAsyncWrapperStatus(state.wrapperEl, 'running'); + updateAsyncLabel(state); + + state.statusTextEl.setText('Running in background'); + + renderAsyncContentLikeSync(state.contentEl, state.info, 'running'); +} + +export function finalizeAsyncSubagent( + state: AsyncSubagentState, + result: string, + isError: boolean +): void { + state.info.asyncStatus = isError ? 'error' : 'completed'; + state.info.status = isError ? 'error' : 'completed'; + state.info.result = result; + + setAsyncWrapperStatus(state.wrapperEl, isError ? 'error' : 'completed'); + updateAsyncLabel(state); + + state.statusTextEl.setText(isError ? 'Error' : ''); + + state.statusEl.className = 'claudian-subagent-status'; + state.statusEl.addClass(`status-${isError ? 'error' : 'completed'}`); + state.statusEl.empty(); + if (isError) { + setIcon(state.statusEl, 'x'); + } else { + setIcon(state.statusEl, 'check'); + } + + if (isError) { + state.wrapperEl.addClass('error'); + } else { + state.wrapperEl.addClass('done'); + } + + renderAsyncContentLikeSync(state.contentEl, state.info, isError ? 'error' : 'completed'); +} + +export function markAsyncSubagentOrphaned(state: AsyncSubagentState): void { + state.info.asyncStatus = 'orphaned'; + state.info.status = 'error'; + state.info.result = 'Conversation ended before task completed'; + + setAsyncWrapperStatus(state.wrapperEl, 'orphaned'); + updateAsyncLabel(state); + + state.statusTextEl.setText('Orphaned'); + + state.statusEl.className = 'claudian-subagent-status status-error'; + state.statusEl.empty(); + setIcon(state.statusEl, 'alert-circle'); + + state.wrapperEl.addClass('error'); + state.wrapperEl.addClass('orphaned'); + + renderAsyncContentLikeSync(state.contentEl, state.info, 'orphaned'); +} + +/** + * Render a stored async subagent from conversation history. + * Expandable to show the task prompt. Collapsed by default. + */ +export function renderStoredAsyncSubagent( + parentEl: HTMLElement, + subagent: SubagentInfo +): HTMLElement { + const wrapperEl = parentEl.createDiv({ cls: 'claudian-subagent-list' }); + const displayStatus = getAsyncDisplayStatus(subagent.asyncStatus); + setAsyncWrapperStatus(wrapperEl, displayStatus); + + if (displayStatus === 'completed') { + wrapperEl.addClass('done'); + } else if (displayStatus === 'error' || displayStatus === 'orphaned') { + wrapperEl.addClass('error'); + } + wrapperEl.dataset.asyncSubagentId = subagent.id; + + const statusText = getAsyncStatusText(subagent.asyncStatus); + const statusAriaLabel = getAsyncStatusAriaLabel(subagent.asyncStatus); + + const headerEl = wrapperEl.createDiv({ cls: 'claudian-subagent-header' }); + headerEl.setAttribute('tabindex', '0'); + headerEl.setAttribute('role', 'button'); + headerEl.setAttribute('aria-expanded', 'false'); + headerEl.setAttribute( + 'aria-label', + `Background task: ${subagent.description} - ${statusAriaLabel} - click to expand` + ); + + const iconEl = headerEl.createDiv({ cls: 'claudian-subagent-icon' }); + iconEl.setAttribute('aria-hidden', 'true'); + setIcon(iconEl, getToolIcon(TOOL_TASK)); + + const labelEl = headerEl.createDiv({ cls: 'claudian-subagent-label' }); + labelEl.setText(truncateDescription(subagent.description)); + + const statusTextEl = headerEl.createDiv({ cls: 'claudian-subagent-status-text' }); + statusTextEl.setText(statusText); + + let statusIconClass: string; + switch (displayStatus) { + case 'error': + case 'orphaned': + statusIconClass = 'status-error'; + break; + case 'completed': + statusIconClass = 'status-completed'; + break; + default: + statusIconClass = 'status-running'; + } + const statusEl = headerEl.createDiv({ cls: `claudian-subagent-status ${statusIconClass}` }); + statusEl.setAttribute('aria-label', `Status: ${statusAriaLabel}`); + + switch (displayStatus) { + case 'completed': + setIcon(statusEl, 'check'); + break; + case 'error': + setIcon(statusEl, 'x'); + break; + case 'orphaned': + setIcon(statusEl, 'alert-circle'); + break; + } + + const contentEl = wrapperEl.createDiv({ cls: 'claudian-subagent-content' }); + renderAsyncContentLikeSync(contentEl, subagent, displayStatus); + + const state = { isExpanded: false }; + setupCollapsible(wrapperEl, headerEl, contentEl, state); + + return wrapperEl; +} diff --git a/src/features/chat/rendering/ThinkingBlockRenderer.ts b/src/features/chat/rendering/ThinkingBlockRenderer.ts new file mode 100644 index 0000000..16c17df --- /dev/null +++ b/src/features/chat/rendering/ThinkingBlockRenderer.ts @@ -0,0 +1,126 @@ +import { collapseElement, setupCollapsible } from './collapsible'; + +export type RenderContentFn = (el: HTMLElement, markdown: string) => Promise; + +export interface ThinkingBlockState { + wrapperEl: HTMLElement; + contentEl: HTMLElement; + labelEl: HTMLElement; + content: string; + startTime: number; + timerInterval: number | null; + isExpanded: boolean; +} + +export function createThinkingBlock( + parentEl: HTMLElement, + renderContent: RenderContentFn +): ThinkingBlockState { + const wrapperEl = parentEl.createDiv({ cls: 'claudian-thinking-block' }); + + // Header (clickable to expand/collapse) + const header = wrapperEl.createDiv({ cls: 'claudian-thinking-header' }); + header.setAttribute('tabindex', '0'); + header.setAttribute('role', 'button'); + header.setAttribute('aria-expanded', 'false'); + header.setAttribute('aria-label', 'Extended thinking - click to expand'); + + // Label with timer + const labelEl = header.createSpan({ cls: 'claudian-thinking-label' }); + const startTime = Date.now(); + labelEl.setText('Thinking 0s...'); + + // Start timer interval to update label every second + const timerInterval = window.setInterval(() => { + const elapsed = Math.floor((Date.now() - startTime) / 1000); + labelEl.setText(`Thinking ${elapsed}s...`); + }, 1000); + + // Collapsible content (collapsed by default) + const contentEl = wrapperEl.createDiv({ cls: 'claudian-thinking-content' }); + + // Create state object first so toggle can reference it + const state: ThinkingBlockState = { + wrapperEl, + contentEl, + labelEl, + content: '', + startTime, + timerInterval, + isExpanded: false, + }; + + // Setup collapsible behavior (handles click, keyboard, ARIA, CSS) + setupCollapsible(wrapperEl, header, contentEl, state); + + return state; +} + +export async function appendThinkingContent( + state: ThinkingBlockState, + content: string, + renderContent: RenderContentFn +) { + state.content += content; + await renderContent(state.contentEl, state.content); +} + +export function finalizeThinkingBlock(state: ThinkingBlockState): number { + // Stop the timer + if (state.timerInterval) { + window.clearInterval(state.timerInterval); + state.timerInterval = null; + } + + // Calculate final duration + const durationSeconds = Math.floor((Date.now() - state.startTime) / 1000); + + // Update label to show final duration (without "...") + state.labelEl.setText(`Thought for ${durationSeconds}s`); + + // Collapse when done and sync state + const header = state.wrapperEl.querySelector('.claudian-thinking-header'); + if (header) { + collapseElement(state.wrapperEl, header as HTMLElement, state.contentEl, state); + } + + return durationSeconds; +} + +export function cleanupThinkingBlock(state: ThinkingBlockState | null) { + if (state?.timerInterval) { + window.clearInterval(state.timerInterval); + } +} + +export function renderStoredThinkingBlock( + parentEl: HTMLElement, + content: string, + durationSeconds: number | undefined, + renderContent: RenderContentFn +): HTMLElement { + const wrapperEl = parentEl.createDiv({ cls: 'claudian-thinking-block' }); + + // Header (clickable to expand/collapse) + const header = wrapperEl.createDiv({ cls: 'claudian-thinking-header' }); + header.setAttribute('tabindex', '0'); + header.setAttribute('role', 'button'); + header.setAttribute('aria-label', 'Extended thinking - click to expand'); + + // Label with duration + const labelEl = header.createSpan({ cls: 'claudian-thinking-label' }); + const labelText = durationSeconds !== undefined ? `Thought for ${durationSeconds}s` : 'Thought'; + labelEl.setText(labelText); + + // Collapsible content + const contentEl = wrapperEl.createDiv({ cls: 'claudian-thinking-content' }); + void renderContent(contentEl, content).catch(() => { + contentEl.setText(content); + }); + + // Setup collapsible behavior (handles click, keyboard, ARIA, CSS) + const state = { isExpanded: false }; + setupCollapsible(wrapperEl, header, contentEl, state); + + return wrapperEl; +} diff --git a/src/features/chat/rendering/TodoListRenderer.ts b/src/features/chat/rendering/TodoListRenderer.ts new file mode 100644 index 0000000..af4a126 --- /dev/null +++ b/src/features/chat/rendering/TodoListRenderer.ts @@ -0,0 +1,5 @@ +export { + extractLastTodosFromMessages, + parseTodoInput, + type TodoItem, +} from '../../../core/tools/todo'; diff --git a/src/features/chat/rendering/ToolCallRenderer.ts b/src/features/chat/rendering/ToolCallRenderer.ts new file mode 100644 index 0000000..ac2b371 --- /dev/null +++ b/src/features/chat/rendering/ToolCallRenderer.ts @@ -0,0 +1,1161 @@ +import { setIcon } from 'obsidian'; + +import type { TodoItem } from '../../../core/tools/todo'; +import { getToolIcon, MCP_ICON_MARKER } from '../../../core/tools/toolIcons'; +import { extractResolvedAnswersFromResultText } from '../../../core/tools/toolInput'; +import { + isAgentLifecycleTool, + TOOL_APPLY_PATCH, + TOOL_ASK_USER_QUESTION, + TOOL_BASH, + TOOL_EDIT, + TOOL_ENTER_PLAN_MODE, + TOOL_EXIT_PLAN_MODE, + TOOL_GLOB, + TOOL_GREP, + TOOL_LS, + TOOL_READ, + TOOL_SKILL, + TOOL_TODO_WRITE, + TOOL_TOOL_SEARCH, + TOOL_WEB_FETCH, + TOOL_WEB_SEARCH, + TOOL_WRITE, + TOOL_WRITE_STDIN, +} from '../../../core/tools/toolNames'; +import { extractToolResultContent } from '../../../core/tools/toolResultContent'; +import type { AskUserQuestionItem, AskUserQuestionOption, ToolCallInfo } from '../../../core/types'; +import type { DiffStats } from '../../../core/types/diff'; +import { appendMcpIcon } from '../../../shared/icons'; +import { parseApplyPatchDiffs, parseFileUpdateChangeDiffs } from '../../../utils/diff'; +import { setupCollapsible } from './collapsible'; +import { renderDiffContent, renderDiffStats } from './DiffRenderer'; +import { renderTodoItems } from './todoUtils'; + +export function setToolIcon(el: HTMLElement, name: string): void { + const icon = getToolIcon(name); + if (icon === MCP_ICON_MARKER) { + appendMcpIcon(el); + } else { + setIcon(el, icon); + } +} + +function stringifyToolValue(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (value === null || value === undefined) return ''; + + try { + return JSON.stringify(value); + } catch { + return ''; + } +} + +function getInputText(input: Record, key: string, fallback = ''): string { + return stringifyToolValue(input[key]) || fallback; +} + +export function getToolName(name: string, input: Record): string { + switch (name) { + case TOOL_TODO_WRITE: { + const todos = input.todos as Array<{ status: string }> | undefined; + if (todos && Array.isArray(todos) && todos.length > 0) { + const completed = todos.filter(t => t.status === 'completed').length; + return `Tasks ${completed}/${todos.length}`; + } + return 'Tasks'; + } + case TOOL_ENTER_PLAN_MODE: + return 'Entering plan mode'; + case TOOL_EXIT_PLAN_MODE: + return 'Plan complete'; + default: + return name; + } +} + +export function getToolSummary(name: string, input: Record): string { + switch (name) { + case TOOL_READ: + case TOOL_WRITE: + case TOOL_EDIT: { + const filePath = getInputText(input, 'file_path'); + return fileNameOnly(filePath); + } + case TOOL_BASH: { + const cmd = getInputText(input, 'command'); + return truncateText(cmd, 60); + } + case TOOL_GLOB: + case TOOL_GREP: + return getInputText(input, 'pattern'); + case TOOL_WEB_SEARCH: + return getWebSearchSummary(input, 60); + case TOOL_WEB_FETCH: + return truncateText(getInputText(input, 'url'), 60); + case TOOL_LS: + return fileNameOnly(getInputText(input, 'path', '.')); + case TOOL_SKILL: + return getInputText(input, 'skill'); + case TOOL_TOOL_SEARCH: + return truncateText(parseToolSearchQuery(getInputText(input, 'query')), 60); + case TOOL_TODO_WRITE: + return ''; + case TOOL_APPLY_PATCH: + return getApplyPatchSummary(input); + case TOOL_WRITE_STDIN: + return getWriteStdinSummary(input); + default: + if (isAgentLifecycleTool(name)) { + return getAgentLifecycleSummary(name, input); + } + return ''; + } +} + +/** Combined name+summary for ARIA labels (collapsible regions need a single descriptive phrase). */ +export function getToolLabel(name: string, input: Record): string { + switch (name) { + case TOOL_READ: + return `Read: ${shortenPath(getInputText(input, 'file_path')) || 'file'}`; + case TOOL_WRITE: + return `Write: ${shortenPath(getInputText(input, 'file_path')) || 'file'}`; + case TOOL_EDIT: + return `Edit: ${shortenPath(getInputText(input, 'file_path')) || 'file'}`; + case TOOL_BASH: { + const cmd = getInputText(input, 'command', 'command'); + return `Bash: ${cmd.length > 40 ? cmd.substring(0, 40) + '...' : cmd}`; + } + case TOOL_GLOB: + return `Glob: ${getInputText(input, 'pattern', 'files')}`; + case TOOL_GREP: + return `Grep: ${getInputText(input, 'pattern', 'pattern')}`; + case TOOL_WEB_SEARCH: { + return getWebSearchLabel(input, 40); + } + case TOOL_WEB_FETCH: { + const url = getInputText(input, 'url', 'url'); + return `WebFetch: ${url.length > 40 ? url.substring(0, 40) + '...' : url}`; + } + case TOOL_LS: + return `LS: ${shortenPath(getInputText(input, 'path')) || '.'}`; + case TOOL_TODO_WRITE: { + const todos = input.todos as Array<{ status: string }> | undefined; + if (todos && Array.isArray(todos)) { + const completed = todos.filter(t => t.status === 'completed').length; + return `Tasks (${completed}/${todos.length})`; + } + return 'Tasks'; + } + case TOOL_SKILL: { + const skillName = getInputText(input, 'skill', 'skill'); + return `Skill: ${skillName}`; + } + case TOOL_TOOL_SEARCH: { + const tools = parseToolSearchQuery(getInputText(input, 'query')); + return `ToolSearch: ${tools || 'tools'}`; + } + case TOOL_ENTER_PLAN_MODE: + return 'Entering plan mode'; + case TOOL_EXIT_PLAN_MODE: + return 'Plan complete'; + case TOOL_APPLY_PATCH: { + const summary = getApplyPatchSummary(input); + return summary ? `apply_patch: ${summary}` : 'apply_patch'; + } + case TOOL_WRITE_STDIN: { + const summary = getWriteStdinSummary(input); + return summary ? `write_stdin: ${summary}` : 'write_stdin'; + } + default: + if (isAgentLifecycleTool(name)) { + const summary = getAgentLifecycleSummary(name, input); + return summary ? `${name}: ${summary}` : name; + } + return name; + } +} + +export function fileNameOnly(filePath: string): string { + if (!filePath) return ''; + const normalized = filePath.replace(/\\/g, '/'); + return normalized.split('/').pop() ?? normalized; +} + +function getApplyPatchSummary(input: Record): string { + // Extract file paths from patch text markers + const patchText = typeof input.patch === 'string' ? input.patch : ''; + const patchFiles = [...patchText.matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)] + .map(m => m[1]?.trim() ?? ''); + + // Also check changes array + const changes = input.changes; + const changeFiles = Array.isArray(changes) + ? (changes as Array<{ path?: string }>) + .map(c => c.path) + .filter((p): p is string => !!p) + : []; + + const files = [...new Set([...patchFiles, ...changeFiles])]; + if (files.length === 0) return patchText ? 'patch' : ''; + if (files.length === 1) return fileNameOnly(files[0]); + return `${files.length} files`; +} + +function getWriteStdinSummary(input: Record): string { + const sessionId = stringifyToolValue(input.session_id ?? input.sessionId); + const chars = typeof input.chars === 'string' ? input.chars.replace(/\n/g, '\\n') : ''; + if (chars) { + const preview = chars.length > 24 ? `${chars.slice(0, 24)}...` : chars; + return sessionId ? `#${sessionId} ${preview}` : preview; + } + return sessionId ? `#${sessionId}` : ''; +} + +function getAgentLifecycleSummary(name: string, input: Record): string { + switch (name) { + case 'spawn_agent': { + const msg = typeof input.message === 'string' ? input.message : ''; + return msg.length > 50 ? `${msg.slice(0, 50)}...` : msg; + } + case 'send_input': { + const msg = typeof input.message === 'string' ? input.message : ''; + return msg.length > 40 ? `${msg.slice(0, 40)}...` : msg; + } + case 'wait': { + const ids = Array.isArray(input.ids) ? input.ids.length : 0; + const timeoutMs = typeof input.timeout_ms === 'number' ? input.timeout_ms : undefined; + const parts: string[] = []; + if (ids > 0) parts.push(`${ids} agent${ids === 1 ? '' : 's'}`); + if (timeoutMs !== undefined) parts.push(`${Math.round(timeoutMs / 1000)}s`); + return parts.join(', '); + } + case 'resume_agent': + case 'close_agent': + return ''; + default: + return ''; + } +} + +function shortenPath(filePath: string | undefined): string { + if (!filePath) return ''; + const normalized = filePath.replace(/\\/g, '/'); + const parts = normalized.split('/'); + if (parts.length <= 3) return normalized; + return '.../' + parts.slice(-2).join('/'); +} + +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + '...'; +} + +function parseToolSearchQuery(query: string | undefined): string { + if (!query) return ''; + const selectPrefix = 'select:'; + const body = query.startsWith(selectPrefix) ? query.slice(selectPrefix.length) : query; + return body.split(',').map(s => s.trim()).filter(Boolean).join(', '); +} + +interface WebSearchLink { + title: string; + url: string; +} + +interface WebSearchDisplayData { + actionType: string; + query: string; + queries: string[]; + url: string; + pattern: string; +} + +function normalizeWebSearchDisplayData(input: Record): WebSearchDisplayData { + const queries = Array.isArray(input.queries) + ? input.queries + .filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + .map(entry => entry.trim()) + : []; + + const query = typeof input.query === 'string' && input.query.trim() + ? input.query.trim() + : queries[0] ?? ''; + const url = typeof input.url === 'string' && input.url.trim() ? input.url.trim() : ''; + const pattern = typeof input.pattern === 'string' && input.pattern.trim() ? input.pattern.trim() : ''; + + const explicitActionType = typeof input.actionType === 'string' && input.actionType.trim() + ? input.actionType.trim() + : ''; + const actionType = explicitActionType + || (url && pattern ? 'find_in_page' : url ? 'open_page' : (query || queries.length > 0) ? 'search' : ''); + + return { actionType, query, queries, url, pattern }; +} + +function getWebSearchSummary(input: Record, maxLength: number): string { + const data = normalizeWebSearchDisplayData(input); + + switch (data.actionType) { + case 'open_page': + return truncateText(`Open ${data.url || 'page'}`, maxLength); + case 'find_in_page': { + const target = data.pattern ? `Find "${data.pattern}"` : 'Find in page'; + const suffix = data.url ? ` in ${data.url}` : ''; + return truncateText(target + suffix, maxLength); + } + case 'search': + return truncateText(data.query || data.queries[0] || '', maxLength); + default: + return truncateText(data.query || data.url || data.pattern || '', maxLength); + } +} + +function getWebSearchLabel(input: Record, maxLength: number): string { + const summary = getWebSearchSummary(input, maxLength); + return `WebSearch: ${summary || 'search'}`; +} + +function appendToolLink(parent: HTMLElement, title: string, url: string): void { + const linkEl = parent.createEl('a', { cls: 'claudian-tool-link' }); + linkEl.setAttribute('href', url); + linkEl.setAttribute('target', '_blank'); + linkEl.setAttribute('rel', 'noopener noreferrer'); + + const iconEl = linkEl.createSpan({ cls: 'claudian-tool-link-icon' }); + setIcon(iconEl, 'external-link'); + + linkEl.createSpan({ cls: 'claudian-tool-link-title', text: title }); +} + +function isPlaceholderWebSearchResult(result: string | undefined): boolean { + if (!result) return true; + const normalized = result.trim().toLowerCase(); + return normalized === '' || normalized === 'search complete'; +} + +function parseWebSearchResult(result: string): { links: WebSearchLink[]; summary: string } | null { + const linksMatch = result.match(/Links:\s*(\[[\s\S]*?\])(?:\n|$)/); + if (!linksMatch) return null; + + try { + const parsed = JSON.parse(linksMatch[1]) as WebSearchLink[]; + if (!Array.isArray(parsed) || parsed.length === 0) return null; + + const linksEndIndex = result.indexOf(linksMatch[0]) + linksMatch[0].length; + const summary = result.slice(linksEndIndex).trim(); + return { links: parsed.filter(l => l.title && l.url), summary }; + } catch { + return null; + } +} + +function renderWebSearchActionExpanded(container: HTMLElement, input: Record): boolean { + const data = normalizeWebSearchDisplayData(input); + const hasStructuredData = Boolean(data.actionType || data.query || data.queries.length || data.url || data.pattern); + if (!hasStructuredData) { + return false; + } + + const linesEl = container.createDiv({ cls: 'claudian-tool-lines' }); + + switch (data.actionType) { + case 'open_page': + linesEl.createDiv({ cls: 'claudian-tool-line', text: 'Open page' }); + if (data.url) { + appendToolLink(linesEl, data.url, data.url); + } else { + linesEl.createDiv({ cls: 'claudian-tool-line', text: 'URL unavailable' }); + } + return true; + + case 'find_in_page': + linesEl.createDiv({ cls: 'claudian-tool-line', text: 'Find in page' }); + if (data.url) { + appendToolLink(linesEl, data.url, data.url); + } else { + linesEl.createDiv({ cls: 'claudian-tool-line', text: 'URL unavailable' }); + } + if (data.pattern) { + linesEl.createDiv({ cls: 'claudian-tool-line', text: `Pattern: ${data.pattern}` }); + } + return true; + + case 'search': + default: { + const primaryQuery = data.query || data.queries[0]; + linesEl.createDiv({ + cls: 'claudian-tool-line', + text: primaryQuery ? `Query: ${primaryQuery}` : 'Search web', + }); + + const alternateQueries = data.queries.filter(query => query !== primaryQuery); + for (const query of alternateQueries.slice(0, 4)) { + linesEl.createDiv({ cls: 'claudian-tool-line', text: `Alt query: ${query}` }); + } + if (alternateQueries.length > 4) { + linesEl.createDiv({ + cls: 'claudian-tool-truncated', + text: `... ${alternateQueries.length - 4} more queries`, + }); + } + return true; + } + } +} + +function renderWebSearchExpanded( + container: HTMLElement, + input: Record, + result: string | undefined, +): void { + const parsed = result ? parseWebSearchResult(result) : null; + if (parsed && parsed.links.length > 0) { + const linksEl = container.createDiv({ cls: 'claudian-tool-lines' }); + for (const link of parsed.links) { + appendToolLink(linksEl, link.title, link.url); + } + + if (parsed.summary) { + const summaryEl = container.createDiv({ cls: 'claudian-tool-web-summary' }); + summaryEl.setText(parsed.summary.length > 800 ? parsed.summary.slice(0, 800) + '...' : parsed.summary); + } + return; + } + + const data = normalizeWebSearchDisplayData(input); + const shouldRenderAction = Boolean(data.actionType || data.query || data.queries.length || data.url || data.pattern) + && (!result + || isPlaceholderWebSearchResult(result) + || data.actionType === 'open_page' + || data.actionType === 'find_in_page'); + + if (shouldRenderAction && renderWebSearchActionExpanded(container, input)) { + if (result && !isPlaceholderWebSearchResult(result)) { + renderLinesExpanded(container, result, 12); + } + return; + } + + if (result) { + renderLinesExpanded(container, result, 20); + return; + } + + if (renderWebSearchActionExpanded(container, input)) { + return; + } + + container.createDiv({ cls: 'claudian-tool-empty', text: 'No result' }); +} + +function renderFileSearchExpanded(container: HTMLElement, result: string): void { + const lines = result.split(/\r?\n/).filter(line => line.trim()); + if (lines.length === 0) { + container.createDiv({ cls: 'claudian-tool-empty', text: 'No matches found' }); + return; + } + renderLinesExpanded(container, result, 15, true); +} + +function renderLinesExpanded( + container: HTMLElement, + result: string, + maxLines: number, + hoverable = false +): void { + const lines = result.split(/\r?\n/); + const truncated = lines.length > maxLines; + const displayLines = truncated ? lines.slice(0, maxLines) : lines; + + const linesEl = container.createDiv({ cls: 'claudian-tool-lines' }); + for (const line of displayLines) { + const stripped = line.replace(/^\s*\d+→/, ''); + const lineEl = linesEl.createDiv({ cls: 'claudian-tool-line' }); + if (hoverable) lineEl.addClass('hoverable'); + lineEl.setText(stripped || ' '); + } + + if (truncated) { + linesEl.createDiv({ + cls: 'claudian-tool-truncated', + text: `... ${lines.length - maxLines} more lines`, + }); + } +} + +function renderToolSearchExpanded(container: HTMLElement, result: string): void { + let toolNames: string[] = []; + try { + const parsed = JSON.parse(result) as Array<{ type: string; tool_name: string }>; + if (Array.isArray(parsed)) { + toolNames = parsed + .filter(item => item.type === 'tool_reference' && item.tool_name) + .map(item => item.tool_name); + } + } catch { + // Fall back to showing raw result + } + + if (toolNames.length === 0) { + renderLinesExpanded(container, result, 20); + return; + } + + for (const name of toolNames) { + const lineEl = container.createDiv({ cls: 'claudian-tool-search-item' }); + const iconEl = lineEl.createSpan({ cls: 'claudian-tool-search-icon' }); + setToolIcon(iconEl, name); + lineEl.createSpan({ text: name }); + } +} + +function renderWebFetchExpanded(container: HTMLElement, result: string): void { + const maxChars = 500; + const linesEl = container.createDiv({ cls: 'claudian-tool-lines' }); + const lineEl = linesEl.createDiv({ cls: 'claudian-tool-line claudian-tool-line-wrap' }); + + if (result.length > maxChars) { + lineEl.setText(result.slice(0, maxChars)); + linesEl.createDiv({ + cls: 'claudian-tool-truncated', + text: `... ${result.length - maxChars} more characters`, + }); + } else { + lineEl.setText(result); + } +} + +function renderApplyPatchExpanded( + container: HTMLElement, + input: Record, + result: string | undefined, +): void { + const patchText = typeof input.patch === 'string' ? input.patch : ''; + const parsedDiffs = getApplyPatchFileDiffs(input); + + if (result && /verification failed|^[Ee]rror:/.test(result.trim())) { + renderLinesExpanded(container, result, 20); + } + + if (parsedDiffs.length > 0) { + renderApplyPatchDiffSections(container, parsedDiffs); + return; + } + + const changes = Array.isArray(input.changes) ? input.changes : []; + if (changes.length > 0) { + const linesEl = container.createDiv({ cls: 'claudian-tool-lines' }); + for (const change of changes as unknown[]) { + if (!change || typeof change !== 'object' || Array.isArray(change)) continue; + const changeRecord = change as Record; + const path = typeof changeRecord.path === 'string' ? changeRecord.path : ''; + if (!path) continue; + const movedTo = readMoveTarget(changeRecord.kind); + const pathText = movedTo ? `${path} -> ${movedTo}` : path; + linesEl.createDiv({ cls: 'claudian-tool-line', text: pathText }); + } + return; + } + + if (patchText) { + renderLinesExpanded(container, patchText, 80); + return; + } + + if (result) { + const fileMatches = [...result.matchAll(/(?:update|add|delete|create|modify|Applied:\s*)(?:\w+:\s*)?([^\n,]+)/gi)]; + if (fileMatches.length > 0) { + const linesEl = container.createDiv({ cls: 'claudian-tool-lines' }); + for (const match of fileMatches) { + const filePath = match[1]?.trim(); + if (filePath) { + const lineEl = linesEl.createDiv({ cls: 'claudian-tool-line' }); + lineEl.setText(filePath); + } + } + return; + } + renderLinesExpanded(container, result, 20); + return; + } + + container.createDiv({ cls: 'claudian-tool-empty', text: 'No result' }); +} + +function renderApplyPatchDiffSections( + container: HTMLElement, + fileDiffs: ReturnType, +): void { + for (const fileDiff of fileDiffs) { + const sectionEl = container.createDiv({ cls: 'claudian-tool-patch-section' }); + + if (fileDiff.operation === 'delete' && fileDiff.diffLines.length === 0) { + sectionEl.createDiv({ cls: 'claudian-tool-empty', text: 'File deleted' }); + continue; + } + + if (fileDiff.diffLines.length === 0) { + sectionEl.createDiv({ cls: 'claudian-tool-empty', text: 'No textual diff available' }); + continue; + } + + const diffRow = sectionEl.createDiv({ cls: 'claudian-write-edit-diff-row' }); + const diffEl = diffRow.createDiv({ cls: 'claudian-write-edit-diff' }); + renderDiffContent(diffEl, fileDiff.diffLines); + } +} + +function readMoveTarget(kind: unknown): string | undefined { + if (!kind || typeof kind !== 'object' || Array.isArray(kind)) { + return undefined; + } + const record = kind as Record; + return typeof record.move_path === 'string' ? record.move_path : undefined; +} + +function getApplyPatchFileDiffs(input: Record): ReturnType { + const patchText = typeof input.patch === 'string' ? input.patch : ''; + const parsedDiffs = patchText ? parseApplyPatchDiffs(patchText) : []; + return parsedDiffs.length > 0 ? parsedDiffs : parseFileUpdateChangeDiffs(input.changes); +} + +function getApplyPatchDiffStats(input: Record): DiffStats | undefined { + const fileDiffs = getApplyPatchFileDiffs(input); + if (fileDiffs.length === 0) return undefined; + + const stats = fileDiffs.reduce( + (acc, fileDiff) => ({ + added: acc.added + fileDiff.stats.added, + removed: acc.removed + fileDiff.stats.removed, + }), + { added: 0, removed: 0 } + ); + + return stats.added > 0 || stats.removed > 0 ? stats : undefined; +} + +function getDiffStatsAriaLabel(stats: DiffStats): string { + return `Changes: +${stats.added} -${stats.removed}`; +} + +function renderAgentLifecycleExpanded(container: HTMLElement, result: string): void { + // Try to parse as JSON for structured display + const trimmed = result.trim(); + if (trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed) as Record; + const linesEl = container.createDiv({ cls: 'claudian-tool-lines' }); + for (const [key, value] of Object.entries(parsed)) { + const lineEl = linesEl.createDiv({ cls: 'claudian-tool-line' }); + const displayValue = formatToolDisplayValue(value); + lineEl.setText(`${key}: ${displayValue}`); + } + return; + } catch { /* fall through to plain text */ } + } + renderLinesExpanded(container, result, 20); +} + +function formatToolDisplayValue(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return `${value}`; + } + if (value === null || value === undefined) { + return ''; + } + return JSON.stringify(value); +} + +export function renderExpandedContent( + container: HTMLElement, + toolName: string, + result: string | undefined, + input: Record = {}, +): void { + if (!result && toolName !== TOOL_WEB_SEARCH && toolName !== TOOL_BASH && toolName !== TOOL_APPLY_PATCH) { + container.createDiv({ cls: 'claudian-tool-empty', text: 'No result' }); + return; + } + + const resolvedResult = result ?? ''; + + if (isAgentLifecycleTool(toolName)) { + renderAgentLifecycleExpanded(container, resolvedResult); + return; + } + + switch (toolName) { + case TOOL_BASH: + renderBashContent(container, input, resolvedResult); + break; + case TOOL_WRITE_STDIN: + renderLinesExpanded(container, resolvedResult, 20); + break; + case TOOL_READ: + renderLinesExpanded(container, resolvedResult, 15); + break; + case TOOL_GLOB: + case TOOL_GREP: + case TOOL_LS: + renderFileSearchExpanded(container, resolvedResult); + break; + case TOOL_WEB_SEARCH: + renderWebSearchExpanded(container, input, result); + break; + case TOOL_WEB_FETCH: + renderWebFetchExpanded(container, resolvedResult); + break; + case TOOL_TOOL_SEARCH: + renderToolSearchExpanded(container, resolvedResult); + break; + case TOOL_APPLY_PATCH: + renderApplyPatchExpanded(container, input, result); + break; + default: + renderLinesExpanded(container, resolvedResult, 20); + break; + } +} + +function getTodos(input: Record): TodoItem[] | undefined { + const todos = input.todos; + if (!todos || !Array.isArray(todos)) return undefined; + return todos as TodoItem[]; +} + +function getCurrentTask(input: Record): TodoItem | undefined { + const todos = getTodos(input); + if (!todos) return undefined; + return todos.find(t => t.status === 'in_progress'); +} + +function areAllTodosCompleted(input: Record): boolean { + const todos = getTodos(input); + if (!todos || todos.length === 0) return false; + return todos.every(t => t.status === 'completed'); +} + +function resetStatusElement(statusEl: HTMLElement, statusClass: string, ariaLabel: string): void { + statusEl.className = 'claudian-tool-status'; + statusEl.empty(); + statusEl.addClass(statusClass); + statusEl.setAttribute('aria-label', ariaLabel); +} + +const STATUS_ICONS: Record = { + completed: 'check', + error: 'x', + blocked: 'shield-off', +}; + +function setTodoWriteStatus(statusEl: HTMLElement, input: Record): void { + const isComplete = areAllTodosCompleted(input); + const status = isComplete ? 'completed' : 'running'; + const ariaLabel = isComplete ? 'Status: completed' : 'Status: in progress'; + resetStatusElement(statusEl, `status-${status}`, ariaLabel); + if (isComplete) setIcon(statusEl, 'check'); +} + +function setToolStatus(statusEl: HTMLElement, status: ToolCallInfo['status']): void { + resetStatusElement(statusEl, `status-${status}`, `Status: ${status}`); + const icon = STATUS_ICONS[status]; + if (icon) setIcon(statusEl, icon); +} + +function setApplyPatchHeaderRight(statusEl: HTMLElement, toolCall: ToolCallInfo): void { + const isError = toolCall.status === 'error' || toolCall.status === 'blocked'; + const stats = isError ? undefined : getApplyPatchDiffStats(toolCall.input); + if (!stats) { + setToolStatus(statusEl, toolCall.status); + return; + } + + statusEl.className = 'claudian-tool-status claudian-write-edit-stats'; + statusEl.empty(); + statusEl.setAttribute('aria-label', getDiffStatsAriaLabel(stats)); + renderDiffStats(statusEl, stats); +} + +function setGenericToolHeaderRight(statusEl: HTMLElement, toolCall: ToolCallInfo): void { + if (toolCall.name === TOOL_APPLY_PATCH) { + setApplyPatchHeaderRight(statusEl, toolCall); + return; + } + + setToolStatus(statusEl, toolCall.status); +} + +export function renderTodoWriteResult( + container: HTMLElement, + input: Record +): void { + container.empty(); + container.addClass('claudian-todo-panel-content'); + container.addClass('claudian-todo-list-container'); + + const todos = input.todos as TodoItem[] | undefined; + if (!todos || !Array.isArray(todos)) { + const item = container.createSpan({ cls: 'claudian-tool-result-item' }); + item.setText('Tasks updated'); + return; + } + + renderTodoItems(container, todos); +} + +export function isBlockedToolResult(content: unknown, isError?: boolean): boolean { + const lower = extractToolResultContent(content, { fallbackIndent: 2 }).toLowerCase(); + if (lower.includes('outside the vault')) return true; + if (lower.includes('access denied')) return true; + if (lower.includes('user denied')) return true; + if (lower.includes('approval')) return true; + if (isError && lower.includes('deny')) return true; + return false; +} + +interface ToolElementStructure { + toolEl: HTMLElement; + header: HTMLElement; + iconEl: HTMLElement; + nameEl: HTMLElement; + summaryEl: HTMLElement; + statusEl: HTMLElement; + content: HTMLElement; + currentTaskEl: HTMLElement | null; +} + +export interface ToolCallRenderOptions { + initiallyExpanded?: boolean; +} + +function createToolElementStructure( + parentEl: HTMLElement, + toolCall: ToolCallInfo +): ToolElementStructure { + const toolEl = parentEl.createDiv({ cls: 'claudian-tool-call' }); + if (toolCall.name === TOOL_BASH) { + toolEl.addClass('claudian-tool-call-bash'); + } + + const header = toolEl.createDiv({ cls: 'claudian-tool-header' }); + header.setAttribute('tabindex', '0'); + header.setAttribute('role', 'button'); + + const iconEl = header.createSpan({ cls: 'claudian-tool-icon' }); + iconEl.setAttribute('aria-hidden', 'true'); + setToolIcon(iconEl, toolCall.name); + + const nameEl = header.createSpan({ cls: 'claudian-tool-name' }); + nameEl.setText(getToolName(toolCall.name, toolCall.input)); + + const summaryEl = header.createSpan({ cls: 'claudian-tool-summary' }); + summaryEl.setText(getToolSummary(toolCall.name, toolCall.input)); + + const currentTaskEl = toolCall.name === TOOL_TODO_WRITE + ? createCurrentTaskPreview(header, toolCall.input) + : null; + + const statusEl = header.createSpan({ cls: 'claudian-tool-status' }); + + const content = toolEl.createDiv({ cls: 'claudian-tool-content' }); + + return { toolEl, header, iconEl, nameEl, summaryEl, statusEl, content, currentTaskEl }; +} + +function formatAnswer(raw: unknown): string { + if (Array.isArray(raw)) return raw.join(', '); + if (typeof raw === 'string') return raw; + return ''; +} + +function resolveAskUserAnswers(toolCall: ToolCallInfo): Record | undefined { + if (toolCall.resolvedAnswers) return toolCall.resolvedAnswers; + + const parsed = extractResolvedAnswersFromResultText(toolCall.result); + if (parsed) { + toolCall.resolvedAnswers = parsed; + return parsed; + } + + return undefined; +} + +function renderAskUserQuestionResult(container: HTMLElement, toolCall: ToolCallInfo): boolean { + container.empty(); + const questions = toolCall.input.questions as AskUserQuestionItem[] | undefined; + const answers = resolveAskUserAnswers(toolCall); + if (!questions || !Array.isArray(questions) || !answers) return false; + + const reviewEl = container.createDiv({ cls: 'claudian-ask-review' }); + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + const answer = formatAnswer( + (q.id ? answers[q.id] : undefined) ?? answers[q.question] + ); + const pairEl = reviewEl.createDiv({ cls: 'claudian-ask-review-pair' }); + pairEl.createDiv({ text: `${i + 1}.`, cls: 'claudian-ask-review-num' }); + const bodyEl = pairEl.createDiv({ cls: 'claudian-ask-review-body' }); + bodyEl.createDiv({ text: q.question, cls: 'claudian-ask-review-q-text' }); + bodyEl.createDiv({ + text: answer || 'Not answered', + cls: answer ? 'claudian-ask-review-a-text' : 'claudian-ask-review-empty', + }); + } + + return true; +} + +function renderAskUserQuestionFallback(container: HTMLElement, toolCall: ToolCallInfo, initialText?: string): void { + container.empty(); + + const questions = Array.isArray(toolCall.input.questions) + ? toolCall.input.questions as AskUserQuestionItem[] + : []; + + if (questions.length === 0) { + contentFallback(container, initialText || toolCall.result || 'Waiting for answer...'); + return; + } + + if (initialText || toolCall.result) { + container.createDiv({ + cls: 'claudian-ask-review-prompt', + text: initialText || toolCall.result || 'Waiting for answer...', + }); + } + + for (let questionIndex = 0; questionIndex < questions.length; questionIndex++) { + const question = questions[questionIndex]; + const reviewEl = container.createDiv({ cls: 'claudian-ask-review' }); + const pairEl = reviewEl.createDiv({ cls: 'claudian-ask-review-pair' }); + pairEl.createDiv({ text: `${questionIndex + 1}.`, cls: 'claudian-ask-review-num' }); + const bodyEl = pairEl.createDiv({ cls: 'claudian-ask-review-body' }); + bodyEl.createDiv({ text: question.question, cls: 'claudian-ask-review-q-text' }); + + if (!Array.isArray(question.options) || question.options.length === 0) { + bodyEl.createDiv({ cls: 'claudian-ask-review-empty', text: 'No options recorded' }); + continue; + } + + const listEl = bodyEl.createDiv({ cls: 'claudian-ask-list' }); + question.options.forEach((option, optionIndex) => { + renderAskUserQuestionOption(listEl, option, optionIndex, question.multiSelect === true); + }); + } +} + +function renderAskUserQuestionOption( + parentEl: HTMLElement, + option: AskUserQuestionOption, + optionIndex: number, + isMultiSelect: boolean, +): void { + const itemEl = parentEl.createDiv({ cls: 'claudian-ask-item is-disabled' }); + + if (isMultiSelect) { + itemEl.createDiv({ cls: 'claudian-ask-check', text: '[ ] ' }); + } else { + itemEl.createDiv({ cls: 'claudian-ask-item-num', text: `${optionIndex + 1}. ` }); + } + + const contentEl = itemEl.createDiv({ cls: 'claudian-ask-item-content' }); + const labelRowEl = contentEl.createDiv({ cls: 'claudian-ask-label-row' }); + labelRowEl.createDiv({ cls: 'claudian-ask-item-label', text: option.label }); + + if (option.description) { + contentEl.createDiv({ cls: 'claudian-ask-item-desc', text: option.description }); + } +} + +function contentFallback(container: HTMLElement, text: string): void { + const resultRow = container.createDiv({ cls: 'claudian-tool-result-row' }); + const resultText = resultRow.createSpan({ cls: 'claudian-tool-result-text' }); + resultText.setText(text); +} + +function renderBashContent( + container: HTMLElement, + input: Record, + result: string, + initialText?: string, +): void { + const command = (input.command as string) || ''; + if (command) { + const cmdEl = container.createDiv({ cls: 'claudian-tool-bash-command' }); + cmdEl.setText(`$ ${command}`); + } + if (initialText) { + contentFallback(container, initialText); + } else if (result) { + renderLinesExpanded(container, result, 20); + } else { + container.createDiv({ cls: 'claudian-tool-empty', text: 'No result' }); + } +} + +function createCurrentTaskPreview( + header: HTMLElement, + input: Record +): HTMLElement { + const currentTaskEl = header.createSpan({ cls: 'claudian-tool-current' }); + const currentTask = getCurrentTask(input); + if (currentTask) { + currentTaskEl.setText(currentTask.activeForm); + } + return currentTaskEl; +} + +function createTodoToggleHandler( + currentTaskEl: HTMLElement | null, + statusEl: HTMLElement | null, + onExpandChange?: (expanded: boolean) => void +): (expanded: boolean) => void { + return (expanded: boolean) => { + if (onExpandChange) onExpandChange(expanded); + if (currentTaskEl) { + currentTaskEl.toggleClass('claudian-hidden', expanded); + } + if (statusEl) { + statusEl.toggleClass('claudian-hidden', expanded); + } + }; +} + +function renderToolContent( + content: HTMLElement, + toolCall: ToolCallInfo, + initialText?: string +): void { + if (toolCall.name === TOOL_TODO_WRITE) { + content.addClass('claudian-tool-content-todo'); + renderTodoWriteResult(content, toolCall.input); + } else if (toolCall.name === TOOL_ASK_USER_QUESTION) { + content.addClass('claudian-tool-content-ask'); + if (initialText) { + renderAskUserQuestionFallback(content, toolCall, 'Waiting for answer...'); + } else if (!renderAskUserQuestionResult(content, toolCall)) { + renderAskUserQuestionFallback(content, toolCall); + } + } else if (toolCall.name === TOOL_BASH) { + renderBashContent(content, toolCall.input, toolCall.result ?? '', initialText); + } else if (initialText) { + contentFallback(content, initialText); + } else { + renderExpandedContent(content, toolCall.name, toolCall.result, toolCall.input); + } +} + +export function renderToolCall( + parentEl: HTMLElement, + toolCall: ToolCallInfo, + toolCallElements: Map, + options: ToolCallRenderOptions = {} +): HTMLElement { + const { toolEl, header, statusEl, content, currentTaskEl } = + createToolElementStructure(parentEl, toolCall); + + toolEl.dataset.toolId = toolCall.id; + toolCallElements.set(toolCall.id, toolEl); + + setGenericToolHeaderRight(statusEl, toolCall); + + renderToolContent(content, toolCall, 'Running...'); + + const initiallyExpanded = options.initiallyExpanded ?? false; + const state = { isExpanded: initiallyExpanded }; + toolCall.isExpanded = initiallyExpanded; + const todoStatusEl = toolCall.name === TOOL_TODO_WRITE ? statusEl : null; + setupCollapsible(toolEl, header, content, state, { + initiallyExpanded, + onToggle: createTodoToggleHandler(currentTaskEl, todoStatusEl, (expanded) => { + toolCall.isExpanded = expanded; + }), + baseAriaLabel: getToolLabel(toolCall.name, toolCall.input) + }); + + return toolEl; +} + +export function updateToolCallResult( + toolId: string, + toolCall: ToolCallInfo, + toolCallElements: Map +) { + const toolEl = toolCallElements.get(toolId); + if (!toolEl) return; + + if (toolCall.name === TOOL_TODO_WRITE) { + const statusEl = toolEl.querySelector('.claudian-tool-status') as HTMLElement; + if (statusEl) { + setTodoWriteStatus(statusEl, toolCall.input); + } + const content = toolEl.querySelector('.claudian-tool-content') as HTMLElement; + if (content) { + renderTodoWriteResult(content, toolCall.input); + } + const nameEl = toolEl.querySelector('.claudian-tool-name') as HTMLElement; + if (nameEl) { + nameEl.setText(getToolName(toolCall.name, toolCall.input)); + } + const currentTaskEl = toolEl.querySelector('.claudian-tool-current') as HTMLElement; + if (currentTaskEl) { + const currentTask = getCurrentTask(toolCall.input); + currentTaskEl.setText(currentTask ? currentTask.activeForm : ''); + } + return; + } + + const statusEl = toolEl.querySelector('.claudian-tool-status') as HTMLElement; + if (statusEl) { + setGenericToolHeaderRight(statusEl, toolCall); + } + + if (toolCall.name === TOOL_ASK_USER_QUESTION) { + const content = toolEl.querySelector('.claudian-tool-content') as HTMLElement; + if (content) { + content.addClass('claudian-tool-content-ask'); + if (!renderAskUserQuestionResult(content, toolCall)) { + renderAskUserQuestionFallback(content, toolCall); + } + } + return; + } + + const content = toolEl.querySelector('.claudian-tool-content') as HTMLElement; + if (content) { + content.empty(); + renderExpandedContent(content, toolCall.name, toolCall.result, toolCall.input); + } +} + +/** For stored (non-streaming) tool calls — collapsed by default. */ +export function renderStoredToolCall( + parentEl: HTMLElement, + toolCall: ToolCallInfo, + options: ToolCallRenderOptions = {} +): HTMLElement { + const { toolEl, header, statusEl, content, currentTaskEl } = + createToolElementStructure(parentEl, toolCall); + + if (toolCall.name === TOOL_TODO_WRITE) { + setTodoWriteStatus(statusEl, toolCall.input); + } else { + setGenericToolHeaderRight(statusEl, toolCall); + } + + renderToolContent(content, toolCall); + + const state = { isExpanded: false }; + const todoStatusEl = toolCall.name === TOOL_TODO_WRITE ? statusEl : null; + setupCollapsible(toolEl, header, content, state, { + initiallyExpanded: options.initiallyExpanded ?? false, + onToggle: createTodoToggleHandler(currentTaskEl, todoStatusEl), + baseAriaLabel: getToolLabel(toolCall.name, toolCall.input) + }); + + return toolEl; +} diff --git a/src/features/chat/rendering/WriteEditRenderer.ts b/src/features/chat/rendering/WriteEditRenderer.ts new file mode 100644 index 0000000..f7d66c3 --- /dev/null +++ b/src/features/chat/rendering/WriteEditRenderer.ts @@ -0,0 +1,232 @@ +import { setIcon } from 'obsidian'; + +import { getToolIcon } from '../../../core/tools/toolIcons'; +import type { ToolCallInfo, ToolDiffData } from '../../../core/types'; +import type { DiffLine } from '../../../core/types/diff'; +import { setupCollapsible } from './collapsible'; +import { renderDiffContent, renderDiffStats } from './DiffRenderer'; +import { fileNameOnly } from './ToolCallRenderer'; + +export interface WriteEditState { + wrapperEl: HTMLElement; + contentEl: HTMLElement; + headerEl: HTMLElement; + nameEl: HTMLElement; + summaryEl: HTMLElement; + statsEl: HTMLElement; + statusEl: HTMLElement; + toolCall: ToolCallInfo; + isExpanded: boolean; + diffLines?: DiffLine[]; +} + +export interface WriteEditRenderOptions { + initiallyExpanded?: boolean; +} + +function shortenPath(filePath: string, maxLength = 40): string { + if (!filePath) return 'file'; + // Normalize path separators for cross-platform support + const normalized = filePath.replace(/\\/g, '/'); + if (normalized.length <= maxLength) return normalized; + + const parts = normalized.split('/'); + if (parts.length <= 2) { + return '...' + normalized.slice(-maxLength + 3); + } + + // Show first dir + ... + filename + const filename = parts[parts.length - 1]; + const firstDir = parts[0]; + const available = maxLength - firstDir.length - filename.length - 5; // 5 for ".../.../" + + if (available < 0) { + return '...' + filename.slice(-maxLength + 3); + } + + return `${firstDir}/.../${filename}`; +} + +export function createWriteEditBlock( + parentEl: HTMLElement, + toolCall: ToolCallInfo, + options: WriteEditRenderOptions = {} +): WriteEditState { + const filePath = (toolCall.input.file_path as string) || 'file'; + const toolName = toolCall.name; // 'Write' or 'Edit' + const baseAriaLabel = `${toolName}: ${shortenPath(filePath)}`; + + const wrapperEl = parentEl.createDiv({ cls: 'claudian-write-edit-block' }); + wrapperEl.dataset.toolId = toolCall.id; + + // Header (clickable to collapse/expand) + const headerEl = wrapperEl.createDiv({ cls: 'claudian-write-edit-header' }); + headerEl.setAttribute('tabindex', '0'); + headerEl.setAttribute('role', 'button'); + + // File icon + const iconEl = headerEl.createDiv({ cls: 'claudian-write-edit-icon' }); + iconEl.setAttribute('aria-hidden', 'true'); + setIcon(iconEl, getToolIcon(toolName)); + + const nameEl = headerEl.createDiv({ cls: 'claudian-write-edit-name' }); + nameEl.setText(toolName); + const summaryEl = headerEl.createDiv({ cls: 'claudian-write-edit-summary' }); + summaryEl.setText(fileNameOnly(filePath) || 'file'); + + // Populated when diff is computed + const statsEl = headerEl.createDiv({ cls: 'claudian-write-edit-stats' }); + + const statusEl = headerEl.createDiv({ cls: 'claudian-write-edit-status status-running' }); + statusEl.setAttribute('aria-label', 'Status: running'); + + // Content area (collapsed by default) + const contentEl = wrapperEl.createDiv({ cls: 'claudian-write-edit-content' }); + + // Initial loading state + const loadingRow = contentEl.createDiv({ cls: 'claudian-write-edit-diff-row' }); + const loadingEl = loadingRow.createDiv({ cls: 'claudian-write-edit-loading' }); + loadingEl.setText('Writing...'); + + // Create state object + const state: WriteEditState = { + wrapperEl, + contentEl, + headerEl, + nameEl, + summaryEl, + statsEl, + statusEl, + toolCall, + isExpanded: false, + }; + + // Setup collapsible behavior (handles click, keyboard, ARIA, CSS) + setupCollapsible(wrapperEl, headerEl, contentEl, state, { + initiallyExpanded: options.initiallyExpanded ?? false, + baseAriaLabel, + }); + + return state; +} + +export function updateWriteEditWithDiff(state: WriteEditState, diffData: ToolDiffData): void { + state.statsEl.empty(); + state.contentEl.empty(); + + const { diffLines, stats } = diffData; + state.diffLines = diffLines; + + // Update stats + renderDiffStats(state.statsEl, stats); + + // Render diff content + const row = state.contentEl.createDiv({ cls: 'claudian-write-edit-diff-row' }); + const diffEl = row.createDiv({ cls: 'claudian-write-edit-diff' }); + renderDiffContent(diffEl, diffLines); +} + +export function finalizeWriteEditBlock(state: WriteEditState, isError: boolean): void { + // Update status icon - only show icon on error + state.statusEl.className = 'claudian-write-edit-status'; + state.statusEl.empty(); + + if (isError) { + state.statusEl.addClass('status-error'); + setIcon(state.statusEl, 'x'); + state.statusEl.setAttribute('aria-label', 'Status: error'); + + // Show error in content if no diff was shown + if (!state.diffLines) { + state.contentEl.empty(); + const row = state.contentEl.createDiv({ cls: 'claudian-write-edit-diff-row' }); + const errorEl = row.createDiv({ cls: 'claudian-write-edit-error' }); + errorEl.setText(state.toolCall.result || 'Error'); + } + } else if (!state.diffLines) { + // Success but no diff data - clear the "Writing..." loading text and show DONE + state.contentEl.empty(); + const row = state.contentEl.createDiv({ cls: 'claudian-write-edit-diff-row' }); + const doneEl = row.createDiv({ cls: 'claudian-write-edit-done-text' }); + doneEl.setText('DONE'); + } + + // Update wrapper class + if (isError) { + state.wrapperEl.addClass('error'); + } else { + state.wrapperEl.addClass('done'); + } +} + +export function renderStoredWriteEdit( + parentEl: HTMLElement, + toolCall: ToolCallInfo, + options: WriteEditRenderOptions = {} +): HTMLElement { + const filePath = (toolCall.input.file_path as string) || 'file'; + const toolName = toolCall.name; + const baseAriaLabel = `${toolName}: ${shortenPath(filePath)}`; + const isError = toolCall.status === 'error' || toolCall.status === 'blocked'; + + const wrapperEl = parentEl.createDiv({ cls: 'claudian-write-edit-block' }); + if (isError) { + wrapperEl.addClass('error'); + } else if (toolCall.status === 'completed') { + wrapperEl.addClass('done'); + } + wrapperEl.dataset.toolId = toolCall.id; + + // Header + const headerEl = wrapperEl.createDiv({ cls: 'claudian-write-edit-header' }); + headerEl.setAttribute('tabindex', '0'); + headerEl.setAttribute('role', 'button'); + + // File icon + const iconEl = headerEl.createDiv({ cls: 'claudian-write-edit-icon' }); + iconEl.setAttribute('aria-hidden', 'true'); + setIcon(iconEl, getToolIcon(toolName)); + + const nameEl = headerEl.createDiv({ cls: 'claudian-write-edit-name' }); + nameEl.setText(toolName); + const summaryEl = headerEl.createDiv({ cls: 'claudian-write-edit-summary' }); + summaryEl.setText(fileNameOnly(filePath) || 'file'); + + const statsEl = headerEl.createDiv({ cls: 'claudian-write-edit-stats' }); + if (toolCall.diffData) { + renderDiffStats(statsEl, toolCall.diffData.stats); + } + + // Status indicator - only show icon on error + const statusEl = headerEl.createDiv({ cls: 'claudian-write-edit-status' }); + if (isError) { + statusEl.addClass('status-error'); + setIcon(statusEl, 'x'); + } + + // Content + const contentEl = wrapperEl.createDiv({ cls: 'claudian-write-edit-content' }); + + // Render diff if available + const row = contentEl.createDiv({ cls: 'claudian-write-edit-diff-row' }); + + if (toolCall.diffData && toolCall.diffData.diffLines.length > 0) { + const diffEl = row.createDiv({ cls: 'claudian-write-edit-diff' }); + renderDiffContent(diffEl, toolCall.diffData.diffLines); + } else if (isError && toolCall.result) { + const errorEl = row.createDiv({ cls: 'claudian-write-edit-error' }); + errorEl.setText(toolCall.result); + } else { + const doneEl = row.createDiv({ cls: 'claudian-write-edit-done-text' }); + doneEl.setText(isError ? 'ERROR' : 'DONE'); + } + + // Setup collapsible behavior (handles click, keyboard, ARIA, CSS) + const state = { isExpanded: false }; + setupCollapsible(wrapperEl, headerEl, contentEl, state, { + initiallyExpanded: options.initiallyExpanded ?? false, + baseAriaLabel, + }); + + return wrapperEl; +} diff --git a/src/features/chat/rendering/collapsible.ts b/src/features/chat/rendering/collapsible.ts new file mode 100644 index 0000000..a38ce42 --- /dev/null +++ b/src/features/chat/rendering/collapsible.ts @@ -0,0 +1,101 @@ +export interface CollapsibleState { + isExpanded: boolean; +} + +export interface CollapsibleOptions { + /** Initial expanded state (default: false) */ + initiallyExpanded?: boolean; + /** Callback when state changes */ + onToggle?: (isExpanded: boolean) => void; + /** Base label for aria-label (will append "click to expand/collapse") */ + baseAriaLabel?: string; +} + +/** + * Setup collapsible behavior on a header/content pair. + * + * Handles: + * - Click to toggle + * - Enter/Space keyboard navigation + * - aria-expanded attribute + * - CSS 'expanded' class on wrapper + * - content display style + * + * @param wrapperEl - The wrapper element to add/remove 'expanded' class + * @param headerEl - The clickable header element + * @param contentEl - The content element to show/hide + * @param state - State object to track isExpanded (mutated by this function) + * @param options - Optional configuration + */ +export function setupCollapsible( + wrapperEl: HTMLElement, + headerEl: HTMLElement, + contentEl: HTMLElement, + state: CollapsibleState, + options: CollapsibleOptions = {} +): void { + const { initiallyExpanded = false, onToggle, baseAriaLabel } = options; + + // Helper to update aria-label based on expanded state + const updateAriaLabel = (isExpanded: boolean) => { + if (baseAriaLabel) { + const action = isExpanded ? 'click to collapse' : 'click to expand'; + headerEl.setAttribute('aria-label', `${baseAriaLabel} - ${action}`); + } + }; + + // Set initial state + state.isExpanded = initiallyExpanded; + if (initiallyExpanded) { + wrapperEl.addClass('expanded'); + contentEl.removeClass('claudian-hidden'); + headerEl.setAttribute('aria-expanded', 'true'); + } else { + contentEl.addClass('claudian-hidden'); + headerEl.setAttribute('aria-expanded', 'false'); + } + updateAriaLabel(initiallyExpanded); + + // Toggle handler + const toggleExpand = () => { + state.isExpanded = !state.isExpanded; + if (state.isExpanded) { + wrapperEl.addClass('expanded'); + contentEl.removeClass('claudian-hidden'); + headerEl.setAttribute('aria-expanded', 'true'); + } else { + wrapperEl.removeClass('expanded'); + contentEl.addClass('claudian-hidden'); + headerEl.setAttribute('aria-expanded', 'false'); + } + updateAriaLabel(state.isExpanded); + onToggle?.(state.isExpanded); + }; + + // Click handler + headerEl.addEventListener('click', toggleExpand); + + // Keyboard handler (Enter/Space) + headerEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleExpand(); + } + }); +} + +/** + * Collapse a collapsible element and sync state. + * Use this when programmatically collapsing (e.g., on finalize). + */ +export function collapseElement( + wrapperEl: HTMLElement, + headerEl: HTMLElement, + contentEl: HTMLElement, + state: CollapsibleState +): void { + state.isExpanded = false; + wrapperEl.removeClass('expanded'); + contentEl.addClass('claudian-hidden'); + headerEl.setAttribute('aria-expanded', 'false'); +} diff --git a/src/features/chat/rendering/subagentLifecycleResolution.ts b/src/features/chat/rendering/subagentLifecycleResolution.ts new file mode 100644 index 0000000..2d70e01 --- /dev/null +++ b/src/features/chat/rendering/subagentLifecycleResolution.ts @@ -0,0 +1,25 @@ +import { ProviderRegistry } from '../../../core/providers/ProviderRegistry'; +import type { ProviderId, ProviderSubagentLifecycleAdapter } from '../../../core/providers/types'; + +/** + * Resolves the lifecycle adapter owned by the active provider. + */ +export function resolveSubagentLifecycleAdapter( + activeProviderId: ProviderId, + toolName?: string, +): ProviderSubagentLifecycleAdapter | null { + const activeAdapter = ProviderRegistry.getSubagentLifecycleAdapter(activeProviderId); + + if (!toolName) { + return activeAdapter; + } + + return activeAdapter && adapterOwnsTool(activeAdapter, toolName) ? activeAdapter : null; +} + +function adapterOwnsTool(adapter: ProviderSubagentLifecycleAdapter, toolName: string): boolean { + return adapter.isSpawnTool(toolName) + || adapter.isHiddenTool(toolName) + || adapter.isWaitTool(toolName) + || adapter.isCloseTool(toolName); +} diff --git a/src/features/chat/rendering/todoUtils.ts b/src/features/chat/rendering/todoUtils.ts new file mode 100644 index 0000000..38c2835 --- /dev/null +++ b/src/features/chat/rendering/todoUtils.ts @@ -0,0 +1,29 @@ +import { setIcon } from 'obsidian'; + +import type { TodoItem } from '../../../core/tools/todo'; + +export function getTodoStatusIcon(status: TodoItem['status']): string { + return status === 'completed' ? 'check' : 'dot'; +} + +export function getTodoDisplayText(todo: TodoItem): string { + return todo.status === 'in_progress' ? todo.activeForm : todo.content; +} + +export function renderTodoItems( + container: HTMLElement, + todos: TodoItem[] +): void { + container.empty(); + + for (const todo of todos) { + const item = container.createDiv({ cls: `claudian-todo-item claudian-todo-${todo.status}` }); + + const icon = item.createSpan({ cls: 'claudian-todo-status-icon' }); + icon.setAttribute('aria-hidden', 'true'); + setIcon(icon, getTodoStatusIcon(todo.status)); + + const text = item.createSpan({ cls: 'claudian-todo-text' }); + text.setText(getTodoDisplayText(todo)); + } +} diff --git a/src/features/chat/rewind.ts b/src/features/chat/rewind.ts new file mode 100644 index 0000000..b6006da --- /dev/null +++ b/src/features/chat/rewind.ts @@ -0,0 +1,31 @@ +import type { ChatMessage } from '../../core/types'; + +export interface RewindContext { + prevAssistantUuid: string | undefined; + hasResponse: boolean; +} + +/** + * Scans around a user message to find the previous assistant UUID (rewind target) + * and whether a response with a UUID follows it (proving the SDK processed it). + */ +export function findRewindContext(messages: ChatMessage[], userIndex: number): RewindContext { + let prevAssistantUuid: string | undefined; + for (let i = userIndex - 1; i >= 0; i--) { + if (messages[i].role === 'assistant' && messages[i].assistantMessageId) { + prevAssistantUuid = messages[i].assistantMessageId; + break; + } + } + + let hasResponse = false; + for (let i = userIndex + 1; i < messages.length; i++) { + if (messages[i].role === 'user') break; + if (messages[i].role === 'assistant' && messages[i].assistantMessageId) { + hasResponse = true; + break; + } + } + + return { prevAssistantUuid, hasResponse }; +} diff --git a/src/features/chat/services/BangBashService.ts b/src/features/chat/services/BangBashService.ts new file mode 100644 index 0000000..e518309 --- /dev/null +++ b/src/features/chat/services/BangBashService.ts @@ -0,0 +1,56 @@ +import { exec } from 'child_process'; + +export interface BangBashResult { + command: string; + stdout: string; + stderr: string; + exitCode: number; + error?: string; +} + +const TIMEOUT_MS = 30_000; +const MAX_BUFFER = 1024 * 1024; // 1MB + +export class BangBashService { + private cwd: string; + private enhancedPath: string; + + constructor(cwd: string, enhancedPath: string) { + this.cwd = cwd; + this.enhancedPath = enhancedPath; + } + + execute(command: string): Promise { + return new Promise((resolve) => { + exec(command, { + cwd: this.cwd, + env: { ...process.env, PATH: this.enhancedPath }, + timeout: TIMEOUT_MS, + maxBuffer: MAX_BUFFER, + shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash', + }, (error, stdout, stderr) => { + if (error && 'killed' in error && error.killed) { + // Node.js types declare code as number, but maxBuffer errors set it to a string at runtime + const isMaxBuffer = 'code' in error && (error.code as unknown) === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'; + resolve({ + command, + stdout: stdout ?? '', + stderr: stderr ?? '', + exitCode: 124, + error: isMaxBuffer + ? 'Output exceeded maximum buffer size (1MB)' + : `Command timed out after ${TIMEOUT_MS / 1000}s`, + }); + return; + } + + resolve({ + command, + stdout: stdout ?? '', + stderr: stderr ?? '', + exitCode: typeof error?.code === 'number' ? error.code : error ? 1 : 0, + }); + }); + }); + } +} diff --git a/src/features/chat/services/MentionCacheCoordinator.ts b/src/features/chat/services/MentionCacheCoordinator.ts new file mode 100644 index 0000000..e6e3cc3 --- /dev/null +++ b/src/features/chat/services/MentionCacheCoordinator.ts @@ -0,0 +1,26 @@ +export interface MentionCacheRegistration { + fileContextManager: { + markFileCacheDirty(): void; + markFolderCacheDirty(): void; + } | null; +} + +/** Owns vault-event invalidation for the existing per-tab mention caches. */ +export class MentionCacheCoordinator { + constructor( + private readonly getRegistrations: () => Iterable, + ) {} + + markStructureDirty(): void { + for (const registration of this.getRegistrations()) { + registration.fileContextManager?.markFileCacheDirty(); + registration.fileContextManager?.markFolderCacheDirty(); + } + } + + markFilesDirty(): void { + for (const registration of this.getRegistrations()) { + registration.fileContextManager?.markFileCacheDirty(); + } + } +} diff --git a/src/features/chat/services/SubagentManager.ts b/src/features/chat/services/SubagentManager.ts new file mode 100644 index 0000000..3d76fe1 --- /dev/null +++ b/src/features/chat/services/SubagentManager.ts @@ -0,0 +1,1107 @@ +import { existsSync, readFileSync, realpathSync } from 'fs'; +import { tmpdir } from 'os'; +import { isAbsolute, sep } from 'path'; + +import { ProviderRegistry } from '../../../core/providers/ProviderRegistry'; +import type { ProviderTaskResultInterpreter } from '../../../core/providers/types'; +import { TOOL_TASK } from '../../../core/tools/toolNames'; +import { extractToolResultContent } from '../../../core/tools/toolResultContent'; +import type { + SubagentInfo, + ToolCallInfo, +} from '../../../core/types'; +import { extractFinalResultFromSubagentJsonl } from '../../../utils/subagentJsonl'; +import { + addSubagentToolCall, + type AsyncSubagentState, + createAsyncSubagentBlock, + createSubagentBlock, + finalizeAsyncSubagent, + finalizeSubagentBlock, + markAsyncSubagentOrphaned, + type SubagentState, + updateAsyncSubagentRunning, + updateSubagentToolResult, +} from '../rendering/SubagentRenderer'; +import type { PendingToolCall } from '../state/types'; + +export type SubagentStateChangeCallback = (subagent: SubagentInfo) => void; + +export type HandleTaskResult = + | { action: 'buffered' } + | { action: 'created_sync'; subagentState: SubagentState } + | { action: 'created_async'; info: SubagentInfo; domState: AsyncSubagentState } + | { action: 'label_updated' }; + +export type RenderPendingResult = + | { mode: 'sync'; subagentState: SubagentState } + | { mode: 'async'; info: SubagentInfo; domState: AsyncSubagentState }; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function parseJsonRecord(value: string): Record | null { + try { + const parsed: unknown = JSON.parse(value); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function parseJsonValue(value: string): unknown { + try { + const parsed: unknown = JSON.parse(value); + return parsed; + } catch { + return null; + } +} + +export class SubagentManager { + private static readonly TRUSTED_OUTPUT_EXT = '.output'; + private static readonly TRUSTED_TMP_ROOTS = SubagentManager.resolveTrustedTmpRoots(); + + private syncSubagents: Map = new Map(); + private pendingTasks: Map = new Map(); + private _spawnedThisStream = 0; + + private activeAsyncSubagents: Map = new Map(); + private pendingAsyncSubagents: Map = new Map(); + private taskIdToAgentId: Map = new Map(); + private outputToolIdToAgentId: Map = new Map(); + private asyncDomStates: Map = new Map(); + + private onStateChange: SubagentStateChangeCallback; + private taskResultInterpreter: ProviderTaskResultInterpreter; + + constructor( + onStateChange: SubagentStateChangeCallback, + taskResultInterpreter: ProviderTaskResultInterpreter = ProviderRegistry.getTaskResultInterpreter(), + ) { + this.onStateChange = onStateChange; + this.taskResultInterpreter = taskResultInterpreter; + } + + public setCallback(callback: SubagentStateChangeCallback): void { + this.onStateChange = callback; + } + + public setTaskResultInterpreter(interpreter: ProviderTaskResultInterpreter): void { + this.taskResultInterpreter = interpreter; + } + + // ============================================ + // Unified Subagent Entry Point + // ============================================ + + /** + * Handles an Agent tool_use chunk with minimal buffering to determine sync vs async. + * Returns a typed result so StreamController can update messages accordingly. + */ + public handleTaskToolUse( + taskToolId: string, + taskInput: Record, + currentContentEl: HTMLElement | null + ): HandleTaskResult { + // Already rendered as sync → update label (no parentEl needed) + const existingSyncState = this.syncSubagents.get(taskToolId); + if (existingSyncState) { + this.updateSubagentLabel(existingSyncState.wrapperEl, existingSyncState.info, taskInput); + return { action: 'label_updated' }; + } + + // Already rendered as async → update label (no parentEl needed) + const existingAsyncState = this.asyncDomStates.get(taskToolId); + if (existingAsyncState) { + this.updateSubagentLabel(existingAsyncState.wrapperEl, existingAsyncState.info, taskInput); + // Sync to canonical SubagentInfo so status transitions don't revert updates + const canonical = this.getByTaskId(taskToolId); + if (canonical && canonical !== existingAsyncState.info) { + if (taskInput.description) canonical.description = taskInput.description as string; + if (taskInput.prompt) canonical.prompt = taskInput.prompt as string; + } + return { action: 'label_updated' }; + } + + // Already buffered → merge input and try to render + const pending = this.pendingTasks.get(taskToolId); + if (pending) { + const newInput = taskInput || {}; + if (Object.keys(newInput).length > 0) { + pending.toolCall.input = { ...pending.toolCall.input, ...newInput }; + } + if (currentContentEl) { + pending.parentEl = currentContentEl; + } + + // Do not lock mode before run_in_background is explicitly known. + // Sync fallback is handled when child chunks/tool_result confirm sync. + if (this.resolveTaskMode(pending.toolCall.input)) { + const result = this.renderPendingTask(taskToolId, currentContentEl); + if (result) { + return result.mode === 'sync' + ? { action: 'created_sync', subagentState: result.subagentState } + : { action: 'created_async', info: result.info, domState: result.domState }; + } + } + return { action: 'buffered' }; + } + + // New Task without a content element — buffer for later rendering + if (!currentContentEl) { + const toolCall: ToolCallInfo = { + id: taskToolId, + name: TOOL_TASK, + input: taskInput || {}, + status: 'running', + isExpanded: false, + }; + this.pendingTasks.set(taskToolId, { toolCall, parentEl: null }); + return { action: 'buffered' }; + } + + const mode = this.resolveTaskMode(taskInput); + if (!mode) { + const toolCall: ToolCallInfo = { + id: taskToolId, + name: TOOL_TASK, + input: taskInput || {}, + status: 'running', + isExpanded: false, + }; + this.pendingTasks.set(taskToolId, { toolCall, parentEl: currentContentEl }); + return { action: 'buffered' }; + } + + this._spawnedThisStream++; + if (mode === 'async') { + return this.createAsyncTask(taskToolId, taskInput, currentContentEl); + } + return this.createSyncTask(taskToolId, taskInput, currentContentEl); + } + + // ============================================ + // Pending Task Resolution + // ============================================ + + public hasPendingTask(toolId: string): boolean { + return this.pendingTasks.has(toolId); + } + + /** + * Renders a buffered pending task. Called when a child chunk or tool_result + * confirms the task is sync, or when run_in_background becomes known. + * Uses the optional parentEl override, falling back to the stored parentEl. + */ + public renderPendingTask( + toolId: string, + parentElOverride?: HTMLElement | null + ): RenderPendingResult | null { + const pending = this.pendingTasks.get(toolId); + if (!pending) return null; + + const input = pending.toolCall.input; + const targetEl = parentElOverride ?? pending.parentEl; + if (!targetEl) return null; + + this.pendingTasks.delete(toolId); + + try { + if (input.run_in_background === true) { + const result = this.createAsyncTask(pending.toolCall.id, input, targetEl); + if (result.action === 'created_async') { + this._spawnedThisStream++; + return { mode: 'async', info: result.info, domState: result.domState }; + } + } else { + const result = this.createSyncTask(pending.toolCall.id, input, targetEl); + if (result.action === 'created_sync') { + this._spawnedThisStream++; + return { mode: 'sync', subagentState: result.subagentState }; + } + } + } catch { + // Non-fatal: task appears incomplete but doesn't crash the stream + } + + return null; + } + + /** + * Resolves a pending Task when its own tool_result arrives. + * If mode is still unknown, infer async from task result shape (agent_id/agentId), + * otherwise fall back to sync so it never remains pending indefinitely. + */ + public renderPendingTaskFromTaskResult( + toolId: string, + taskResult: unknown, + isError: boolean, + parentElOverride?: HTMLElement | null, + taskToolUseResult?: unknown + ): RenderPendingResult | null { + const pending = this.pendingTasks.get(toolId); + if (!pending) return null; + + const input = pending.toolCall.input; + const targetEl = parentElOverride ?? pending.parentEl; + if (!targetEl) return null; + + const explicitMode = this.resolveTaskMode(input); + const taskResultText = extractToolResultContent(taskResult, { fallbackIndent: 2 }); + const inferredMode = explicitMode + ?? this.inferModeFromTaskResult(taskResultText, isError, taskToolUseResult); + + this.pendingTasks.delete(toolId); + + try { + if (inferredMode === 'async') { + const result = this.createAsyncTask(pending.toolCall.id, input, targetEl); + if (result.action === 'created_async') { + this._spawnedThisStream++; + return { mode: 'async', info: result.info, domState: result.domState }; + } + } else { + const result = this.createSyncTask(pending.toolCall.id, input, targetEl); + if (result.action === 'created_sync') { + this._spawnedThisStream++; + return { mode: 'sync', subagentState: result.subagentState }; + } + } + } catch { + // Non-fatal: task appears incomplete but doesn't crash the stream + } + + return null; + } + + // ============================================ + // Sync Subagent Operations + // ============================================ + + public getSyncSubagent(toolId: string): SubagentState | undefined { + return this.syncSubagents.get(toolId); + } + + public addSyncToolCall(parentToolUseId: string, toolCall: ToolCallInfo): void { + const subagentState = this.syncSubagents.get(parentToolUseId); + if (!subagentState) return; + addSubagentToolCall(subagentState, toolCall); + } + + public updateSyncToolResult( + parentToolUseId: string, + toolId: string, + toolCall: ToolCallInfo + ): void { + const subagentState = this.syncSubagents.get(parentToolUseId); + if (!subagentState) return; + updateSubagentToolResult(subagentState, toolId, toolCall); + } + + public finalizeSyncSubagent( + toolId: string, + result: unknown, + isError: boolean, + toolUseResult?: unknown + ): SubagentInfo | null { + const subagentState = this.syncSubagents.get(toolId); + if (!subagentState) return null; + + const resultText = extractToolResultContent(result, { fallbackIndent: 2 }); + const extractedResult = this.extractAgentResult(resultText, '', toolUseResult); + finalizeSubagentBlock(subagentState, extractedResult, isError); + this.syncSubagents.delete(toolId); + + return subagentState.info; + } + + // ============================================ + // Async Subagent Lifecycle + // ============================================ + + public handleTaskToolResult( + taskToolId: string, + result: unknown, + isError?: boolean, + toolUseResult?: unknown + ): void { + const subagent = this.pendingAsyncSubagents.get(taskToolId); + if (!subagent) return; + const resultText = extractToolResultContent(result, { fallbackIndent: 2 }); + + if (isError) { + this.transitionToError(subagent, taskToolId, resultText || 'Task failed to start'); + return; + } + + const agentId = this.taskResultInterpreter.extractAgentId(toolUseResult) ?? this.parseAgentId(resultText); + + if (!agentId) { + const truncatedResult = resultText.length > 100 ? resultText.substring(0, 100) + '...' : resultText; + this.transitionToError(subagent, taskToolId, `Failed to parse agent_id. Result: ${truncatedResult}`); + return; + } + + subagent.asyncStatus = 'running'; + subagent.agentId = agentId; + subagent.startedAt = Date.now(); + + this.pendingAsyncSubagents.delete(taskToolId); + this.activeAsyncSubagents.set(agentId, subagent); + this.taskIdToAgentId.set(taskToolId, agentId); + + this.updateAsyncDomState(subagent); + this.onStateChange(subagent); + } + + public handleAgentOutputToolUse(toolCall: ToolCallInfo): void { + const agentId = this.extractAgentIdFromInput(toolCall.input); + if (!agentId) return; + + const subagent = this.activeAsyncSubagents.get(agentId); + if (!subagent) return; + + subagent.outputToolId = toolCall.id; + this.outputToolIdToAgentId.set(toolCall.id, agentId); + } + + public handleAgentOutputToolResult( + toolId: string, + result: unknown, + isError: boolean, + toolUseResult?: unknown + ): SubagentInfo | undefined { + const resultText = extractToolResultContent(result, { fallbackIndent: 2 }); + let agentId = this.outputToolIdToAgentId.get(toolId); + let subagent = agentId ? this.activeAsyncSubagents.get(agentId) : undefined; + + if (!subagent) { + const inferredAgentId = this.inferAgentIdFromResult(resultText); + if (inferredAgentId) { + agentId = inferredAgentId; + subagent = this.activeAsyncSubagents.get(inferredAgentId); + } + } + + if (!subagent) return undefined; + + if (agentId) { + subagent.agentId = subagent.agentId || agentId; + this.outputToolIdToAgentId.set(toolId, agentId); + } + + if (subagent.asyncStatus !== 'running') { + return undefined; + } + + const stillRunning = this.isStillRunningResult(resultText, isError); + if (stillRunning) { + this.outputToolIdToAgentId.delete(toolId); + return subagent; + } + + const extractedResult = this.extractAgentResult(resultText, agentId ?? '', toolUseResult); + + // The chunk's is_error flag can be unreliable for async subagent results + // (SDK may set is_error on the content block even when the agent succeeded). + // Prefer the structured toolUseResult to determine actual error status. + const finalStatus = this.taskResultInterpreter.resolveTerminalStatus( + toolUseResult, + isError ? 'error' : 'completed', + ); + + subagent.asyncStatus = finalStatus; + subagent.status = finalStatus; + subagent.result = extractedResult; + subagent.completedAt = Date.now(); + + if (agentId) this.activeAsyncSubagents.delete(agentId); + this.outputToolIdToAgentId.delete(toolId); + + this.updateAsyncDomState(subagent); + this.onStateChange(subagent); + return subagent; + } + + public handleAsyncSubagentResult( + agentId: string, + status: 'completed' | 'error', + result?: string + ): SubagentInfo | undefined { + const subagent = this.activeAsyncSubagents.get(agentId); + if (!subagent || subagent.asyncStatus !== 'running') { + return undefined; + } + + subagent.agentId = subagent.agentId || agentId; + subagent.asyncStatus = status; + subagent.status = status; + subagent.result = result?.trim() || (status === 'error' ? 'Background task failed.' : 'Background task completed.'); + subagent.completedAt = Date.now(); + + this.activeAsyncSubagents.delete(agentId); + for (const [toolId, mappedAgentId] of this.outputToolIdToAgentId.entries()) { + if (mappedAgentId === agentId) { + this.outputToolIdToAgentId.delete(toolId); + } + } + + this.updateAsyncDomState(subagent); + this.onStateChange(subagent); + return subagent; + } + + public isPendingAsyncTask(taskToolId: string): boolean { + return this.pendingAsyncSubagents.has(taskToolId); + } + + public isLinkedAgentOutputTool(toolId: string): boolean { + return this.outputToolIdToAgentId.has(toolId); + } + + public getByTaskId(taskToolId: string): SubagentInfo | undefined { + const pending = this.pendingAsyncSubagents.get(taskToolId); + if (pending) return pending; + + const agentId = this.taskIdToAgentId.get(taskToolId); + if (agentId) { + return this.activeAsyncSubagents.get(agentId); + } + + return undefined; + } + + /** + * Re-renders an async subagent after data-only updates (for example, + * hydrating tool calls from SDK sidecar files) without changing lifecycle state. + */ + public refreshAsyncSubagent(subagent: SubagentInfo): void { + this.updateAsyncDomState(subagent); + this.onStateChange(subagent); + } + + // ============================================ + // Hook State + // ============================================ + + public hasRunningSubagents(): boolean { + // pendingAsyncSubagents: awaiting agent_id; activeAsyncSubagents: only holds running entries + return this.pendingAsyncSubagents.size > 0 || this.activeAsyncSubagents.size > 0; + } + + // ============================================ + // Lifecycle + // ============================================ + + public get subagentsSpawnedThisStream(): number { + return this._spawnedThisStream; + } + + public resetSpawnedCount(): void { + this._spawnedThisStream = 0; + } + + public resetStreamingState(): void { + this.syncSubagents.clear(); + this.pendingTasks.clear(); + } + + public orphanAllActive(): SubagentInfo[] { + const orphaned: SubagentInfo[] = []; + + for (const subagent of this.pendingAsyncSubagents.values()) { + this.markOrphaned(subagent); + orphaned.push(subagent); + } + + for (const subagent of this.activeAsyncSubagents.values()) { + if (subagent.asyncStatus === 'running') { + this.markOrphaned(subagent); + orphaned.push(subagent); + } + } + + this.pendingAsyncSubagents.clear(); + this.activeAsyncSubagents.clear(); + this.taskIdToAgentId.clear(); + this.outputToolIdToAgentId.clear(); + + return orphaned; + } + + public clear(): void { + this.syncSubagents.clear(); + this.pendingTasks.clear(); + this.pendingAsyncSubagents.clear(); + this.activeAsyncSubagents.clear(); + this.taskIdToAgentId.clear(); + this.outputToolIdToAgentId.clear(); + this.asyncDomStates.clear(); + } + + // ============================================ + // Private: State Transitions + // ============================================ + + private markOrphaned(subagent: SubagentInfo): void { + subagent.asyncStatus = 'orphaned'; + subagent.status = 'error'; + subagent.result = 'Conversation ended before task completed'; + subagent.completedAt = Date.now(); + this.updateAsyncDomState(subagent); + this.onStateChange(subagent); + } + + private transitionToError(subagent: SubagentInfo, taskToolId: string, errorResult: string): void { + subagent.asyncStatus = 'error'; + subagent.status = 'error'; + subagent.result = errorResult; + subagent.completedAt = Date.now(); + this.pendingAsyncSubagents.delete(taskToolId); + this.updateAsyncDomState(subagent); + this.onStateChange(subagent); + } + + // ============================================ + // Private: Task Creation + // ============================================ + + private createSyncTask( + taskToolId: string, + taskInput: Record, + parentEl: HTMLElement + ): HandleTaskResult { + const subagentState = createSubagentBlock(parentEl, taskToolId, taskInput); + this.syncSubagents.set(taskToolId, subagentState); + return { action: 'created_sync', subagentState }; + } + + private createAsyncTask( + taskToolId: string, + taskInput: Record, + parentEl: HTMLElement + ): HandleTaskResult { + const description = (taskInput.description as string) || 'Background task'; + const prompt = (taskInput.prompt as string) || ''; + + const info: SubagentInfo = { + id: taskToolId, + description, + prompt, + mode: 'async', + isExpanded: false, + status: 'running', + toolCalls: [], + asyncStatus: 'pending', + }; + + this.pendingAsyncSubagents.set(taskToolId, info); + + const domState = createAsyncSubagentBlock(parentEl, taskToolId, taskInput); + this.asyncDomStates.set(taskToolId, domState); + + return { action: 'created_async', info, domState }; + } + + // ============================================ + // Private: Label Update + // ============================================ + + private updateSubagentLabel( + wrapperEl: HTMLElement, + info: SubagentInfo, + newInput: Record + ): void { + if (!newInput || Object.keys(newInput).length === 0) return; + const description = (newInput.description as string) || ''; + if (description) { + info.description = description; + const labelEl = wrapperEl.querySelector('.claudian-subagent-label'); + if (labelEl) { + const truncated = description.length > 40 ? description.substring(0, 40) + '...' : description; + labelEl.setText(truncated); + } + } + const prompt = (newInput.prompt as string) || ''; + if (prompt) { + info.prompt = prompt; + const promptEl = wrapperEl.querySelector('.claudian-subagent-prompt-text'); + if (promptEl) { + promptEl.setText(prompt); + } + } + } + + private resolveTaskMode(taskInput: Record): 'sync' | 'async' | null { + if (!Object.prototype.hasOwnProperty.call(taskInput, 'run_in_background')) { + return null; + } + if (taskInput.run_in_background === true) { + return 'async'; + } + if (taskInput.run_in_background === false) { + return 'sync'; + } + return null; + } + + private inferModeFromTaskResult( + taskResult: string, + isError: boolean, + taskToolUseResult?: unknown + ): 'sync' | 'async' { + if (isError) { + return 'sync'; + } + if (this.taskResultInterpreter.hasAsyncLaunchMarker(taskToolUseResult)) { + return 'async'; + } + // Only promote to async for launch-shaped payloads. Completed sync results + // can still contain agent metadata in the payload or final output text. + return this.parseAgentIdStrict(taskResult) ? 'async' : 'sync'; + } + + private parseAgentIdStrict(result: string): string | null { + const payload = this.unwrapTextPayload(result).trim(); + if (!payload) { + return null; + } + + const parsed = parseJsonRecord(payload); + if (parsed) { + if (this.hasTerminalTaskStatus(parsed)) { + return null; + } + + const directAgentId = this.extractAgentIdFromRecord(parsed); + if (directAgentId) { + return directAgentId; + } + + const taskRecord = parsed.task; + if (isRecord(taskRecord)) { + return this.extractAgentIdFromRecord(taskRecord); + } + } + + const xmlStatus = this.taskResultInterpreter.extractTagValue(payload, 'retrieval_status') + ?? this.taskResultInterpreter.extractTagValue(payload, 'status'); + if (this.isTerminalTaskStatusValue(xmlStatus)) { + return null; + } + + const exactLineMatch = payload.match(/^\s*(?:agent_id|agentId)\s*[=:]\s*"?([a-zA-Z0-9_-]+)"?\s*$/i); + return exactLineMatch?.[1] ?? null; + } + + private hasTerminalTaskStatus(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false; + } + + const record = value as Record; + const rawStatus = record.retrieval_status ?? record.status; + return this.isTerminalTaskStatusValue(rawStatus); + } + + private isTerminalTaskStatusValue(rawStatus: unknown): boolean { + if (typeof rawStatus !== 'string') { + return false; + } + + const normalized = rawStatus.toLowerCase(); + return normalized === 'completed' || normalized === 'success' || normalized === 'error'; + } + + private extractAgentIdFromRecord(record: Record): string | null { + const direct = record.agent_id ?? record.agentId; + if (typeof direct === 'string' && direct.length > 0) { + return direct; + } + + const data = record.data; + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return null; + } + + const nested = (data as Record).agent_id ?? (data as Record).agentId; + return typeof nested === 'string' && nested.length > 0 ? nested : null; + } + + private extractAgentIdFromString(value: string): string | null { + const regexPatterns = [ + /"agent_id"\s*:\s*"([^"]+)"/, + /"agentId"\s*:\s*"([^"]+)"/, + /agent_id[=:]\s*"?([a-zA-Z0-9_-]+)"?/i, + /agentId[=:]\s*"?([a-zA-Z0-9_-]+)"?/i, + ]; + + for (const pattern of regexPatterns) { + const match = value.match(pattern); + if (match && match[1]) { + return match[1]; + } + } + + return null; + } + + // ============================================ + // Private: Async DOM State Updates + // ============================================ + + private updateAsyncDomState(subagent: SubagentInfo): void { + // Find DOM state by task ID first, then by agentId + let asyncState = this.asyncDomStates.get(subagent.id); + + if (!asyncState) { + for (const s of this.asyncDomStates.values()) { + if (s.info.agentId === subagent.agentId) { + asyncState = s; + break; + } + } + if (!asyncState) return; + } + + asyncState.info = subagent; + + switch (subagent.asyncStatus) { + case 'running': + updateAsyncSubagentRunning(asyncState, subagent.agentId || ''); + break; + + case 'completed': + case 'error': + finalizeAsyncSubagent(asyncState, subagent.result || '', subagent.asyncStatus === 'error'); + break; + + case 'orphaned': + markAsyncSubagentOrphaned(asyncState); + break; + } + } + + // ============================================ + // Private: Async Parsing Logic + // ============================================ + + private isStillRunningResult(result: string, isError: boolean): boolean { + const trimmed = result?.trim() || ''; + const payload = this.unwrapTextPayload(trimmed); + + if (isError) return false; + if (!trimmed) return false; + + const parsed = parseJsonRecord(payload); + if (parsed) { + const status = parsed.retrieval_status ?? parsed.status; + const agents = isRecord(parsed.agents) ? parsed.agents : null; + const hasAgents = agents !== null && Object.keys(agents).length > 0; + + if (status === 'not_ready' || status === 'running' || status === 'pending') { + return true; + } + + if (hasAgents && agents) { + const agentStatuses = Object.values(agents) + .map((agent) => (isRecord(agent) && typeof agent.status === 'string') ? agent.status.toLowerCase() : ''); + const anyRunning = agentStatuses.some(s => + s === 'running' || s === 'pending' || s === 'not_ready' + ); + if (anyRunning) return true; + return false; + } + + if (status === 'success' || status === 'completed') { + return false; + } + + return false; + } + + const lowerResult = payload.toLowerCase(); + if (lowerResult.includes('not_ready') || lowerResult.includes('not ready')) { + return true; + } + + const xmlStatusMatch = lowerResult.match(/([^<]+)<\/status>/); + if (xmlStatusMatch) { + const status = xmlStatusMatch[1].trim(); + if (status === 'running' || status === 'pending' || status === 'not_ready') { + return true; + } + } + + return false; + } + + private extractAgentResult(result: string, agentId: string, toolUseResult?: unknown): string { + const structuredResult = this.taskResultInterpreter.extractStructuredResult(toolUseResult); + const normalizedStructuredResult = this.extractResultFromCandidateString(structuredResult); + if (normalizedStructuredResult) { + return normalizedStructuredResult; + } + if (structuredResult) { + return structuredResult; + } + + const payload = this.unwrapTextPayload(result); + + const parsed = parseJsonRecord(payload); + if (parsed) { + const taskResult = this.extractResultFromTaskObject(parsed.task); + if (taskResult) { + return taskResult; + } + + const agents = isRecord(parsed.agents) ? parsed.agents : null; + const agentData = agents && agentId ? agents[agentId] : null; + if (isRecord(agentData)) { + const parsedResult = this.extractResultFromCandidateString(agentData.result); + if (parsedResult) { + return parsedResult; + } + const parsedOutput = this.extractResultFromCandidateString(agentData.output); + if (parsedOutput) { + return parsedOutput; + } + return JSON.stringify(agentData, null, 2); + } + + if (agents) { + const agentIds = Object.keys(agents); + if (agentIds.length > 0) { + const firstAgent = agents[agentIds[0]]; + if (isRecord(firstAgent)) { + const parsedResult = this.extractResultFromCandidateString(firstAgent.result); + if (parsedResult) { + return parsedResult; + } + const parsedOutput = this.extractResultFromCandidateString(firstAgent.output); + if (parsedOutput) { + return parsedOutput; + } + } + return JSON.stringify(firstAgent, null, 2); + } + } + + const parsedResult = this.extractResultFromCandidateString(parsed.result); + if (parsedResult) { + return parsedResult; + } + + const parsedOutput = this.extractResultFromCandidateString(parsed.output); + if (parsedOutput) { + return parsedOutput; + } + } + + const taggedResult = this.extractResultFromTaggedPayload(payload); + if (taggedResult) { + return taggedResult; + } + + return payload; + } + + private extractResultFromTaskObject(task: unknown): string | null { + if (!task || typeof task !== 'object') { + return null; + } + const taskRecord = task as Record; + return this.extractResultFromCandidateString(taskRecord.result) + ?? this.extractResultFromCandidateString(taskRecord.output); + } + + private extractResultFromCandidateString(candidate: unknown): string | null { + if (typeof candidate !== 'string') { + return null; + } + + const trimmed = candidate.trim(); + if (!trimmed) { + return null; + } + + const taggedResult = this.extractResultFromTaggedPayload(trimmed); + if (taggedResult) { + return taggedResult; + } + + const jsonlResult = this.extractResultFromOutputJsonl(trimmed); + if (jsonlResult) { + return jsonlResult; + } + + return trimmed; + } + + private parseAgentId(result: string): string | null { + const regexPatterns = [ + /"agent_id"\s*:\s*"([^"]+)"/, + /"agentId"\s*:\s*"([^"]+)"/, + /agent_id[=:]\s*"?([a-zA-Z0-9_-]+)"?/i, + /agentId[=:]\s*"?([a-zA-Z0-9_-]+)"?/i, + /\b([a-f0-9]{8})\b/, + ]; + + for (const pattern of regexPatterns) { + const match = result.match(pattern); + if (match && match[1]) { + return match[1]; + } + } + + const parsed = parseJsonRecord(result); + if (parsed) { + const agentId = parsed.agent_id || parsed.agentId; + + if (typeof agentId === 'string' && agentId.length > 0) { + return agentId; + } + + const data = parsed.data; + if (isRecord(data) && typeof data.agent_id === 'string') { + return data.agent_id; + } + + if (parsed.id && typeof parsed.id === 'string') { + return parsed.id; + } + } + + return null; + } + + private inferAgentIdFromResult(result: string): string | null { + const parsed = parseJsonRecord(result); + if (parsed) { + const agents = isRecord(parsed.agents) ? parsed.agents : null; + if (agents) { + return Object.keys(agents)[0] ?? null; + } + } + return null; + } + + private unwrapTextPayload(raw: string): string { + const parsed = parseJsonValue(raw); + if (parsed !== null) { + if (Array.isArray(parsed)) { + const textBlock = (parsed as unknown[]).find((block) => isRecord(block) && typeof block.text === 'string'); + if (isRecord(textBlock) && typeof textBlock.text === 'string') return textBlock.text; + } else if (isRecord(parsed) && typeof parsed.text === 'string') { + return parsed.text; + } + } + return raw; + } + + private extractResultFromTaggedPayload(payload: string): string | null { + const directResult = this.taskResultInterpreter.extractTagValue(payload, 'result'); + if (directResult) return directResult; + + const outputContent = this.taskResultInterpreter.extractTagValue(payload, 'output'); + if (!outputContent) return null; + + const extractedFromJsonl = this.extractResultFromOutputJsonl(outputContent); + if (extractedFromJsonl) return extractedFromJsonl; + + const nestedResult = this.taskResultInterpreter.extractTagValue(outputContent, 'result'); + if (nestedResult) return nestedResult; + + const trimmed = outputContent.trim(); + return trimmed.length > 0 ? trimmed : null; + } + + private extractResultFromOutputJsonl(outputContent: string): string | null { + const inlineResult = extractFinalResultFromSubagentJsonl(outputContent); + if (inlineResult) { + return inlineResult; + } + + const fullOutputPath = this.extractFullOutputPath(outputContent); + if (!fullOutputPath) { + return null; + } + + const fullOutput = this.readFullOutputFile(fullOutputPath); + if (!fullOutput) { + return null; + } + + return extractFinalResultFromSubagentJsonl(fullOutput); + } + + private extractFullOutputPath(content: string): string | null { + const truncatedPattern = /\[Truncated\.\s*Full output:\s*([^\]\n]+)\]/i; + const match = content.match(truncatedPattern); + if (!match || !match[1]) { + return null; + } + + const outputPath = match[1].trim(); + return outputPath.length > 0 ? outputPath : null; + } + + private readFullOutputFile(fullOutputPath: string): string | null { + try { + if (!this.isTrustedOutputPath(fullOutputPath)) { + return null; + } + + if (!existsSync(fullOutputPath)) { + return null; + } + + const fileContent = readFileSync(fullOutputPath, 'utf-8'); + const trimmed = fileContent.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } + } + + private extractAgentIdFromInput(input: Record): string | null { + const agentId = (input.task_id as string) || (input.agentId as string) || (input.agent_id as string); + return agentId || null; + } + + private static resolveTrustedTmpRoots(): string[] { + const roots = new Set(); + const candidates = [tmpdir(), '/tmp', '/private/tmp']; + for (const candidate of candidates) { + try { + roots.add(realpathSync(candidate)); + } catch { + // Ignore unavailable temp roots. + } + } + return Array.from(roots); + } + + private isTrustedOutputPath(fullOutputPath: string): boolean { + if (!isAbsolute(fullOutputPath)) { + return false; + } + + if (!fullOutputPath.toLowerCase().endsWith(SubagentManager.TRUSTED_OUTPUT_EXT)) { + return false; + } + + let resolvedPath: string; + try { + resolvedPath = realpathSync(fullOutputPath); + } catch { + return false; + } + + return SubagentManager.TRUSTED_TMP_ROOTS.some((root) => + resolvedPath === root || resolvedPath.startsWith(`${root}${sep}`) + ); + } +} diff --git a/src/features/chat/state/ChatState.ts b/src/features/chat/state/ChatState.ts new file mode 100644 index 0000000..0d15587 --- /dev/null +++ b/src/features/chat/state/ChatState.ts @@ -0,0 +1,436 @@ +import type { UsageInfo } from '../../../core/types'; +import type { + ChatMessage, + ChatStateCallbacks, + ChatStateData, + PendingToolCall, + QueuedMessage, + ThinkingBlockState, + TodoItem, + WriteEditState, +} from './types'; + +function createInitialState(): ChatStateData { + return { + messages: [], + isStreaming: false, + cancelRequested: false, + streamGeneration: 0, + isCreatingConversation: false, + isSwitchingConversation: false, + hasPendingConversationSave: false, + currentConversationId: null, + queuedMessage: null, + currentContentEl: null, + currentTextEl: null, + currentTextContent: '', + currentThinkingState: null, + thinkingEl: null, + queueIndicatorEl: null, + thinkingIndicatorTimeout: null, + toolCallElements: new Map(), + writeEditStates: new Map(), + pendingTools: new Map(), + usage: null, + ignoreUsageUpdates: false, + currentTodos: null, + needsAttention: false, + autoScrollEnabled: true, // Default; controllers will override based on settings + responseStartTime: null, + flavorTimerInterval: null, + pendingNewSessionPlan: null, + planFilePath: null, + prePlanPermissionMode: null, + }; +} + +export class ChatState { + private state: ChatStateData; + private _callbacks: ChatStateCallbacks; + private thinkingIndicatorTimeoutWindow: Window | null = null; + private flavorTimerIntervalWindow: Window | null = null; + + constructor(callbacks: ChatStateCallbacks = {}) { + this.state = createInitialState(); + this._callbacks = callbacks; + } + + get callbacks(): ChatStateCallbacks { + return this._callbacks; + } + + set callbacks(value: ChatStateCallbacks) { + this._callbacks = value; + } + + // ============================================ + // Messages + // ============================================ + + get messages(): ChatMessage[] { + return [...this.state.messages]; + } + + set messages(value: ChatMessage[]) { + this.state.messages = value; + this._callbacks.onMessagesChanged?.(); + } + + addMessage(msg: ChatMessage): void { + this.state.messages.push(msg); + this._callbacks.onMessagesChanged?.(); + } + + clearMessages(): void { + this.state.messages = []; + this._callbacks.onMessagesChanged?.(); + } + + truncateAt(messageId: string): number { + const idx = this.state.messages.findIndex(m => m.id === messageId); + if (idx === -1) return 0; + const removed = this.state.messages.length - idx; + this.state.messages = this.state.messages.slice(0, idx); + this._callbacks.onMessagesChanged?.(); + return removed; + } + + // ============================================ + // Streaming Control + // ============================================ + + get isStreaming(): boolean { + return this.state.isStreaming; + } + + set isStreaming(value: boolean) { + this.state.isStreaming = value; + this._callbacks.onStreamingStateChanged?.(value); + } + + get cancelRequested(): boolean { + return this.state.cancelRequested; + } + + set cancelRequested(value: boolean) { + this.state.cancelRequested = value; + } + + get streamGeneration(): number { + return this.state.streamGeneration; + } + + bumpStreamGeneration(): number { + this.state.streamGeneration += 1; + return this.state.streamGeneration; + } + + get isCreatingConversation(): boolean { + return this.state.isCreatingConversation; + } + + set isCreatingConversation(value: boolean) { + this.state.isCreatingConversation = value; + } + + get isSwitchingConversation(): boolean { + return this.state.isSwitchingConversation; + } + + set isSwitchingConversation(value: boolean) { + this.state.isSwitchingConversation = value; + } + + get hasPendingConversationSave(): boolean { + return this.state.hasPendingConversationSave; + } + + set hasPendingConversationSave(value: boolean) { + this.state.hasPendingConversationSave = value; + } + + // ============================================ + // Conversation + // ============================================ + + get currentConversationId(): string | null { + return this.state.currentConversationId; + } + + set currentConversationId(value: string | null) { + this.state.currentConversationId = value; + this._callbacks.onConversationChanged?.(value); + } + + // ============================================ + // Queued Message + // ============================================ + + get queuedMessage(): QueuedMessage | null { + return this.state.queuedMessage; + } + + set queuedMessage(value: QueuedMessage | null) { + this.state.queuedMessage = value; + } + + // ============================================ + // Streaming DOM State + // ============================================ + + get currentContentEl(): HTMLElement | null { + return this.state.currentContentEl; + } + + set currentContentEl(value: HTMLElement | null) { + this.state.currentContentEl = value; + } + + get currentTextEl(): HTMLElement | null { + return this.state.currentTextEl; + } + + set currentTextEl(value: HTMLElement | null) { + this.state.currentTextEl = value; + } + + get currentTextContent(): string { + return this.state.currentTextContent; + } + + set currentTextContent(value: string) { + this.state.currentTextContent = value; + } + + get currentThinkingState(): ThinkingBlockState | null { + return this.state.currentThinkingState; + } + + set currentThinkingState(value: ThinkingBlockState | null) { + this.state.currentThinkingState = value; + } + + get thinkingEl(): HTMLElement | null { + return this.state.thinkingEl; + } + + set thinkingEl(value: HTMLElement | null) { + this.state.thinkingEl = value; + } + + get queueIndicatorEl(): HTMLElement | null { + return this.state.queueIndicatorEl; + } + + set queueIndicatorEl(value: HTMLElement | null) { + this.state.queueIndicatorEl = value; + } + + get thinkingIndicatorTimeout(): number | null { + return this.state.thinkingIndicatorTimeout; + } + + set thinkingIndicatorTimeout(value: number | null) { + this.state.thinkingIndicatorTimeout = value; + this.thinkingIndicatorTimeoutWindow = value === null ? null : this.getDefaultTimerWindow(); + } + + // ============================================ + // Tool Tracking Maps (mutable references) + // ============================================ + + get toolCallElements(): Map { + return this.state.toolCallElements; + } + + get writeEditStates(): Map { + return this.state.writeEditStates; + } + + get pendingTools(): Map { + return this.state.pendingTools; + } + + // ============================================ + // Usage State + // ============================================ + + get usage(): UsageInfo | null { + return this.state.usage; + } + + set usage(value: UsageInfo | null) { + this.state.usage = value; + this._callbacks.onUsageChanged?.(value); + } + + get ignoreUsageUpdates(): boolean { + return this.state.ignoreUsageUpdates; + } + + set ignoreUsageUpdates(value: boolean) { + this.state.ignoreUsageUpdates = value; + } + + // ============================================ + // Current Todos (for persistent bottom panel) + // ============================================ + + get currentTodos(): TodoItem[] | null { + return this.state.currentTodos ? [...this.state.currentTodos] : null; + } + + set currentTodos(value: TodoItem[] | null) { + // Normalize empty arrays to null for consistency + const normalizedValue = (value && value.length > 0) ? value : null; + this.state.currentTodos = normalizedValue; + this._callbacks.onTodosChanged?.(normalizedValue); + } + + // ============================================ + // Attention State (approval pending, error, etc.) + // ============================================ + + get needsAttention(): boolean { + return this.state.needsAttention; + } + + set needsAttention(value: boolean) { + this.state.needsAttention = value; + this._callbacks.onAttentionChanged?.(value); + } + + // ============================================ + // Auto-Scroll Control + // ============================================ + + get autoScrollEnabled(): boolean { + return this.state.autoScrollEnabled; + } + + set autoScrollEnabled(value: boolean) { + const changed = this.state.autoScrollEnabled !== value; + this.state.autoScrollEnabled = value; + if (changed) { + this._callbacks.onAutoScrollChanged?.(value); + } + } + + // ============================================ + // Response Timer State + // ============================================ + + get responseStartTime(): number | null { + return this.state.responseStartTime; + } + + set responseStartTime(value: number | null) { + this.state.responseStartTime = value; + } + + get flavorTimerInterval(): number | null { + return this.state.flavorTimerInterval; + } + + set flavorTimerInterval(value: number | null) { + this.state.flavorTimerInterval = value; + this.flavorTimerIntervalWindow = value === null ? null : this.getDefaultTimerWindow(); + } + + get pendingNewSessionPlan(): string | null { + return this.state.pendingNewSessionPlan; + } + + set pendingNewSessionPlan(value: string | null) { + this.state.pendingNewSessionPlan = value; + } + + get planFilePath(): string | null { + return this.state.planFilePath; + } + + set planFilePath(value: string | null) { + this.state.planFilePath = value; + } + + get prePlanPermissionMode(): string | null { + return this.state.prePlanPermissionMode; + } + + set prePlanPermissionMode(value: string | null) { + this.state.prePlanPermissionMode = value; + } + + // ============================================ + // Reset Methods + // ============================================ + + setThinkingIndicatorTimeout(value: number | null, ownerWindow: Window | null): void { + this.state.thinkingIndicatorTimeout = value; + this.thinkingIndicatorTimeoutWindow = value === null ? null : ownerWindow; + } + + clearThinkingIndicatorTimeout(fallbackWindow: Window | null = null): void { + if (this.state.thinkingIndicatorTimeout) { + const ownerWindow = this.thinkingIndicatorTimeoutWindow ?? fallbackWindow ?? this.getDefaultTimerWindow(); + ownerWindow?.clearTimeout(this.state.thinkingIndicatorTimeout); + this.state.thinkingIndicatorTimeout = null; + this.thinkingIndicatorTimeoutWindow = null; + } + } + + setFlavorTimerInterval(value: number | null, ownerWindow: Window | null): void { + this.state.flavorTimerInterval = value; + this.flavorTimerIntervalWindow = value === null ? null : ownerWindow; + } + + clearFlavorTimerInterval(): void { + if (this.state.flavorTimerInterval) { + const ownerWindow = this.flavorTimerIntervalWindow ?? this.getDefaultTimerWindow(); + ownerWindow?.clearInterval(this.state.flavorTimerInterval); + this.state.flavorTimerInterval = null; + this.flavorTimerIntervalWindow = null; + } + } + + resetStreamingState(): void { + this.state.currentContentEl = null; + this.state.currentTextEl = null; + this.state.currentTextContent = ''; + this.state.currentThinkingState = null; + this.state.isStreaming = false; + this.state.cancelRequested = false; + // Clear thinking indicator timeout + this.clearThinkingIndicatorTimeout(); + // Clear response timer + this.clearFlavorTimerInterval(); + this.state.responseStartTime = null; + } + + clearMaps(): void { + this.state.toolCallElements.clear(); + this.state.writeEditStates.clear(); + this.state.pendingTools.clear(); + } + + resetForNewConversation(): void { + this.clearMessages(); + this.resetStreamingState(); + this.clearMaps(); + this.state.queuedMessage = null; + this.usage = null; + this.currentTodos = null; + this.autoScrollEnabled = true; + } + + getPersistedMessages(): ChatMessage[] { + // Return messages as-is - image data is single source of truth + return this.state.messages; + } + + private getDefaultTimerWindow(): Window | null { + return typeof window === 'undefined' ? null : window; + } +} + +export { createInitialState }; diff --git a/src/features/chat/state/types.ts b/src/features/chat/state/types.ts new file mode 100644 index 0000000..fb52c77 --- /dev/null +++ b/src/features/chat/state/types.ts @@ -0,0 +1,138 @@ +import type { EditorView } from '@codemirror/view'; + +import type { ChatRuntimeQueryOptions, ChatTurnRequest } from '../../../core/runtime/types'; +import type { TodoItem } from '../../../core/tools/todo'; +import type { + ChatMessage, + ImageAttachment, + SubagentInfo, + ToolCallInfo, + UsageInfo, +} from '../../../core/types'; +import type { BrowserSelectionContext } from '../../../utils/browser'; +import type { CanvasSelectionContext } from '../../../utils/canvas'; +import type { EditorSelectionContext } from '../../../utils/editor'; +import type { ThinkingBlockState } from '../rendering/ThinkingBlockRenderer'; +import type { WriteEditState } from '../rendering/WriteEditRenderer'; + +/** Queued message waiting to be sent after current streaming completes. */ +export interface QueuedMessage { + content: string; + images?: ImageAttachment[]; + editorContext: EditorSelectionContext | null; + browserContext?: BrowserSelectionContext | null; + canvasContext: CanvasSelectionContext | null; + /** Provider-neutral turn snapshot captured at enqueue time. */ + turnRequest?: ChatTurnRequest; +} + +/** Pending tool call waiting to be rendered (buffered until input is complete). */ +export interface PendingToolCall { + toolCall: ToolCallInfo; + parentEl: HTMLElement | null; +} + +/** Stored selection state from editor polling. */ +export interface StoredSelection { + notePath: string; + selectedText: string; + lineCount: number; + startLine?: number; + from?: number; + to?: number; + editorView?: EditorView; + domRanges?: Range[]; +} + +/** Centralized chat state data. */ +export interface ChatStateData { + // Message state + messages: ChatMessage[]; + + // Streaming control + isStreaming: boolean; + cancelRequested: boolean; + streamGeneration: number; + /** Guards against concurrent operations during conversation creation. */ + isCreatingConversation: boolean; + /** Guards against concurrent operations during conversation switching. */ + isSwitchingConversation: boolean; + /** Local tab state is ahead of persisted conversation metadata. */ + hasPendingConversationSave: boolean; + + // Conversation identity + currentConversationId: string | null; + + // Queued message + queuedMessage: QueuedMessage | null; + + // Active streaming DOM state + currentContentEl: HTMLElement | null; + currentTextEl: HTMLElement | null; + currentTextContent: string; + currentThinkingState: ThinkingBlockState | null; + thinkingEl: HTMLElement | null; + queueIndicatorEl: HTMLElement | null; + /** Debounce timeout for showing thinking indicator after inactivity. */ + thinkingIndicatorTimeout: number | null; + + // Tool tracking maps + toolCallElements: Map; + writeEditStates: Map; + /** Pending tool calls buffered until input is complete (for non-streaming-style render). */ + pendingTools: Map; + + // Context window usage + usage: UsageInfo | null; + // Flag to ignore usage updates (during session reset) + ignoreUsageUpdates: boolean; + + // Current todo items for the persistent bottom panel + currentTodos: TodoItem[] | null; + + // Attention state (approval pending, error, etc.) + needsAttention: boolean; + + // Auto-scroll control during streaming + autoScrollEnabled: boolean; + + // Response timer state + responseStartTime: number | null; + flavorTimerInterval: number | null; + + // Pending plan content for approve-new-session (auto-sends in new session after stream ends) + pendingNewSessionPlan: string | null; + + // Plan file path captured from Write tool calls to provider plan directory during plan mode + planFilePath: string | null; + + // Saved permission mode before entering plan mode (for Shift+Tab toggle restore) + prePlanPermissionMode: string | null; +} + +/** Callbacks for ChatState changes. */ +export interface ChatStateCallbacks { + onMessagesChanged?: () => void; + onStreamingStateChanged?: (isStreaming: boolean) => void; + onConversationChanged?: (id: string | null) => void; + onUsageChanged?: (usage: UsageInfo | null) => void; + onTodosChanged?: (todos: TodoItem[] | null) => void; + onAttentionChanged?: (needsAttention: boolean) => void; + onAutoScrollChanged?: (enabled: boolean) => void; +} + +/** Options for query execution. */ +export type QueryOptions = ChatRuntimeQueryOptions; + +// Re-export types that are used across the chat feature +export type { + ChatMessage, + EditorSelectionContext, + ImageAttachment, + SubagentInfo, + ThinkingBlockState, + TodoItem, + ToolCallInfo, + UsageInfo, + WriteEditState, +}; diff --git a/src/features/chat/tabs/RuntimeSupervisor.ts b/src/features/chat/tabs/RuntimeSupervisor.ts new file mode 100644 index 0000000..712cd66 --- /dev/null +++ b/src/features/chat/tabs/RuntimeSupervisor.ts @@ -0,0 +1,22 @@ +import type { ChatRuntime } from '../../../core/runtime/ChatRuntime'; + +/** Owns the concrete provider runtime attached to one tab. */ +export class RuntimeSupervisor { + constructor(private runtime: ChatRuntime | null = null) {} + + get current(): ChatRuntime | null { + return this.runtime; + } + + setCurrent(runtime: ChatRuntime | null): void { + this.runtime = runtime; + } + + cleanup(): void { + const runtime = this.runtime; + runtime?.cleanup(); + if (this.runtime === runtime) { + this.runtime = null; + } + } +} diff --git a/src/features/chat/tabs/Tab.ts b/src/features/chat/tabs/Tab.ts new file mode 100644 index 0000000..a9dfcb0 --- /dev/null +++ b/src/features/chat/tabs/Tab.ts @@ -0,0 +1,2031 @@ +import type { Component } from 'obsidian'; +import { Notice, Platform } from 'obsidian'; + +import { getHiddenProviderCommandSet } from '../../../core/providers/commands/hiddenCommands'; +import type { ProviderCommandDropdownConfig } from '../../../core/providers/commands/ProviderCommandCatalog'; +import type { ProviderCommandEntry } from '../../../core/providers/commands/ProviderCommandEntry'; +import { + getProviderSettingsSnapshotWithModel, + normalizeProviderModelSelection, + resolveConversationModel, +} from '../../../core/providers/conversationModel'; +import { getEnabledProviderForModel, getProviderForModel } from '../../../core/providers/modelRouting'; +import { ProviderRegistry } from '../../../core/providers/ProviderRegistry'; +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import { ProviderWorkspaceRegistry } from '../../../core/providers/ProviderWorkspaceRegistry'; +import type { + ProviderCapabilities, + ProviderChatUIConfig, + ProviderId, + ProviderUIOption, +} from '../../../core/providers/types'; +import { + DEFAULT_CHAT_PROVIDER_ID, +} from '../../../core/providers/types'; +import type { ChatRuntime } from '../../../core/runtime/ChatRuntime'; +import type { AutoTurnResult } from '../../../core/runtime/types'; +import { TOOL_AGENT_OUTPUT } from '../../../core/tools/toolNames'; +import type { ChatMessage, ClaudianSettings, Conversation, StreamChunk } from '../../../core/types'; +import { t } from '../../../i18n/i18n'; +import { SlashCommandDropdown } from '../../../shared/components/SlashCommandDropdown'; +import { getEnhancedPath } from '../../../utils/env'; +import { getVaultPath } from '../../../utils/path'; +import type { FeatureHost } from '../../FeatureHost'; +import { BrowserSelectionController } from '../controllers/BrowserSelectionController'; +import { CanvasSelectionController } from '../controllers/CanvasSelectionController'; +import { ConversationController } from '../controllers/ConversationController'; +import { InputController } from '../controllers/InputController'; +import { NavigationController } from '../controllers/NavigationController'; +import { SelectionController } from '../controllers/SelectionController'; +import { StreamController } from '../controllers/StreamController'; +import { MessageRenderer } from '../rendering/MessageRenderer'; +import { cleanupThinkingBlock } from '../rendering/ThinkingBlockRenderer'; +import { findRewindContext } from '../rewind'; +import { BangBashService } from '../services/BangBashService'; +import { SubagentManager } from '../services/SubagentManager'; +import { ChatState } from '../state/ChatState'; +import { BangBashModeManager as BangBashModeManagerClass } from '../ui/BangBashModeManager'; +import { ComposerContextTray } from '../ui/ComposerContextTray'; +import { FileContextManager } from '../ui/FileContext'; +import { ImageContextManager } from '../ui/ImageContext'; +import { createInputToolbar } from '../ui/InputToolbar'; +import { InstructionModeManager as InstructionModeManagerClass } from '../ui/InstructionModeManager'; +import { NavigationSidebar } from '../ui/NavigationSidebar'; +import { StatusPanel } from '../ui/StatusPanel'; +import { autoResizeTextarea } from '../ui/textareaResize'; +import { recalculateUsageForModel } from '../utils/usageInfo'; +import { getTabProviderId } from './providerResolution'; +import { TabSession } from './TabSession'; +import type { TabData, TabDOMElements, TabId, TabManagerViewHost, TabProviderContext } from './types'; +import { generateTabId } from './types'; + +type TabProviderSettings = Record & { + model: string; + thinkingBudget: string; + effortLevel: string; + serviceTier: string; + permissionMode: string; + customContextLimits?: Record; +}; + +function getSharedSelectionFocusScopeEls(component: Component): HTMLElement[] { + const host = component as Partial; + return host.getSharedSelectionFocusScopeEls?.() ?? []; +} + +/** + * Returns model options for a blank tab. + * Uses provider registration metadata to determine which providers are + * available and how they should appear in the mixed picker. + */ +export function getBlankTabModelOptions( + settings: Record, +): ProviderUIOption[] { + return ProviderRegistry.getEnabledProviderIds(settings).flatMap((providerId) => { + const uiConfig = ProviderRegistry.getChatUIConfig(providerId); + const providerIcon = uiConfig.getProviderIcon?.() ?? undefined; + const group = ProviderRegistry.getProviderDisplayName(providerId); + + return uiConfig.getModelOptions(settings) + .map(model => ({ ...model, group, providerIcon })); + }); +} + +/** + * Resolves the draft model for a new blank tab by projecting provider-specific + * saved settings. Without this, `plugin.settings.model` reflects only the + * settings-provider's model, which may belong to a different provider. + */ +function resolveBlankTabModel( + plugin: FeatureHost, + providerId?: ProviderId, +): string { + const settings = plugin.settings as unknown as Record; + if (!providerId) { + return settings.model as string; + } + + const targetProviderId = ProviderRegistry.isEnabled(providerId, settings) + ? providerId + : ProviderRegistry.resolveSettingsProviderId(settings); + const snapshot = ProviderSettingsCoordinator.getProviderSettingsSnapshot(settings, targetProviderId); + return snapshot.model as string; +} + +export interface TabCreateOptions { + plugin: FeatureHost; + + containerEl: HTMLElement; + conversation?: Conversation; + tabId?: TabId; + /** Restored draft model for blank tabs. */ + draftModel?: string | null; + /** Provider to inherit for blank tabs (e.g. from the active tab). */ + defaultProviderId?: ProviderId; + onStreamingChanged?: (isStreaming: boolean) => void; + onTitleChanged?: (title: string) => void; + onAttentionChanged?: (needsAttention: boolean) => void; + onConversationIdChanged?: (conversationId: string | null) => void; +} + +export { getTabProviderId } from './providerResolution'; + +function getTabCapabilities( + tab: TabProviderContext, + plugin: FeatureHost, + conversation?: Conversation | null, +): ProviderCapabilities { + const providerId = getTabProviderId(tab, plugin, conversation); + if (tab.service?.providerId === providerId) { + return tab.service.getCapabilities(); + } + + return ProviderRegistry.getCapabilities(providerId); +} + +function getTabChatUIConfig( + tab: TabProviderContext, + plugin: FeatureHost, + conversation?: Conversation | null, +): ProviderChatUIConfig { + return ProviderRegistry.getChatUIConfig(getTabProviderId(tab, plugin, conversation)); +} + +function getTabSettingsSnapshot( + tab: TabProviderContext, + plugin: FeatureHost, +): TabProviderSettings { + const providerId = getTabProviderId(tab, plugin); + return getProviderSettingsSnapshotWithModel( + plugin.settings, + providerId, + getTabSelectedModel(tab, plugin), + ); +} + +function getWritableTabSettingsSnapshot( + tab: TabProviderContext, + plugin: FeatureHost, + settings: ClaudianSettings = plugin.settings, +): TabProviderSettings { + return ProviderSettingsCoordinator.getProviderSettingsSnapshot( + settings, + getTabProviderId(tab, plugin), + ); +} + +function getTabConversation( + tab: TabProviderContext, + plugin: FeatureHost, +): Conversation | null { + return tab.conversationId ? plugin.getConversationSync(tab.conversationId) : null; +} + +function getTabSelectedModel( + tab: TabProviderContext, + plugin: FeatureHost, +): string | null { + const providerId = getTabProviderId(tab, plugin); + if (tab.lifecycleState === 'blank') { + return normalizeProviderModelSelection(providerId, plugin.settings, tab.draftModel) + ?? tab.service?.getAuxiliaryModel?.() + ?? tab.draftModel + ?? null; + } + + const conversation = getTabConversation(tab, plugin); + if (conversation) { + return resolveConversationModel(plugin.settings, providerId, conversation).model; + } + + return tab.service?.getAuxiliaryModel?.() ?? null; +} + +function getTabPermissionMode( + tab: TabProviderContext, + plugin: FeatureHost, +): string { + const permissionMode = getTabSettingsSnapshot(tab, plugin).permissionMode; + return typeof permissionMode === 'string' && permissionMode + ? permissionMode + : 'normal'; +} + +function getTabHiddenCommands( + tab: TabProviderContext, + plugin: FeatureHost, + conversation?: Conversation | null, +): Set { + return getHiddenProviderCommandSet( + plugin.settings, + getTabProviderId(tab, plugin, conversation), + ); +} + +function isEnterWithoutShiftOrComposition(e: KeyboardEvent): boolean { + if (e.key !== 'Enter' || e.shiftKey || e.isComposing) { + return false; + } + + return true; +} + +function hasPlatformSendModifier(e: KeyboardEvent): boolean { + if (Platform.isMacOS) { + return e.metaKey === true && !e.ctrlKey && !e.altKey; + } + + return e.ctrlKey === true && !e.metaKey && !e.altKey; +} + +function shouldSendMessageFromExplicitEnterShortcut(e: KeyboardEvent): boolean { + return isEnterWithoutShiftOrComposition(e) && hasPlatformSendModifier(e); +} + +function shouldSendMessageFromEnterKey( + e: KeyboardEvent, + settings: Pick, +): boolean { + if (!isEnterWithoutShiftOrComposition(e)) { + return false; + } + + if (settings.requireCommandOrControlEnterToSend === true) { + return hasPlatformSendModifier(e); + } + + return true; +} + +function isTabInputFocused(tab: TabData): boolean { + return tab.dom.inputEl.ownerDocument.activeElement === tab.dom.inputEl; +} + +function sendTabInputMessage( + tab: TabData, + e: KeyboardEvent, + options?: { requireInputFocus?: boolean }, +): boolean { + if (options?.requireInputFocus && !isTabInputFocused(tab)) { + return false; + } + + const inputController = tab.controllers.inputController; + if (!inputController) { + return false; + } + + e.preventDefault(); + void inputController.sendMessage(); + return true; +} + +export function sendTabInputMessageFromExplicitEnterShortcut( + tab: TabData, + e: KeyboardEvent, + options?: { requireInputFocus?: boolean }, +): boolean { + if (!shouldSendMessageFromExplicitEnterShortcut(e)) { + return false; + } + + return sendTabInputMessage(tab, e, options); +} + +function sendTabInputMessageFromEnterKey( + tab: TabData, + settings: Pick, + e: KeyboardEvent, +): boolean { + if (!shouldSendMessageFromEnterKey(e, settings)) { + return false; + } + + return sendTabInputMessage(tab, e); +} + +type ProviderCatalogInfo = { + config: ProviderCommandDropdownConfig; + getEntries: () => Promise; +} | null; + +function getRegistryProviderCatalogInfo(providerId: ProviderId): ProviderCatalogInfo { + const catalog = ProviderWorkspaceRegistry.getCommandCatalog(providerId); + if (!catalog) { + return null; + } + + return { + config: catalog.getDropdownConfig(), + getEntries: () => catalog.listDropdownEntries({ includeBuiltIns: false }), + }; +} + +function getProviderMcpManager(providerId: ProviderId) { + return ProviderWorkspaceRegistry.getMcpServerManager(providerId); +} + +function syncSlashCommandDropdownForProvider( + tab: TabData, + plugin: FeatureHost, + getProviderCatalogConfig?: () => ProviderCatalogInfo, + conversation?: Conversation | null, +): void { + const dropdown = tab.ui.slashCommandDropdown; + if (!dropdown) { + return; + } + + const catalogInfo = getProviderCatalogConfig?.() + ?? getRegistryProviderCatalogInfo(getTabProviderId(tab, plugin, conversation)); + + if (catalogInfo) { + dropdown.setProviderCatalog?.(catalogInfo.config, catalogInfo.getEntries); + } else { + dropdown.resetSdkSkillsCache(); + } + + dropdown.setHiddenCommands(getTabHiddenCommands(tab, plugin, conversation)); +} + +async function updateTabProviderSettings( + tab: TabProviderContext, + plugin: FeatureHost, + update: (settings: TabProviderSettings) => void, +): Promise { + const providerId = getTabProviderId(tab, plugin); + let snapshot!: TabProviderSettings; + await plugin.mutateSettings((settings) => { + snapshot = getWritableTabSettingsSnapshot(tab, plugin, settings); + update(snapshot); + ProviderSettingsCoordinator.commitProviderSettingsSnapshot( + settings, + providerId, + snapshot, + ); + }); + return snapshot; +} + +function refreshTabProviderUI(tab: TabData, plugin: FeatureHost): void { + const capabilities = getTabCapabilities(tab, plugin); + const permissionMode = getTabPermissionMode(tab, plugin); + tab.ui.modelSelector?.updateDisplay(); + tab.ui.modelSelector?.renderOptions(); + tab.ui.modeSelector?.updateDisplay(); + tab.ui.modeSelector?.renderOptions(); + tab.ui.thinkingBudgetSelector?.updateDisplay(); + tab.ui.permissionToggle?.updateDisplay(); + tab.ui.serviceTierToggle?.updateDisplay(); + tab.dom.inputWrapper.toggleClass( + 'claudian-input-plan-mode', + permissionMode === 'plan' && capabilities.supportsPlanMode, + ); +} + +/** + * Hides or disables UI elements that the active provider does not support. + * Called after toolbar initialization and on provider switches. + */ +function applyProviderUIGating(tab: TabData, plugin: FeatureHost): void { + const capabilities = getTabCapabilities(tab, plugin); + const uiConfig = getTabChatUIConfig(tab, plugin); + const mcpManager = capabilities.supportsMcpTools + ? getProviderMcpManager(capabilities.providerId) + : null; + const hasPermissionToggle = Boolean(uiConfig.getPermissionModeToggle?.()); + + if (!capabilities.supportsMcpTools) { + tab.ui.mcpServerSelector?.clearEnabled(); + } + tab.ui.mcpServerSelector?.setVisible(capabilities.supportsMcpTools); + tab.ui.permissionToggle?.setVisible(hasPermissionToggle); + tab.ui.fileContextManager?.setMcpManager(mcpManager); + + tab.ui.fileContextManager?.setAgentService( + ProviderWorkspaceRegistry.getAgentMentionProvider(capabilities.providerId), + ); + + tab.ui.imageContextManager?.setEnabled(capabilities.supportsImageAttachments); + tab.ui.contextUsageMeter?.update(tab.state.usage); +} + +function syncTabProviderServices( + tab: TabData, + plugin: FeatureHost, +): void { + tab.services.instructionRefineService?.cancel(); + tab.services.instructionRefineService?.resetConversation(); + tab.services.instructionRefineService = ProviderRegistry.createInstructionRefineService( + plugin.providerHost, + tab.providerId, + ); + tab.services.subagentManager.setTaskResultInterpreter?.( + ProviderRegistry.getTaskResultInterpreter(tab.providerId) + ); +} + +function ensureTitleGenerationService(tab: TabData, plugin: FeatureHost): void { + if (!tab.services.titleGenerationService) { + tab.services.titleGenerationService = ProviderRegistry.createTitleGenerationService( + plugin.providerHost, + ); + } +} + +function cleanupTabRuntime(tab: TabData): void { + if (tab.service && typeof tab.service.cleanup === 'function') { + tab.service.cleanup(); + } + tab.service = null; + tab.serviceInitialized = false; +} + +/** + * Called when provider availability changes. If a blank tab targets a provider + * that is now disabled, it falls back to the first enabled provider's default + * blank-tab model. Refreshes model selector options for all blank tabs. + */ +export function onProviderAvailabilityChanged(tab: TabData, plugin: FeatureHost): void { + if (tab.lifecycleState !== 'blank') return; + + const settingsSnapshot = plugin.settings as unknown as Record; + const enabledProviderIds = ProviderRegistry.getEnabledProviderIds(settingsSnapshot); + let nextProviderId = tab.providerId; + + if (tab.draftModel) { + const draftProvider = getEnabledProviderForModel(tab.draftModel, settingsSnapshot); + const draftProviderOwnsModel = ProviderRegistry + .getChatUIConfig(draftProvider) + .ownsModel(tab.draftModel, settingsSnapshot); + if (!enabledProviderIds.includes(draftProvider) || !draftProviderOwnsModel) { + const fallbackProviderId = enabledProviderIds[0] ?? DEFAULT_CHAT_PROVIDER_ID; + const fallbackModels = ProviderRegistry.getChatUIConfig(fallbackProviderId) + .getModelOptions(settingsSnapshot); + tab.draftModel = fallbackModels[0]?.value ?? tab.draftModel; + nextProviderId = fallbackProviderId; + } else { + nextProviderId = draftProvider; + } + } + + tab.providerId = nextProviderId; + + // Clean up stale service if provider changed + if ( + tab.service + && tab.service.providerId !== nextProviderId + ) { + tab.service.cleanup(); + tab.service = null; + tab.serviceInitialized = false; + } + + syncTabProviderServices(tab, plugin); + tab.ui.slashCommandDropdown?.setHiddenCommands(getTabHiddenCommands(tab, plugin)); + tab.ui.slashCommandDropdown?.resetSdkSkillsCache(); + refreshTabProviderUI(tab, plugin); + applyProviderUIGating(tab, plugin); +} + +/** + * Creates a new Tab instance with all required state. + */ +export function createTab(options: TabCreateOptions): TabData { + const { + plugin, + containerEl, + conversation, + tabId, + onStreamingChanged, + onAttentionChanged, + onConversationIdChanged, + } = options; + + const id = tabId ?? generateTabId(); + + const contentEl = containerEl.createDiv({ cls: 'claudian-tab-content claudian-hidden' }); + + const state = new ChatState({ + onStreamingStateChanged: onStreamingChanged, + onAttentionChanged: onAttentionChanged, + onConversationChanged: onConversationIdChanged, + }); + + // Create subagent manager with no-op callback. + // This placeholder is replaced in initializeTabControllers() with the actual + // callback that updates the StreamController. We defer the real callback + // because StreamController doesn't exist until controllers are initialized. + const subagentManager = new SubagentManager(() => {}); + + const dom = buildTabDOM(contentEl); + state.queueIndicatorEl = dom.queueIndicatorEl; + + const isBound = !!conversation?.id; + const restoredDraftModel = typeof options.draftModel === 'string' + ? options.draftModel.trim() + : ''; + const draftModel = isBound + ? null + : (restoredDraftModel || resolveBlankTabModel(plugin, options.defaultProviderId)); + const initialProviderId = conversation?.providerId + ?? (draftModel + ? getEnabledProviderForModel(draftModel, plugin.settings) + : DEFAULT_CHAT_PROVIDER_ID); + const session = new TabSession({ + id, + lifecycleState: isBound ? 'bound_cold' : 'blank', + draftModel, + providerId: initialProviderId, + conversationId: conversation?.id ?? null, + }); + const runtimeSupervisor = session.runtimeSupervisor; + + const tab: TabData = { + session, + get id() { + return session.id; + }, + get lifecycleState() { + return session.lifecycleState; + }, + set lifecycleState(value) { + session.lifecycleState = value; + }, + get draftModel() { + return session.draftModel; + }, + set draftModel(value) { + session.draftModel = value; + }, + get providerId() { + return session.providerId; + }, + set providerId(value) { + session.providerId = value; + }, + get conversationId() { + return session.conversationId; + }, + set conversationId(value) { + session.conversationId = value; + }, + get service() { + return runtimeSupervisor.current; + }, + set service(runtime) { + runtimeSupervisor.setCurrent(runtime); + }, + runtimeSupervisor, + serviceInitialized: false, + state, + controllers: { + selectionController: null, + browserSelectionController: null, + canvasSelectionController: null, + conversationController: null, + streamController: null, + inputController: null, + navigationController: null, + }, + services: { + subagentManager, + instructionRefineService: null, + titleGenerationService: null, + }, + ui: { + contextTray: null, + fileContextManager: null, + imageContextManager: null, + modelSelector: null, + modeSelector: null, + thinkingBudgetSelector: null, + externalContextSelector: null, + mcpServerSelector: null, + permissionToggle: null, + serviceTierToggle: null, + slashCommandDropdown: null, + instructionModeManager: null, + bangBashModeManager: null, + contextUsageMeter: null, + statusPanel: null, + navigationSidebar: null, + }, + dom, + renderer: null, + }; + + return tab; +} + +/** + * Builds the DOM structure for a tab. + */ +function buildTabDOM(contentEl: HTMLElement): TabDOMElements { + const messagesWrapperEl = contentEl.createDiv({ cls: 'claudian-messages-wrapper' }); + const messagesEl = messagesWrapperEl.createDiv({ cls: 'claudian-messages' }); + const welcomeEl = messagesEl.createDiv({ cls: 'claudian-welcome' }); + const statusPanelContainerEl = contentEl.createDiv({ cls: 'claudian-status-panel-container' }); + const inputComposerEl = contentEl.createDiv({ cls: 'claudian-input-composer' }); + const inputContainerEl = inputComposerEl.createDiv({ cls: 'claudian-input-container' }); + const queueIndicatorEl = inputContainerEl.createDiv({ cls: 'claudian-input-queue-row' }); + const navRowEl = inputContainerEl.createDiv({ cls: 'claudian-input-nav-row' }); + const inputWrapper = inputContainerEl.createDiv({ cls: 'claudian-input-wrapper' }); + const contextRowEl = inputWrapper.createDiv({ cls: 'claudian-context-row' }); + const inputEl = inputWrapper.createEl('textarea', { + cls: 'claudian-input', + attr: { + placeholder: 'How can i help you today?', + rows: '3', + dir: 'auto', + }, + }); + + return { + contentEl, + messagesEl, + welcomeEl, + statusPanelContainerEl, + inputComposerEl, + inputContainerEl, + queueIndicatorEl, + inputWrapper, + inputEl, + navRowEl, + contextRowEl, + eventCleanups: [], + }; +} + +/** + * Initializes the tab's chat runtime for the send path. + * + * This is the ONLY place a runtime is created. Called from: + * - ensureServiceInitialized() in InputController.sendMessage() + * + * Session sync is passive (state update only). The runtime is started + * on demand by query() inside the send path. + */ +export async function initializeTabService( + tab: TabData, + plugin: FeatureHost, + conversationOverride?: Conversation | null, +): Promise; +export async function initializeTabService( + tab: TabData, + plugin: FeatureHost, + _legacyArg: unknown, + conversationOverride?: Conversation | null, +): Promise; +export async function initializeTabService( + tab: TabData, + plugin: FeatureHost, + argOrOverride?: unknown, + maybeOverride?: Conversation | null, +): Promise { + if (tab.lifecycleState === 'closing') { + return; + } + + // Support legacy 4-arg call sites (3rd arg was previously an MCP manager) + const conversationOverride = isConversationLike(argOrOverride) + ? argOrOverride + : (argOrOverride === null ? null : maybeOverride); + + const conversation = conversationOverride ?? ( + tab.conversationId + ? await plugin.getConversationById(tab.conversationId) + : null + ); + const providerId = getTabProviderId(tab, plugin, conversation); + const selectedModel = conversation + ? resolveConversationModel(plugin.settings, providerId, conversation).model + : getTabSelectedModel(tab, plugin); + + if (tab.serviceInitialized && tab.service?.providerId === providerId) { + return; + } + + let service: ChatRuntime | null = null; + let unsubscribeReadyState: (() => void) | null = null; + const previousService = tab.service; + + try { + if (typeof previousService?.cleanup === 'function') { + previousService.cleanup(); + } + tab.service = null; + tab.serviceInitialized = false; + + const runtime = ProviderRegistry.createChatRuntime({ + plugin: plugin.providerHost, + providerId, + }); + service = runtime; + unsubscribeReadyState = runtime.onReadyStateChange(() => {}); + tab.dom.eventCleanups.push(() => unsubscribeReadyState?.()); + + // Passive sync: set session state without starting the runtime process. + // The runtime starts on demand when query() is called. + const hasMessages = conversation ? conversation.messages.length > 0 : false; + const externalContextPaths = conversation && hasMessages + ? conversation.externalContextPaths || [] + : (plugin.settings.persistentExternalContextPaths || []); + const runtimeConversationState = conversation + ?? (selectedModel ? { sessionId: null, selectedModel } : null); + runtime.syncConversationState(runtimeConversationState, externalContextPaths); + + // Re-check after async operations — tab may have been closed during init + if (isClosingLifecycleState(tab.lifecycleState)) { + unsubscribeReadyState?.(); + service?.cleanup(); + return; + } + + + tab.providerId = providerId; + tab.service = service; + tab.serviceInitialized = true; + + // Update lifecycle state + if (tab.lifecycleState === 'blank') { + tab.draftModel = null; + } + tab.lifecycleState = 'bound_active'; + } catch (error) { + // Clean up partial state on failure + unsubscribeReadyState?.(); + service?.cleanup(); + tab.service = null; + tab.serviceInitialized = false; + + // Re-throw to let caller handle (e.g., show error to user) + throw error; + } +} + +function isConversationLike(value: unknown): value is Conversation { + return !!value + && typeof value === 'object' + && typeof (value as Conversation).id === 'string' + && Array.isArray((value as Conversation).messages); +} + +function initializeContextManagers(tab: TabData, plugin: FeatureHost): void { + const { dom } = tab; + const app = plugin.app; + const contextTray = tab.ui.contextTray; + if (!contextTray) { + throw new Error('Composer context tray must be initialized before context managers'); + } + + tab.ui.fileContextManager = new FileContextManager( + app, + dom.contextRowEl, + dom.inputEl, + { + getExcludedTags: () => plugin.settings.excludedTags, + getExternalContexts: () => tab.ui.externalContextSelector?.getExternalContexts() || [], + }, + dom.inputContainerEl, + contextTray, + ); + tab.ui.fileContextManager.setMcpManager(getProviderMcpManager(getTabProviderId(tab, plugin))); + + tab.ui.imageContextManager = new ImageContextManager( + dom.inputContainerEl, + dom.inputEl, + {}, + dom.contextRowEl, + contextTray, + ); +} + +function initializeSlashCommands( + tab: TabData, + getHiddenCommands?: () => Set, + catalogInfo?: { config: ProviderCommandDropdownConfig; getEntries: () => Promise } | null, +): void { + const { dom } = tab; + + tab.ui.slashCommandDropdown = new SlashCommandDropdown( + dom.inputContainerEl, + dom.inputEl, + { + onSelect: () => {}, + onHide: () => {}, + }, + { + hiddenCommands: getHiddenCommands?.() ?? new Set(), + providerConfig: catalogInfo?.config, + getProviderEntries: catalogInfo?.getEntries, + } + ); +} + +/** + * Initializes instruction mode and todo panel for a tab. + */ +function initializeInstructionAndTodo(tab: TabData, plugin: FeatureHost): void { + const { dom } = tab; + + syncTabProviderServices(tab, plugin); + ensureTitleGenerationService(tab, plugin); + tab.ui.instructionModeManager = new InstructionModeManagerClass( + dom.inputEl, + { + onSubmit: async (rawInstruction) => { + await tab.controllers.inputController?.handleInstructionSubmit(rawInstruction); + }, + getInputWrapper: () => dom.inputWrapper, + } + ); + + // Bang bash mode (! command execution) + if (isBangBashEnabled(plugin.settings)) { + const vaultPath = getVaultPath(plugin.app); + if (vaultPath) { + const enhancedPath = getEnhancedPath(); + const bashService = new BangBashService(vaultPath, enhancedPath); + + tab.ui.bangBashModeManager = new BangBashModeManagerClass( + dom.inputEl, + { + onSubmit: async (command) => { + const statusPanel = tab.ui.statusPanel; + if (!statusPanel) return; + + const id = `bash-${Date.now()}`; + statusPanel.addBashOutput({ id, command, status: 'running', output: '' }); + + const result = await bashService.execute(command); + const output = [result.stdout, result.stderr, result.error].filter(Boolean).join('\n').trim(); + const status = result.exitCode === 0 ? 'completed' : 'error'; + statusPanel.updateBashOutput(id, { status, output, exitCode: result.exitCode }); + }, + getInputWrapper: () => dom.inputWrapper, + } + ); + } + } + + tab.ui.statusPanel = new StatusPanel(); + tab.ui.statusPanel.mount(dom.statusPanelContainerEl); +} + +function isBangBashEnabled(settings: Record): boolean { + return ProviderRegistry.getEnabledProviderIds(settings).some((providerId) => ( + ProviderRegistry.getChatUIConfig(providerId).isBangBashEnabled?.(settings) ?? false + )); +} + +/** + * Creates and wires the input toolbar for a tab. + */ +function initializeInputToolbar( + tab: TabData, + plugin: FeatureHost, + getProviderCatalogConfig?: () => ProviderCatalogInfo, + onProviderChanged?: (providerId: ProviderId) => void | Promise, +): void { + const { dom } = tab; + + const inputToolbar = dom.inputWrapper.createDiv({ cls: 'claudian-input-toolbar' }); + + // Blank-tab UI config wrapper that returns mixed model options + const blankTabUIConfigProxy = (): ProviderChatUIConfig => { + const draftProvider = tab.draftModel + ? getEnabledProviderForModel(tab.draftModel, plugin.settings) + : DEFAULT_CHAT_PROVIDER_ID; + const baseConfig = ProviderRegistry.getChatUIConfig(draftProvider); + return { + ...baseConfig, + getModelOptions: (settings: Record) => + getBlankTabModelOptions(settings), + }; + }; + + const toolbarComponents = createInputToolbar(inputToolbar, { + getUIConfig: () => { + if (tab.lifecycleState === 'blank') { + return blankTabUIConfigProxy(); + } + return getTabChatUIConfig(tab, plugin); + }, + getCapabilities: () => getTabCapabilities(tab, plugin), + getSettings: () => getTabSettingsSnapshot(tab, plugin), + getEnvironmentVariables: () => plugin.getActiveEnvironmentVariables(), + onModelChange: async (model: string) => { + // For blank tabs, update draft model and derive provider + if (tab.lifecycleState === 'blank') { + const previousProvider = tab.providerId; + tab.draftModel = model; + const newProvider = getEnabledProviderForModel( + model, + plugin.settings, + ); + const didProviderChange = newProvider !== previousProvider; + if (tab.service) { + cleanupTabRuntime(tab); + } + tab.providerId = newProvider; + if (didProviderChange) { + syncTabProviderServices(tab, plugin); + } + syncSlashCommandDropdownForProvider(tab, plugin, getProviderCatalogConfig); + + const uiConfig = ProviderRegistry.getChatUIConfig(newProvider); + if (didProviderChange) { + await onProviderChanged?.(newProvider); + } + await uiConfig.prepareModelMetadata?.( + model, + getProviderSettingsSnapshotWithModel(plugin.settings, newProvider, model), + { plugin: plugin.providerHost }, + ); + tab.ui.thinkingBudgetSelector?.updateDisplay(); + tab.ui.serviceTierToggle?.updateDisplay(); + tab.ui.modelSelector?.updateDisplay(); + tab.ui.modeSelector?.updateDisplay(); + // Re-render options (provider may have changed reasoning controls) + tab.ui.modelSelector?.renderOptions(); + tab.ui.modeSelector?.renderOptions(); + applyProviderUIGating(tab, plugin); + return; + } + + // For bound tabs, reject cross-provider model changes + const boundProvider = tab.providerId; + const modelProvider = getProviderForModel(model, plugin.settings); + if (modelProvider !== boundProvider) { + new Notice('Cannot switch provider on a bound session. Start a new tab instead.'); + tab.ui.modelSelector?.updateDisplay(); + return; + } + + const uiConfig: ProviderChatUIConfig = getTabChatUIConfig(tab, plugin); + const normalizedModel = normalizeProviderModelSelection(boundProvider, plugin.settings, model) ?? model; + const providerSettings = getProviderSettingsSnapshotWithModel( + plugin.settings, + boundProvider, + normalizedModel, + ) as TabProviderSettings; + + if (tab.conversationId) { + await plugin.updateConversation(tab.conversationId, { + selectedModel: normalizedModel, + }); + const updatedConversation = plugin.getConversationSync(tab.conversationId); + if (updatedConversation && tab.service?.providerId === boundProvider) { + const hasMessages = updatedConversation.messages.length > 0; + const externalContextPaths = tab.ui.externalContextSelector?.getExternalContexts() + ?? (hasMessages + ? updatedConversation.externalContextPaths ?? [] + : plugin.settings.persistentExternalContextPaths ?? []); + tab.service.syncConversationState(updatedConversation, externalContextPaths); + } + } + + await uiConfig.prepareModelMetadata?.( + normalizedModel, + providerSettings, + { plugin: plugin.providerHost }, + ); + tab.ui.thinkingBudgetSelector?.updateDisplay(); + tab.ui.serviceTierToggle?.updateDisplay(); + tab.ui.modelSelector?.updateDisplay(); + tab.ui.modelSelector?.renderOptions(); + + // Recalculate context usage percentage for the new model's context window + const currentUsage = tab.state.usage; + if (currentUsage) { + const newContextWindow = uiConfig.getContextWindowSize( + normalizedModel, + providerSettings.customContextLimits, + providerSettings, + ); + tab.state.usage = recalculateUsageForModel(currentUsage, normalizedModel, newContextWindow); + } + }, + onModeChange: async (mode: string) => { + await updateTabProviderSettings(tab, plugin, (settings) => { + getTabChatUIConfig(tab, plugin).applyModeSelection?.(mode, settings); + }); + tab.ui.modeSelector?.updateDisplay(); + tab.ui.modeSelector?.renderOptions(); + }, + onThinkingBudgetChange: async (budget: string) => { + await updateTabProviderSettings(tab, plugin, (settings) => { + const model = getTabSelectedModel(tab, plugin) ?? settings.model; + settings.thinkingBudget = budget; + getTabChatUIConfig(tab, plugin).applyReasoningSelection?.(model, budget, settings); + }); + }, + onEffortLevelChange: async (effort: string) => { + await updateTabProviderSettings(tab, plugin, (settings) => { + const model = getTabSelectedModel(tab, plugin) ?? settings.model; + settings.effortLevel = effort; + getTabChatUIConfig(tab, plugin).applyReasoningSelection?.(model, effort, settings); + }); + }, + onServiceTierChange: async (serviceTier: string) => { + await updateTabProviderSettings(tab, plugin, (settings) => { + settings.serviceTier = serviceTier; + }); + tab.ui.serviceTierToggle?.updateDisplay(); + }, + onPermissionModeChange: async (mode: string) => { + await updateTabProviderSettings(tab, plugin, (settings) => { + const uiConfig = getTabChatUIConfig(tab, plugin); + if (uiConfig.applyPermissionMode) { + uiConfig.applyPermissionMode(mode, settings); + } else { + settings.permissionMode = mode; + } + }); + tab.ui.permissionToggle?.updateDisplay(); + dom.inputWrapper.toggleClass( + 'claudian-input-plan-mode', + mode === 'plan' && getTabCapabilities(tab, plugin).supportsPlanMode, + ); + }, + }); + + tab.ui.modelSelector = toolbarComponents.modelSelector; + tab.ui.modeSelector = toolbarComponents.modeSelector; + tab.ui.thinkingBudgetSelector = toolbarComponents.thinkingBudgetSelector; + tab.ui.contextUsageMeter = toolbarComponents.contextUsageMeter; + tab.ui.externalContextSelector = toolbarComponents.externalContextSelector; + tab.ui.mcpServerSelector = toolbarComponents.mcpServerSelector; + tab.ui.permissionToggle = toolbarComponents.permissionToggle; + tab.ui.serviceTierToggle = toolbarComponents.serviceTierToggle; + + tab.ui.mcpServerSelector.setMcpManager(getProviderMcpManager(getTabProviderId(tab, plugin))); + + // Sync @-mentions to UI selector + tab.ui.fileContextManager?.setOnMcpMentionChange((servers) => { + tab.ui.mcpServerSelector?.addMentionedServers(servers); + }); + + // Wire external context changes + tab.ui.externalContextSelector.setOnChange(() => { + tab.ui.fileContextManager?.preScanExternalContexts(); + }); + + // Initialize persistent paths + tab.ui.externalContextSelector.setPersistentPaths( + plugin.settings.persistentExternalContextPaths || [] + ); + + // Wire persistence changes + tab.ui.externalContextSelector.setOnPersistenceChange((paths) => { + void plugin.mutateSettings((settings) => { + settings.persistentExternalContextPaths = paths; + }); + }); + + refreshTabProviderUI(tab, plugin); + + // Gate provider-specific UI elements + applyProviderUIGating(tab, plugin); +} + +export interface InitializeTabUIOptions { + getProviderCatalogConfig?: () => ProviderCatalogInfo; + onProviderChanged?: (providerId: ProviderId) => void | Promise; +} + +/** + * Initializes the tab's UI components. + * Call this after the tab is created and before it becomes active. + */ +export function initializeTabUI( + tab: TabData, + plugin: FeatureHost, + options: InitializeTabUIOptions = {} +): void { + const { dom, state } = tab; + + tab.ui.contextTray = new ComposerContextTray(dom.contextRowEl, { + onDidChange: () => { + autoResizeTextarea(dom.inputEl); + tab.renderer?.scrollToBottomIfNeeded(); + }, + }); + initializeContextManagers(tab, plugin); + + const catalogInfo = options.getProviderCatalogConfig?.() ?? null; + initializeSlashCommands( + tab, + () => getTabHiddenCommands(tab, plugin), + catalogInfo, + ); + + if (dom.messagesEl.parentElement) { + tab.ui.navigationSidebar = new NavigationSidebar( + dom.messagesEl.parentElement, + dom.messagesEl + ); + } + + initializeInstructionAndTodo(tab, plugin); + initializeInputToolbar(tab, plugin, options.getProviderCatalogConfig, options.onProviderChanged); + + state.callbacks = { + ...state.callbacks, + onUsageChanged: (usage) => { + tab.ui.contextUsageMeter?.update(usage); + }, + onTodosChanged: (todos) => tab.ui.statusPanel?.updateTodos(todos), + onAutoScrollChanged: () => tab.ui.navigationSidebar?.updateVisibility(), + }; + + // ResizeObserver to detect overflow changes (e.g., content growth) + const resizeObserver = new ResizeObserver(() => { + tab.ui.navigationSidebar?.updateVisibility(); + }); + resizeObserver.observe(dom.messagesEl); + dom.eventCleanups.push(() => resizeObserver.disconnect()); +} + +export interface ForkContext { + messages: ChatMessage[]; + providerId?: ProviderId; + sourceSessionId: string; + sourceProviderState?: Record; + sourceSelectedModel?: string; + resumeAt: string; + sourceTitle?: string; + /** 1-based index used for fork title suffix (counts only non-interrupt user messages). */ + forkAtUserMessage?: number; + currentNote?: string; +} + +function deepCloneMessages(messages: ChatMessage[]): ChatMessage[] { + if (typeof structuredClone === 'function') { + return structuredClone(messages); + } + return JSON.parse(JSON.stringify(messages)) as ChatMessage[]; +} + +function isClosingLifecycleState(state: TabData['lifecycleState']): boolean { + return state === 'closing'; +} + +function countUserMessagesForForkTitle(messages: ChatMessage[]): number { + // Keep fork numbering stable by excluding non-semantic user messages. + return messages.filter(m => m.role === 'user' && !m.isInterrupt && !m.isRebuiltContext).length; +} + +interface ForkSource { + providerId?: ProviderId; + sourceSessionId: string; + sourceProviderState?: Record; + sourceSelectedModel?: string; + sourceTitle?: string; + currentNote?: string; +} + +/** + * Resolves session ID and conversation metadata needed for forking. + * Prefers the live service session ID; falls back to persisted conversation metadata. + * Shows a notice and returns null when no session can be resolved. + */ +function resolveForkSource(tab: TabData, plugin: FeatureHost): ForkSource | null { + const conversation = tab.conversationId + ? plugin.getConversationSync(tab.conversationId) + : null; + + // Delegate session ID resolution to the runtime when available; + // fall back to persisted conversation metadata when no runtime is active. + const sourceSessionId = tab.service + ? tab.service.resolveSessionIdForFork(conversation ?? null) + : ProviderRegistry + .getConversationHistoryService(conversation?.providerId ?? tab.providerId) + .resolveSessionIdForConversation(conversation); + + if (!sourceSessionId) { + new Notice(t('chat.fork.failed', { error: t('chat.fork.errorNoSession') })); + return null; + } + + const providerId = getTabProviderId(tab, plugin, conversation); + + return { + providerId, + sourceSessionId, + sourceProviderState: conversation?.providerState, + sourceSelectedModel: conversation + ? resolveConversationModel(plugin.settings, providerId, conversation).model + : getTabSelectedModel(tab, plugin) ?? undefined, + sourceTitle: conversation?.title, + currentNote: conversation?.currentNote, + }; +} + +async function handleForkRequest( + tab: TabData, + plugin: FeatureHost, + userMessageId: string, + forkRequestCallback: (forkContext: ForkContext) => Promise, +): Promise { + const { state } = tab; + + if (!getTabCapabilities(tab, plugin).supportsFork) { + new Notice('Fork is not supported by this provider.'); + return; + } + + if (state.isStreaming) { + new Notice(t('chat.fork.unavailableStreaming')); + return; + } + + const msgs = state.messages; + const userIdx = msgs.findIndex(m => m.id === userMessageId); + if (userIdx === -1) { + new Notice(t('chat.fork.failed', { error: t('chat.fork.errorMessageNotFound') })); + return; + } + + if (!msgs[userIdx].userMessageId) { + new Notice(t('chat.fork.unavailableNoUuid')); + return; + } + + const rewindCtx = findRewindContext(msgs, userIdx); + if (!rewindCtx.hasResponse || !rewindCtx.prevAssistantUuid) { + new Notice(t('chat.fork.unavailableNoResponse')); + return; + } + + const source = resolveForkSource(tab, plugin); + if (!source) return; + + await forkRequestCallback({ + messages: deepCloneMessages(msgs.slice(0, userIdx)), + providerId: source.providerId, + sourceSessionId: source.sourceSessionId, + sourceProviderState: source.sourceProviderState, + sourceSelectedModel: source.sourceSelectedModel, + resumeAt: rewindCtx.prevAssistantUuid, + sourceTitle: source.sourceTitle, + forkAtUserMessage: countUserMessagesForForkTitle(msgs.slice(0, userIdx + 1)), + currentNote: source.currentNote, + }); +} + +async function handleForkAll( + tab: TabData, + plugin: FeatureHost, + forkRequestCallback: (forkContext: ForkContext) => Promise, +): Promise { + const { state } = tab; + + if (!getTabCapabilities(tab, plugin).supportsFork) { + new Notice('Fork is not supported by this provider.'); + return; + } + + if (state.isStreaming) { + new Notice(t('chat.fork.unavailableStreaming')); + return; + } + + const msgs = state.messages; + if (msgs.length === 0) { + new Notice(t('chat.fork.commandNoMessages')); + return; + } + + let lastAssistantUuid: string | undefined; + for (let i = msgs.length - 1; i >= 0; i--) { + if (msgs[i].role === 'assistant' && msgs[i].assistantMessageId) { + lastAssistantUuid = msgs[i].assistantMessageId; + break; + } + } + + if (!lastAssistantUuid) { + new Notice(t('chat.fork.commandNoAssistantUuid')); + return; + } + + const source = resolveForkSource(tab, plugin); + if (!source) return; + + await forkRequestCallback({ + messages: deepCloneMessages(msgs), + providerId: source.providerId, + sourceSessionId: source.sourceSessionId, + sourceProviderState: source.sourceProviderState, + sourceSelectedModel: source.sourceSelectedModel, + resumeAt: lastAssistantUuid, + sourceTitle: source.sourceTitle, + forkAtUserMessage: countUserMessagesForForkTitle(msgs) + 1, + currentNote: source.currentNote, + }); +} + +export function initializeTabControllers( + tab: TabData, + plugin: FeatureHost, + component: Component, + forkRequestCallback?: (forkContext: ForkContext) => Promise, + openConversation?: (conversationId: string) => Promise, + getProviderCatalogConfig?: () => ProviderCatalogInfo, +): void; +/** @deprecated Legacy 7-arg overload — 4th arg was previously an MCP manager. */ +export function initializeTabControllers( + tab: TabData, + plugin: FeatureHost, + component: Component, + _legacyArg: unknown, + forkRequestCallback?: (forkContext: ForkContext) => Promise, + openConversation?: (conversationId: string) => Promise, + getProviderCatalogConfig?: () => ProviderCatalogInfo, +): void; +export function initializeTabControllers( + tab: TabData, + plugin: FeatureHost, + component: Component, + arg4?: unknown, + arg5?: unknown, + arg6?: unknown, + arg7?: unknown, +): void { + // Support legacy 7-arg call sites (4th arg was previously an MCP manager) + const isLegacy = arg4 !== undefined && typeof arg4 !== 'function'; + const forkRequestCallback = (isLegacy ? arg5 : arg4) as + ((forkContext: ForkContext) => Promise) | undefined; + const openConversation = (isLegacy ? arg6 : arg5) as + ((conversationId: string) => Promise) | undefined; + const getProviderCatalogConfig = (isLegacy ? arg7 : arg6) as + (() => ProviderCatalogInfo) | undefined; + + const { dom, state, services, ui } = tab; + + // Create renderer + tab.renderer = new MessageRenderer( + plugin, + component, + dom.messagesEl, + (id, mode) => tab.controllers.conversationController!.rewind(id, mode), + forkRequestCallback + ? (id) => handleForkRequest(tab, plugin, id, forkRequestCallback) + : undefined, + () => getTabCapabilities(tab, plugin), + ); + + // Selection controller + tab.controllers.selectionController = new SelectionController( + plugin.app, + ui.contextTray!, + dom.inputEl, + undefined, + [dom.contentEl, dom.inputComposerEl, ...getSharedSelectionFocusScopeEls(component)], + ); + + tab.controllers.browserSelectionController = new BrowserSelectionController( + plugin.app, + ui.contextTray!, + dom.inputEl, + ); + + tab.controllers.canvasSelectionController = new CanvasSelectionController( + plugin.app, + ui.contextTray!, + dom.inputEl, + ); + + tab.controllers.streamController = new StreamController({ + plugin, + state, + renderer: tab.renderer, + subagentManager: services.subagentManager, + getMessagesEl: () => dom.messagesEl, + getFileContextManager: () => ui.fileContextManager, + updateQueueIndicator: () => tab.controllers.inputController?.updateQueueIndicator(), + getAgentService: () => tab.service, + }); + + // Wire subagent callback now that StreamController exists + // DOM updates for async subagents are handled by SubagentManager directly; + // this callback handles message persistence. + services.subagentManager.setCallback( + (subagent) => { + tab.controllers.streamController?.onAsyncSubagentStateChange(subagent); + + // During active stream, regular end-of-turn save captures latest state. + if (!tab.state.isStreaming && tab.state.currentConversationId) { + void tab.controllers.conversationController?.save(false).catch(() => { + // Best-effort persistence; avoid surfacing background-save failures here. + }); + } + } + ); + + tab.controllers.conversationController = new ConversationController( + { + plugin, + state, + renderer: tab.renderer, + subagentManager: services.subagentManager, + getHistoryDropdown: () => null, // Tab doesn't have its own history dropdown + getWelcomeEl: () => dom.welcomeEl, + setWelcomeEl: (el) => { dom.welcomeEl = el; }, + getMessagesEl: () => dom.messagesEl, + getInputEl: () => dom.inputEl, + getFileContextManager: () => ui.fileContextManager, + getImageContextManager: () => ui.imageContextManager, + getMcpServerSelector: () => ui.mcpServerSelector, + getExternalContextSelector: () => ui.externalContextSelector, + clearQueuedMessage: () => tab.controllers.inputController?.clearQueuedMessage(), + getTitleGenerationService: () => services.titleGenerationService, + getStatusPanel: () => ui.statusPanel, + getAgentService: () => tab.service, // Use tab's service instead of plugin's + getSelectedModel: () => getTabSelectedModel(tab, plugin), + dismissPendingInlinePrompts: () => tab.controllers.inputController?.dismissPendingApproval(), + ensureServiceForConversation: async (conversation) => { + const nextProviderId = getTabProviderId(tab, plugin, conversation); + const providerChanged = tab.providerId !== nextProviderId; + tab.providerId = nextProviderId; + + if (providerChanged) { + syncTabProviderServices(tab, plugin); + } + + // Bind session state only — runtime starts on send + tab.conversationId = conversation?.id ?? null; + tab.draftModel = null; + tab.lifecycleState = conversation ? 'bound_cold' : 'blank'; + syncSlashCommandDropdownForProvider(tab, plugin, getProviderCatalogConfig, conversation); + + // If the runtime already exists for the right provider, sync it passively + if (tab.service && tab.service.providerId === nextProviderId && conversation) { + const hasMessages = conversation.messages.length > 0; + const externalContextPaths = hasMessages + ? conversation.externalContextPaths || [] + : (plugin.settings.persistentExternalContextPaths || []); + tab.service.syncConversationState(conversation, externalContextPaths); + } + + refreshTabProviderUI(tab, plugin); + applyProviderUIGating(tab, plugin); + }, + }, + { + onNewConversation: () => { + // Reset to blank state and drop the bound runtime so the next send + // reinitializes against the currently selected blank-tab provider. + const previousProviderId = tab.providerId; + cleanupTabRuntime(tab); + tab.lifecycleState = 'blank'; + tab.draftModel = resolveBlankTabModel(plugin, previousProviderId); + tab.conversationId = null; + tab.providerId = getTabProviderId(tab, plugin); + if (tab.providerId !== previousProviderId) { + syncTabProviderServices(tab, plugin); + } + refreshTabProviderUI(tab, plugin); + applyProviderUIGating(tab, plugin); + syncSlashCommandDropdownForProvider(tab, plugin, getProviderCatalogConfig); + }, + onConversationLoaded: () => ui.slashCommandDropdown?.resetSdkSkillsCache(), + onConversationSwitched: () => ui.slashCommandDropdown?.resetSdkSkillsCache(), + } + ); + + tab.controllers.inputController = new InputController({ + plugin, + state, + renderer: tab.renderer, + streamController: tab.controllers.streamController, + selectionController: tab.controllers.selectionController, + browserSelectionController: tab.controllers.browserSelectionController, + canvasSelectionController: tab.controllers.canvasSelectionController, + conversationController: tab.controllers.conversationController, + getInputEl: () => dom.inputEl, + getInputContainerEl: () => dom.inputContainerEl, + getWelcomeEl: () => dom.welcomeEl, + getMessagesEl: () => dom.messagesEl, + getFileContextManager: () => ui.fileContextManager, + getImageContextManager: () => ui.imageContextManager, + getMcpServerSelector: () => ui.mcpServerSelector, + getExternalContextSelector: () => ui.externalContextSelector, + getInstructionModeManager: () => ui.instructionModeManager, + getInstructionRefineService: () => services.instructionRefineService, + getTitleGenerationService: () => services.titleGenerationService, + getStatusPanel: () => ui.statusPanel, + generateId: generateMessageId, + resetInputHeight: () => { + // Per-tab input height is managed by CSS, no dynamic adjustment needed + }, + getAuxiliaryModel: () => getTabSelectedModel(tab, plugin), + getAgentService: () => tab.service, + getSubagentManager: () => services.subagentManager, + getTabProviderId: () => getTabProviderId(tab, plugin), + turnOwner: tab.session, + ensureServiceInitialized: async () => { + if (tab.serviceInitialized && tab.lifecycleState === 'bound_active') { + return true; + } + + try { + // For blank tabs on first send: derive provider from draft model + if (tab.lifecycleState === 'blank' && tab.draftModel) { + const derivedProvider = getEnabledProviderForModel( + tab.draftModel, + plugin.settings, + ); + tab.providerId = derivedProvider; + } + + await initializeTabService(tab, plugin); + setupServiceCallbacks(tab, plugin); + + // Transition: lock model selector to bound provider + refreshTabProviderUI(tab, plugin); + applyProviderUIGating(tab, plugin); + return true; + } catch (error) { + new Notice(error instanceof Error ? error.message : 'Failed to initialize chat service'); + return false; + } + }, + openConversation, + onForkAll: forkRequestCallback + ? () => handleForkAll(tab, plugin, forkRequestCallback) + : undefined, + restorePrePlanPermissionModeIfNeeded: async () => { + if (getTabPermissionMode(tab, plugin) === 'plan') { + const restoreMode = tab.state.prePlanPermissionMode ?? 'normal'; + try { + await updatePlanModeUI(tab, plugin, restoreMode); + } finally { + if (getTabPermissionMode(tab, plugin) !== 'plan') { + tab.state.prePlanPermissionMode = null; + } + } + } + }, + }); + + tab.controllers.navigationController = new NavigationController({ + getMessagesEl: () => dom.messagesEl, + getInputEl: () => dom.inputEl, + getSettings: () => plugin.settings.keyboardNavigation, + isStreaming: () => state.isStreaming, + shouldSkipEscapeHandling: () => { + if (ui.instructionModeManager?.isActive()) return true; + if (ui.bangBashModeManager?.isActive()) return true; + if (tab.controllers.inputController?.isResumeDropdownVisible()) return true; + if (ui.slashCommandDropdown?.isVisible()) return true; + if (ui.fileContextManager?.isMentionDropdownVisible()) return true; + return false; + }, + }); + tab.controllers.navigationController.initialize(); +} + +/** + * Wires up input event handlers for a tab. + * Call this after controllers are initialized. + * Stores cleanup functions in dom.eventCleanups for proper memory management. + */ +export function wireTabInputEvents(tab: TabData, plugin: FeatureHost): void { + const { dom, ui, state, controllers } = tab; + + let wasBangBashActive = ui.bangBashModeManager?.isActive() ?? false; + const syncBangBashSuppression = (): void => { + const isActive = ui.bangBashModeManager?.isActive() ?? false; + if (isActive === wasBangBashActive) return; + wasBangBashActive = isActive; + + ui.slashCommandDropdown?.setEnabled(!isActive); + if (isActive) { + ui.fileContextManager?.hideMentionDropdown(); + } + }; + + const keydownHandler = (e: KeyboardEvent) => { + if (ui.bangBashModeManager?.isActive()) { + ui.bangBashModeManager.handleKeydown(e); + syncBangBashSuppression(); + return; + } + + if (getTabCapabilities(tab, plugin).supportsInstructionMode && ui.instructionModeManager?.handleTriggerKey(e)) { + return; + } + + if (ui.bangBashModeManager?.handleTriggerKey(e)) { + syncBangBashSuppression(); + return; + } + + if (getTabCapabilities(tab, plugin).supportsInstructionMode && ui.instructionModeManager?.handleKeydown(e)) { + return; + } + + if (sendTabInputMessageFromExplicitEnterShortcut(tab, e)) { + return; + } + + if (controllers.inputController?.handleResumeKeydown(e)) { + return; + } + + if (ui.slashCommandDropdown?.handleKeydown(e)) { + return; + } + + if (ui.fileContextManager?.handleMentionKeydown(e)) { + return; + } + + // Check !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) + if (e.key === 'Escape' && !e.isComposing && state.isStreaming) { + e.preventDefault(); + controllers.inputController?.cancelStreaming(); + return; + } + + if (sendTabInputMessageFromEnterKey(tab, plugin.settings, e)) { + return; + } + }; + dom.inputEl.addEventListener('keydown', keydownHandler); + dom.eventCleanups.push(() => dom.inputEl.removeEventListener('keydown', keydownHandler)); + + const inputHandler = () => { + if (!ui.bangBashModeManager?.isActive()) { + ui.fileContextManager?.handleInputChange(); + } + ui.instructionModeManager?.handleInputChange(); + ui.bangBashModeManager?.handleInputChange(); + syncBangBashSuppression(); + autoResizeTextarea(dom.inputEl); + }; + dom.inputEl.addEventListener('input', inputHandler); + dom.eventCleanups.push(() => dom.inputEl.removeEventListener('input', inputHandler)); + + // Scroll listener for auto-scroll control (tracks position always, not just during streaming) + const SCROLL_THRESHOLD = 20; // pixels from bottom to consider "at bottom" + const RE_ENABLE_DELAY = 150; // ms to wait before re-enabling auto-scroll + let reEnableTimeout: number | null = null; + + const isAutoScrollAllowed = (): boolean => plugin.settings.enableAutoScroll ?? true; + + const scrollHandler = () => { + if (!isAutoScrollAllowed()) { + if (reEnableTimeout) { + window.clearTimeout(reEnableTimeout); + reEnableTimeout = null; + } + state.autoScrollEnabled = false; + return; + } + + const { scrollTop, scrollHeight, clientHeight } = dom.messagesEl; + const isAtBottom = scrollHeight - scrollTop - clientHeight <= SCROLL_THRESHOLD; + + if (!isAtBottom) { + // Immediately disable when user scrolls up + if (reEnableTimeout) { + window.clearTimeout(reEnableTimeout); + reEnableTimeout = null; + } + state.autoScrollEnabled = false; + } else if (!state.autoScrollEnabled) { + // Debounce re-enabling to avoid bounce during scroll animation + if (!reEnableTimeout) { + reEnableTimeout = window.setTimeout(() => { + reEnableTimeout = null; + // Re-verify position before enabling (content may have changed) + const { scrollTop, scrollHeight, clientHeight } = dom.messagesEl; + if (scrollHeight - scrollTop - clientHeight <= SCROLL_THRESHOLD) { + state.autoScrollEnabled = true; + } + }, RE_ENABLE_DELAY); + } + } + }; + dom.messagesEl.addEventListener('scroll', scrollHandler, { passive: true }); + dom.eventCleanups.push(() => { + dom.messagesEl.removeEventListener('scroll', scrollHandler); + if (reEnableTimeout) window.clearTimeout(reEnableTimeout); + }); +} + +/** + * Activates a tab (shows it and starts services). + */ +export function activateTab(tab: TabData): void { + tab.dom.contentEl.removeClass('claudian-hidden'); + tab.controllers.selectionController?.start(); + tab.controllers.browserSelectionController?.start(); + tab.controllers.canvasSelectionController?.start(); + // Refresh navigation sidebar visibility (dimensions now available after display) + tab.ui.navigationSidebar?.updateVisibility(); +} + +/** + * Deactivates a tab (hides it and stops services). + */ +export function deactivateTab(tab: TabData): void { + tab.dom.contentEl.addClass('claudian-hidden'); + tab.controllers.selectionController?.stop(); + tab.controllers.browserSelectionController?.stop(); + tab.controllers.canvasSelectionController?.stop(); +} + +/** + * Cleans up a tab and releases all resources. + * Made async to ensure proper cleanup ordering. + */ +export async function destroyTab(tab: TabData): Promise { + tab.lifecycleState = 'closing'; + + tab.controllers.inputController?.dismissPendingApproval(); + const activeTurn = tab.session.activeTurn; + if (activeTurn) { + tab.state.cancelRequested = true; + tab.state.bumpStreamGeneration(); + tab.service?.cancel(); + } + tab.runtimeSupervisor.cleanup(); + tab.service = null; + await activeTurn?.catch(() => undefined); + + tab.controllers.selectionController?.stop(); + tab.controllers.selectionController?.clear(); + tab.controllers.browserSelectionController?.stop(); + tab.controllers.browserSelectionController?.clear(); + tab.controllers.canvasSelectionController?.stop(); + tab.controllers.canvasSelectionController?.clear(); + tab.controllers.navigationController?.dispose(); + + cleanupThinkingBlock(tab.state.currentThinkingState); + tab.state.currentThinkingState = null; + + tab.controllers.inputController?.destroyResumeDropdown(); + tab.ui.fileContextManager?.destroy(); + tab.ui.imageContextManager?.destroy(); + tab.ui.contextTray?.destroy(); + tab.ui.contextTray = null; + tab.ui.slashCommandDropdown?.destroy(); + tab.ui.slashCommandDropdown = null; + tab.ui.instructionModeManager?.destroy(); + tab.ui.instructionModeManager = null; + tab.ui.bangBashModeManager?.destroy(); + tab.ui.bangBashModeManager = null; + tab.services.instructionRefineService?.cancel(); + tab.services.instructionRefineService?.resetConversation(); + tab.services.instructionRefineService = null; + tab.services.titleGenerationService?.cancel(); + tab.services.titleGenerationService = null; + tab.ui.statusPanel?.destroy(); + tab.ui.statusPanel = null; + tab.ui.navigationSidebar?.destroy(); + tab.ui.navigationSidebar = null; + + tab.services.subagentManager.orphanAllActive(); + tab.services.subagentManager.clear(); + + for (const cleanup of tab.dom.eventCleanups) { + cleanup(); + } + tab.dom.eventCleanups.length = 0; + + tab.dom.contentEl.remove(); +} + +/** + * Gets the display title for a tab. + * Uses synchronous access since we only need the title, not messages. + */ +export function getTabTitle(tab: TabData, plugin: FeatureHost): string { + if (tab.conversationId) { + const conversation = plugin.getConversationSync(tab.conversationId); + if (conversation?.title) { + return conversation.title; + } + } + return 'New Chat'; +} + +/** Shared between Tab.ts and TabManager.ts to avoid duplication. */ +export function setupServiceCallbacks(tab: TabData, plugin: FeatureHost): void { + if (tab.service && tab.controllers.inputController) { + tab.service.setApprovalCallback( + async (toolName, input, description, options) => + await tab.controllers.inputController?.handleApprovalRequest(toolName, input, description, options) + ?? 'cancel' + ); + tab.service.setApprovalDismisser( + () => tab.controllers.inputController?.dismissPendingApprovalPrompt() + ); + tab.service.setAskUserQuestionCallback( + async (input, signal) => + await tab.controllers.inputController?.handleAskUserQuestion(input, signal) + ?? null + ); + tab.service.setExitPlanModeCallback( + async (input, signal) => { + const decision = await tab.controllers.inputController?.handleExitPlanMode(input, signal) ?? null; + // Revert only on approve; feedback and cancel keep plan mode active. + if (decision !== null && decision.type !== 'feedback') { + // Only restore permission mode if still in plan mode — user may have toggled out via Shift+Tab + if (getTabPermissionMode(tab, plugin) === 'plan') { + const restoreMode = tab.state.prePlanPermissionMode ?? 'normal'; + try { + await updatePlanModeUI(tab, plugin, restoreMode); + } finally { + if (getTabPermissionMode(tab, plugin) !== 'plan') { + tab.state.prePlanPermissionMode = null; + } + } + } + if (decision.type === 'approve-new-session') { + tab.state.pendingNewSessionPlan = decision.planContent; + tab.state.cancelRequested = true; + } + } + return decision; + } + ); + tab.service.setSubagentHookProvider( + () => ({ + hasRunning: tab.services.subagentManager.hasRunningSubagents(), + }) + ); + tab.service.setAutoTurnCallback((result: AutoTurnResult) => renderAutoTriggeredTurn(tab, result)); + tab.service.setPermissionModeSyncCallback((sdkMode) => { + const mode = sdkMode === 'bypassPermissions' || sdkMode === 'yolo' + ? 'yolo' + : sdkMode === 'plan' + ? 'plan' + : 'normal'; + const currentMode = getTabPermissionMode(tab, plugin); + + if (currentMode !== mode) { + let capturedPrePlanMode = false; + // Save pre-plan mode when entering plan (for Shift+Tab toggle restore) + if (mode === 'plan' && tab.state.prePlanPermissionMode === null) { + tab.state.prePlanPermissionMode = currentMode; + capturedPrePlanMode = true; + } + void updatePlanModeUI(tab, plugin, mode).catch((error: unknown) => { + if (capturedPrePlanMode && getTabPermissionMode(tab, plugin) !== 'plan') { + tab.state.prePlanPermissionMode = null; + } + new Notice(error instanceof Error ? error.message : 'Failed to change permission mode.'); + }); + } + }); + } +} + +function generateMessageId(): string { + return `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Renders an auto-triggered turn (e.g., agent response to task-notification) + * that arrives after the main handler has completed. + */ +function isVisibleAutoTurnChunk(chunk: StreamChunk, hiddenToolIds: Set): boolean { + switch (chunk.type) { + case 'text': + return chunk.content.trim().length > 0; + case 'thinking': + case 'notice': + case 'error': + case 'tool_output': + case 'context_compacted': + case 'subagent_tool_use': + case 'subagent_tool_result': + return true; + case 'tool_use': + return chunk.name !== TOOL_AGENT_OUTPUT; + case 'tool_result': + return !hiddenToolIds.has(chunk.id); + default: + return false; + } +} + +function hasVisibleAutoTurnMessageContent(msg: ChatMessage): boolean { + if (msg.content.trim().length > 0) return true; + if (msg.toolCalls && msg.toolCalls.length > 0) return true; + return msg.contentBlocks?.some(block => + block.type !== 'text' || block.content.trim().length > 0 + ) ?? false; +} + +async function renderAutoTriggeredTurn(tab: TabData, result: AutoTurnResult): Promise { + if (!tab.dom.contentEl.isConnected) { + return; + } + + const { chunks, metadata } = result; + if (chunks.length === 0) return; + + const hiddenToolIds = new Set( + chunks + .filter((chunk): chunk is Extract => + chunk.type === 'tool_use' && chunk.name === TOOL_AGENT_OUTPUT + ) + .map(chunk => chunk.id) + ); + const hasVisibleContent = chunks.some(chunk => isVisibleAutoTurnChunk(chunk, hiddenToolIds)); + + const assistantMsg: ChatMessage = { + id: metadata.assistantMessageId ?? generateMessageId(), + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [], + contentBlocks: [], + ...(metadata.assistantMessageId && { assistantMessageId: metadata.assistantMessageId }), + }; + + const previousContentEl = tab.state.currentContentEl; + const previousTextEl = tab.state.currentTextEl; + const previousTextContent = tab.state.currentTextContent; + const previousThinkingState = tab.state.currentThinkingState; + + if (hasVisibleContent) { + tab.state.addMessage(assistantMsg); + const msgEl = tab.renderer?.addMessage?.(assistantMsg); + const contentEl = msgEl?.querySelector('.claudian-message-content'); + if (contentEl) { + if (!previousContentEl) { + tab.state.toolCallElements.clear(); + } + tab.state.currentContentEl = contentEl; + tab.state.currentTextEl = null; + tab.state.currentTextContent = ''; + tab.state.currentThinkingState = null; + } + } + + try { + for (const chunk of chunks) { + await tab.controllers.streamController?.handleStreamChunk(chunk, assistantMsg); + } + + if (hasVisibleContent && !hasVisibleAutoTurnMessageContent(assistantMsg)) { + const placeholder = '(background task completed)'; + assistantMsg.content = placeholder; + await tab.controllers.streamController?.appendText(placeholder); + } + + if (hasVisibleContent) { + await tab.controllers.streamController?.finalizeCurrentThinkingBlock(assistantMsg); + await tab.controllers.streamController?.finalizeCurrentTextBlock(assistantMsg); + } + } finally { + if (hasVisibleContent) { + tab.controllers.streamController?.hideThinkingIndicator(); + tab.services.subagentManager.resetStreamingState?.(); + tab.state.currentContentEl = previousContentEl; + tab.state.currentTextEl = previousTextEl; + tab.state.currentTextContent = previousTextContent; + tab.state.currentThinkingState = previousThinkingState; + tab.renderer?.scrollToBottom(); + } + } +} + +export async function updatePlanModeUI( + tab: TabData, + plugin: FeatureHost, + mode: string, +): Promise { + const providerId = getTabProviderId(tab, plugin); + const uiConfig = ProviderRegistry.getChatUIConfig(providerId); + try { + await plugin.mutateSettings((settings) => { + const snapshot = getWritableTabSettingsSnapshot(tab, plugin, settings); + if (uiConfig.applyPermissionMode) { + uiConfig.applyPermissionMode(mode, snapshot); + } else { + snapshot.permissionMode = mode; + } + ProviderSettingsCoordinator.commitProviderSettingsSnapshot( + settings, + providerId, + snapshot, + ); + }); + } finally { + const activeMode = getTabPermissionMode(tab, plugin); + tab.ui.permissionToggle?.updateDisplay(); + tab.dom.inputWrapper.toggleClass( + 'claudian-input-plan-mode', + activeMode === 'plan' && getTabCapabilities(tab, plugin).supportsPlanMode, + ); + } +} diff --git a/src/features/chat/tabs/TabBar.ts b/src/features/chat/tabs/TabBar.ts new file mode 100644 index 0000000..9bd7ab0 --- /dev/null +++ b/src/features/chat/tabs/TabBar.ts @@ -0,0 +1,191 @@ +import { scheduleAnimationFrame } from '../../../utils/animationFrame'; +import type { TabBarItem, TabId } from './types'; + +const EXPANDED_TITLE_MAX_LENGTH = 32; +const TRUNCATED_TITLE_SUFFIX = '...'; + +/** Callbacks for TabBar interactions. */ +export interface TabBarCallbacks { + /** Called when a tab badge is clicked. */ + onTabClick: (tabId: TabId) => void; + + /** Called when the close button is clicked on a tab. */ + onTabClose: (tabId: TabId) => void; + + /** Called when the new tab button is clicked. */ + onNewTab: () => void; + + /** Called when badge title expansion state changes. */ + onTitleExpansionChanged?: (expandedTitleTabIds: TabId[]) => void; +} + +/** + * TabBar renders minimal numbered badge navigation. + */ +export class TabBar { + private containerEl: HTMLElement; + private callbacks: TabBarCallbacks; + private expandedTitleTabIds = new Set(); + private lastKnownScrollLeft = 0; + private readonly handleScroll = (): void => { + this.captureScrollPosition(); + }; + + constructor(containerEl: HTMLElement, callbacks: TabBarCallbacks) { + this.containerEl = containerEl; + this.callbacks = callbacks; + this.build(); + } + + /** Builds the tab bar UI. */ + private build(): void { + this.containerEl.addClass('claudian-tab-badges'); + this.containerEl.addEventListener('scroll', this.handleScroll); + } + + /** + * Updates the tab bar with new tab data. + * @param items Tab items to render. + */ + update(items: TabBarItem[]): void { + this.captureStableScrollPosition(); + this.pruneExpandedTitleState(items); + + // Clear existing badges + this.containerEl.empty(); + + // Render badges + for (const item of items) { + this.renderBadge(item); + } + + this.restoreScrollPosition(); + } + + getExpandedTitleTabIds(): TabId[] { + return Array.from(this.expandedTitleTabIds); + } + + setExpandedTitleTabIds(tabIds: readonly TabId[]): void { + this.expandedTitleTabIds = new Set(tabIds); + } + + /** Renders a single tab badge. */ + private renderBadge(item: TabBarItem): void { + // Determine state class (priority: active > attention > streaming > idle) + let stateClass = 'claudian-tab-badge-idle'; + if (item.isActive) { + stateClass = 'claudian-tab-badge-active'; + } else if (item.needsAttention) { + stateClass = 'claudian-tab-badge-attention'; + } else if (item.isStreaming) { + stateClass = 'claudian-tab-badge-streaming'; + } + + const isTitleExpanded = this.expandedTitleTabIds.has(item.id); + const badgeEl = this.containerEl.createDiv({ + cls: [ + 'claudian-tab-badge', + stateClass, + isTitleExpanded ? 'claudian-tab-badge-expanded' : '', + ].filter(Boolean).join(' '), + text: this.getBadgeLabel(item), + }); + + // Obsidian uses aria-label for hover tooltips here; adding title causes duplicate tooltip text. + badgeEl.setAttribute('aria-label', item.title); + badgeEl.setAttribute('data-provider', item.providerId); + badgeEl.setAttribute('data-title-expanded', isTitleExpanded ? 'true' : 'false'); + + // Click handler to switch tab + badgeEl.addEventListener('click', () => { + this.captureScrollPosition(); + this.callbacks.onTabClick(item.id); + }); + + badgeEl.addEventListener('dblclick', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.toggleBadgeTitle(item, badgeEl); + }); + + // Right-click to close (if allowed) + if (item.canClose) { + badgeEl.addEventListener('contextmenu', (e) => { + e.preventDefault(); + this.callbacks.onTabClose(item.id); + }); + } + } + + /** Destroys the tab bar. */ + destroy(): void { + this.containerEl.empty(); + this.containerEl.removeClass('claudian-tab-badges'); + this.containerEl.removeEventListener('scroll', this.handleScroll); + this.expandedTitleTabIds.clear(); + this.lastKnownScrollLeft = 0; + } + + captureScrollPosition(): void { + this.lastKnownScrollLeft = this.containerEl.scrollLeft; + } + + restoreScrollPosition(): void { + const scrollLeft = this.lastKnownScrollLeft; + this.containerEl.scrollLeft = scrollLeft; + if (scrollLeft <= 0) return; + + scheduleAnimationFrame(() => { + if (this.containerEl.scrollLeft !== 0) return; + this.containerEl.scrollLeft = scrollLeft; + }, this.containerEl.ownerDocument.defaultView ?? null); + } + + private captureStableScrollPosition(): void { + const currentScrollLeft = this.containerEl.scrollLeft; + if (currentScrollLeft > 0 || this.lastKnownScrollLeft === 0) { + this.lastKnownScrollLeft = currentScrollLeft; + } + } + + private pruneExpandedTitleState(items: TabBarItem[]): void { + const visibleTabIds = new Set(items.map(item => item.id)); + for (const tabId of this.expandedTitleTabIds) { + if (!visibleTabIds.has(tabId)) { + this.expandedTitleTabIds.delete(tabId); + } + } + } + + private toggleBadgeTitle(item: TabBarItem, badgeEl: HTMLElement): void { + if (this.expandedTitleTabIds.has(item.id)) { + this.expandedTitleTabIds.delete(item.id); + } else { + this.expandedTitleTabIds.add(item.id); + } + + const isTitleExpanded = this.expandedTitleTabIds.has(item.id); + badgeEl.textContent = this.getBadgeLabel(item); + badgeEl.toggleClass('claudian-tab-badge-expanded', isTitleExpanded); + badgeEl.setAttribute('data-title-expanded', isTitleExpanded ? 'true' : 'false'); + this.callbacks.onTitleExpansionChanged?.(this.getExpandedTitleTabIds()); + } + + private getBadgeLabel(item: TabBarItem): string { + if (!this.expandedTitleTabIds.has(item.id)) { + return String(item.index); + } + + return this.truncateExpandedTitle(item.title); + } + + private truncateExpandedTitle(title: string): string { + const chars = Array.from(title); + if (chars.length <= EXPANDED_TITLE_MAX_LENGTH) { + return title; + } + + return `${chars.slice(0, EXPANDED_TITLE_MAX_LENGTH - TRUNCATED_TITLE_SUFFIX.length).join('')}${TRUNCATED_TITLE_SUFFIX}`; + } +} diff --git a/src/features/chat/tabs/TabManager.ts b/src/features/chat/tabs/TabManager.ts new file mode 100644 index 0000000..d84e6f2 --- /dev/null +++ b/src/features/chat/tabs/TabManager.ts @@ -0,0 +1,1065 @@ +import { Notice } from 'obsidian'; + +import { ProviderRegistry } from '../../../core/providers/ProviderRegistry'; +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import { ProviderWorkspaceRegistry } from '../../../core/providers/ProviderWorkspaceRegistry'; +import type { + ProviderId, + ProviderTabWarmupContext, + ProviderTabWarmupMode, +} from '../../../core/providers/types'; +import type { ChatRuntime } from '../../../core/runtime/ChatRuntime'; +import type { Conversation, SlashCommand } from '../../../core/types'; +import { t } from '../../../i18n/i18n'; +import { chooseForkTarget } from '../../../shared/modals/ForkTargetModal'; +import { revealWorkspaceLeaf } from '../../../utils/obsidianCompat'; +import type { FeatureHost } from '../../FeatureHost'; +import { getTabProviderId } from './providerResolution'; +import { + activateTab, + createTab, + deactivateTab, + destroyTab, + type ForkContext, + getTabTitle, + initializeTabControllers, + initializeTabService, + initializeTabUI, + setupServiceCallbacks, + wireTabInputEvents, +} from './Tab'; +import { + DEFAULT_MAX_TABS, + MAX_TABS, + MIN_TABS, + type PersistedTabManagerState, + type PersistedTabState, + type TabBarItem, + type TabData, + type TabId, + type TabManagerCallbacks, + type TabManagerInterface, + type TabManagerViewHost, +} from './types'; + +function isTabManagerViewHost(value: unknown): value is TabManagerViewHost { + return !!value + && typeof value === 'object' + && 'getTabManager' in (value as Record); +} + +type CreateTabOptions = { + activate?: boolean; + draftModel?: string; +}; + +type OpenConversationOptions = { + preferNewTab?: boolean; + activate?: boolean; +}; + +type ProviderCommandCacheEntry = { + commands: SlashCommand[]; + key: string; +}; + +type ProviderWarmupContext = { + conversation: Conversation | null; + externalContextPaths: string[]; + runtime: ChatRuntime | null; + tab: ProviderTabWarmupContext['tab']; + warmupMode: ProviderTabWarmupMode; +}; + +type ProviderCommandContext = ProviderWarmupContext & { + cacheKey: string; +}; + +type ProviderCommandWarmupEntry = { + key: string; + promise: Promise; +}; + +/** + * TabManager coordinates multiple chat tabs. + */ +export class TabManager implements TabManagerInterface { + private plugin: FeatureHost; + private containerEl: HTMLElement; + private view: TabManagerViewHost; + + private tabs: Map = new Map(); + private activeTabId: TabId | null = null; + private callbacks: TabManagerCallbacks; + private providerCommandWarmups = new Map(); + private providerCommandCache = new Map(); + private isRestoringState = false; + + /** Guard to prevent concurrent tab switches. */ + private isSwitchingTab = false; + private pendingSwitchTabId: TabId | null = null; + private pendingTabCreations = 0; + + /** + * Gets the current max tabs limit from settings. + * Clamps to MIN_TABS and MAX_TABS bounds. + */ + private getMaxTabs(): number { + const settingsValue = this.plugin.settings.maxTabs ?? DEFAULT_MAX_TABS; + return Math.max(MIN_TABS, Math.min(MAX_TABS, settingsValue)); + } + + constructor( + plugin: FeatureHost, + containerEl: HTMLElement, + view: TabManagerViewHost, + callbacks?: TabManagerCallbacks, + ); + constructor( + plugin: FeatureHost, + legacyArg: unknown, + containerEl: HTMLElement, + view: TabManagerViewHost, + callbacks?: TabManagerCallbacks, + ); + constructor( + plugin: FeatureHost, + arg2: unknown, + arg3: HTMLElement | TabManagerViewHost, + arg4?: TabManagerViewHost | TabManagerCallbacks, + arg5: TabManagerCallbacks = {}, + ) { + this.plugin = plugin; + + if (isTabManagerViewHost(arg3)) { + this.containerEl = arg2 as HTMLElement; + this.view = arg3; + this.callbacks = (arg4 as TabManagerCallbacks | undefined) ?? {}; + return; + } + + this.containerEl = arg3; + this.view = arg4 as TabManagerViewHost; + this.callbacks = arg5; + } + + // ============================================ + // Tab Lifecycle + // ============================================ + + /** + * Creates a new tab. + * @param conversationId Optional conversation to load into the tab. + * @param tabId Optional tab ID (for restoration). + * @param options Controls whether the new tab becomes active immediately. + * @returns The created tab, or null if max tabs reached. + */ + async createTab( + conversationId?: string | null, + tabId?: TabId, + options: CreateTabOptions = {}, + ): Promise { + const maxTabs = this.getMaxTabs(); + if (this.tabs.size + this.pendingTabCreations >= maxTabs) { + return null; + } + this.pendingTabCreations += 1; + let reservationHeld = true; + + try { + const { activate = true, draftModel } = options; + + const conversation = conversationId + ? await this.plugin.getConversationById(conversationId) + : undefined; + + // Inherit the active tab's provider so the new blank tab picks up its model + const activeTab = this.getActiveTab(); + const defaultProviderId = conversation + ? undefined + : (activeTab ? getTabProviderId(activeTab, this.plugin) : undefined); + + const tab = createTab({ + plugin: this.plugin, + containerEl: this.containerEl, + conversation: conversation ?? undefined, + tabId, + ...(typeof draftModel === 'string' ? { draftModel } : {}), + defaultProviderId, + onStreamingChanged: (isStreaming) => { + this.callbacks.onTabStreamingChanged?.(tab.id, isStreaming); + }, + onTitleChanged: (title) => { + this.callbacks.onTabTitleChanged?.(tab.id, title); + }, + onAttentionChanged: (needsAttention) => { + this.callbacks.onTabAttentionChanged?.(tab.id, needsAttention); + }, + onConversationIdChanged: (conversationId) => { + // Sync tab.conversationId when conversation is lazily created + tab.conversationId = conversationId; + this.callbacks.onTabConversationChanged?.(tab.id, conversationId); + }, + }); + + // Initialize UI components with provider catalog + initializeTabUI(tab, this.plugin, { + getProviderCatalogConfig: () => this.getProviderCatalogConfig(tab), + onProviderChanged: (providerId) => { + this.callbacks.onTabProviderChanged?.(tab.id, providerId); + void this.prewarmProviderTab(tab).catch(() => { + // Keep provider switching non-blocking even if command warmup fails. + }); + }, + }); + + initializeTabControllers( + tab, + this.plugin, + this.view, + (forkContext) => this.handleForkRequest(forkContext), + (conversationId) => this.openConversation(conversationId), + () => this.getProviderCatalogConfig(tab), + ); + + // Wire input event handlers + wireTabInputEvents(tab, this.plugin); + + this.tabs.set(tab.id, tab); + this.pendingTabCreations -= 1; + reservationHeld = false; + this.callbacks.onTabCreated?.(tab); + + if (!this.isRestoringState && (activate || !this.activeTabId)) { + await this.switchToTab(tab.id); + } else if (!this.isRestoringState) { + this.maybePrimeProviderRuntime(tab); + } + + return tab; + } finally { + if (reservationHeld) { + this.pendingTabCreations -= 1; + } + } + } + + /** + * Switches to a different tab. + * @param tabId The tab to switch to. + */ + async switchToTab(tabId: TabId): Promise { + const tab = this.tabs.get(tabId); + if (!tab) { + return; + } + + // Guard against concurrent tab switches + if (this.isSwitchingTab) { + this.pendingSwitchTabId = tabId; + return; + } + + this.isSwitchingTab = true; + const previousTabId = this.activeTabId; + + try { + // Deactivate current tab + if (previousTabId && previousTabId !== tabId) { + const currentTab = this.tabs.get(previousTabId); + if (currentTab) { + deactivateTab(currentTab); + } + } + + // Activate new tab + this.activeTabId = tabId; + activateTab(tab); + this.callbacks.onActiveTabChanged?.(previousTabId, tabId); + + // Load conversation if not already loaded + if (tab.conversationId && tab.state.messages.length === 0) { + await tab.controllers.conversationController?.switchTo(tab.conversationId); + } else if ( + tab.conversationId + && tab.state.messages.length > 0 + && tab.service + && !tab.state.isStreaming + && !tab.state.hasPendingConversationSave + ) { + // Passive sync is only safe once local tab state has been persisted. + const conversation = this.plugin.getConversationSync(tab.conversationId); + if (conversation) { + const hasMessages = conversation.messages.length > 0; + const externalContextPaths = hasMessages + ? conversation.externalContextPaths || [] + : (this.plugin.settings.persistentExternalContextPaths || []); + + tab.service.syncConversationState(conversation, externalContextPaths); + } + } else if (!tab.conversationId && tab.state.messages.length === 0) { + // New tab with no conversation - initialize welcome greeting + tab.controllers.conversationController?.initializeWelcome(); + } + + this.callbacks.onTabSwitched?.(previousTabId, tabId); + this.maybePrimeProviderRuntime(tab); + } finally { + this.isSwitchingTab = false; + const pendingTabId = this.pendingSwitchTabId; + this.pendingSwitchTabId = null; + if (pendingTabId && pendingTabId !== this.activeTabId) { + await this.switchToTab(pendingTabId); + } + } + } + + /** + * Closes a tab. + * @param tabId The tab to close. + * @param force If true, close even if streaming. + * @returns True if the tab was closed. + */ + async closeTab(tabId: TabId, force = false): Promise { + const tab = this.tabs.get(tabId); + if (!tab) { + return false; + } + + // Don't close if streaming unless forced + if (tab.state.isStreaming && !force) { + return false; + } + + // If this is the last tab and it's already empty (no conversation), + // don't close it - it's already a blank draft container. + if (this.tabs.size === 1 && !tab.conversationId && tab.state.messages.length === 0) { + return false; + } + + // Save conversation before closing. Cleanup remains mandatory if save fails. + let saveError: unknown; + let didSaveFail = false; + try { + await tab.controllers.conversationController?.save(); + } catch (error) { + didSaveFail = true; + saveError = error; + } + + // Capture tab order BEFORE deletion for fallback calculation + const tabIdsBefore = Array.from(this.tabs.keys()); + const closingIndex = tabIdsBefore.indexOf(tabId); + + // Destroy tab resources (async for proper cleanup) + await destroyTab(tab); + this.providerCommandWarmups.delete(tabId); + this.providerCommandCache.delete(tabId); + this.tabs.delete(tabId); + this.callbacks.onTabClosed?.(tabId); + + // If we closed the active tab, switch to another + if (this.activeTabId === tabId) { + this.activeTabId = null; + + if (this.tabs.size > 0) { + // Fallback strategy: prefer previous tab, except for first tab (go to next) + const fallbackTabId = closingIndex === 0 + ? tabIdsBefore[1] // First tab: go to next + : tabIdsBefore[closingIndex - 1]; // Others: go to previous + + if (fallbackTabId && this.tabs.has(fallbackTabId)) { + await this.switchToTab(fallbackTabId); + } + } else { + // Create a replacement blank tab. + await this.createTab(); + } + } + + if (didSaveFail) { + throw saveError; + } + return true; + } + + // ============================================ + // Tab Queries + // ============================================ + + /** Gets the currently active tab. */ + getActiveTab(): TabData | null { + return this.activeTabId ? this.tabs.get(this.activeTabId) ?? null : null; + } + + /** Gets the active tab ID. */ + getActiveTabId(): TabId | null { + return this.activeTabId; + } + + /** Gets a tab by ID. */ + getTab(tabId: TabId): TabData | null { + return this.tabs.get(tabId) ?? null; + } + + /** Gets all tabs. */ + getAllTabs(): TabData[] { + return Array.from(this.tabs.values()); + } + + /** Gets the number of tabs. */ + getTabCount(): number { + return this.tabs.size; + } + + /** Checks if more tabs can be created. */ + canCreateTab(): boolean { + return this.tabs.size < this.getMaxTabs(); + } + + // ============================================ + // Tab Bar Data + // ============================================ + + /** Gets data for rendering the tab bar. */ + getTabBarItems(): TabBarItem[] { + const items: TabBarItem[] = []; + let index = 1; + + for (const tab of this.tabs.values()) { + items.push({ + id: tab.id, + index: index++, + title: getTabTitle(tab, this.plugin), + providerId: getTabProviderId(tab, this.plugin), + isActive: tab.id === this.activeTabId, + isStreaming: tab.state.isStreaming, + needsAttention: tab.state.needsAttention, + canClose: this.tabs.size > 1 || !tab.state.isStreaming, + }); + } + + return items; + } + + // ============================================ + // Conversation Management + // ============================================ + + /** + * Opens a conversation in a new tab or existing tab. + * @param conversationId The conversation to open. + * @param options Controls tab creation behavior (backward-compatible with boolean). + */ + async openConversation( + conversationId: string, + options: boolean | OpenConversationOptions = false, + ): Promise { + const preferNewTab = typeof options === 'boolean' + ? options + : options.preferNewTab ?? false; + const activate = typeof options === 'boolean' + ? true + : options.activate ?? true; + + // Check if conversation is already open in this view's tabs + for (const tab of this.tabs.values()) { + if (tab.conversationId === conversationId) { + await this.switchToTab(tab.id); + return; + } + } + + // Check if conversation is open in another view (split workspace scenario) + // Compare view references directly (more robust than leaf comparison) + const crossViewResult = this.plugin.findConversationAcrossViews(conversationId); + const isSameView = crossViewResult?.view === this.view; + if (crossViewResult && !isSameView) { + // Focus the other view and switch to its tab instead of opening duplicate + await revealWorkspaceLeaf(this.plugin.app.workspace, crossViewResult.view.leaf); + await crossViewResult.view.getTabManager()?.switchToTab(crossViewResult.tabId); + return; + } + + // Open in current tab or new tab + if (preferNewTab && this.canCreateTab()) { + await this.createTab(conversationId, undefined, { activate }); + } else { + // Open in current tab + // Note: Don't set tab.conversationId here - the onConversationIdChanged callback + // will sync it after successful switch. Setting it before switchTo() would cause + // incorrect tab metadata if switchTo() returns early (streaming/switching/creating). + const activeTab = this.getActiveTab(); + if (activeTab) { + await activeTab.controllers.conversationController?.switchTo(conversationId); + } + } + } + + /** + * Creates a new conversation in the active tab. + */ + async createNewConversation(): Promise { + const activeTab = this.getActiveTab(); + if (activeTab) { + await activeTab.controllers.conversationController?.createNew(); + // Sync tab.conversationId with the newly created conversation + activeTab.conversationId = activeTab.state.currentConversationId; + this.maybePrimeProviderRuntime(activeTab); + } + } + + invalidateProviderCommandCaches(providerIds?: ProviderId | ProviderId[]): void { + for (const tab of this.filterTabsByProvider(providerIds, (tab) => getTabProviderId(tab, this.plugin))) { + this.providerCommandWarmups.delete(tab.id); + this.providerCommandCache.delete(tab.id); + tab.ui?.slashCommandDropdown?.resetSdkSkillsCache(); + } + } + + primeProviderRuntime(providerIds?: ProviderId | ProviderId[]): void { + for (const tab of this.filterTabsByProvider(providerIds, (tab) => tab.service?.providerId ?? tab.providerId)) { + this.maybePrimeProviderRuntime(tab); + } + } + + private *filterTabsByProvider( + providerIds: ProviderId | ProviderId[] | undefined, + resolve: (tab: TabData) => ProviderId, + ): Iterable { + const filter = providerIds + ? new Set(Array.isArray(providerIds) ? providerIds : [providerIds]) + : null; + + for (const tab of this.tabs.values()) { + if (filter && !filter.has(resolve(tab))) { + continue; + } + yield tab; + } + } + + // ============================================ + // Fork + // ============================================ + + private async handleForkRequest(context: ForkContext): Promise { + const target = await chooseForkTarget(this.plugin.app); + if (!target) return; + + if (target === 'new-tab') { + const tab = await this.forkToNewTab(context); + if (!tab) { + const maxTabs = this.getMaxTabs(); + new Notice(t('chat.fork.maxTabsReached', { count: String(maxTabs) })); + return; + } + new Notice(t('chat.fork.notice')); + } else { + const success = await this.forkInCurrentTab(context); + if (!success) { + new Notice(t('chat.fork.failed', { error: t('chat.fork.errorNoActiveTab') })); + return; + } + new Notice(t('chat.fork.noticeCurrentTab')); + } + } + + async forkToNewTab(context: ForkContext): Promise { + const maxTabs = this.getMaxTabs(); + if (this.tabs.size >= maxTabs) { + return null; + } + + const conversationId = await this.createForkConversation(context); + try { + return await this.createTab(conversationId); + } catch (error) { + await this.plugin.deleteConversation(conversationId).catch(() => {}); + throw error; + } + } + + async forkInCurrentTab(context: ForkContext): Promise { + const activeTab = this.getActiveTab(); + if (!activeTab?.controllers.conversationController) return false; + + const conversationId = await this.createForkConversation(context); + try { + await activeTab.controllers.conversationController.switchTo(conversationId); + } catch (error) { + await this.plugin.deleteConversation(conversationId).catch(() => {}); + throw error; + } + return true; + } + + private async createForkConversation(context: ForkContext): Promise { + const conversation = await this.plugin.createConversation({ + providerId: context.providerId, + ...(context.sourceSelectedModel ? { selectedModel: context.sourceSelectedModel } : {}), + }); + + const title = context.sourceTitle + ? this.buildForkTitle(context.sourceTitle, context.forkAtUserMessage) + : undefined; + + const forkProviderState = ProviderRegistry + .getConversationHistoryService(conversation.providerId) + .buildForkProviderState( + context.sourceSessionId, + context.resumeAt, + context.sourceProviderState, + ); + + await this.plugin.updateConversation(conversation.id, { + messages: context.messages, + providerState: forkProviderState, + ...(title && { title }), + ...(context.currentNote && { currentNote: context.currentNote }), + }); + + return conversation.id; + } + + private buildForkTitle(sourceTitle: string, forkAtUserMessage?: number): string { + const MAX_TITLE_LENGTH = 50; + const forkSuffix = forkAtUserMessage ? ` (#${forkAtUserMessage})` : ''; + const forkPrefix = 'Fork: '; + const maxSourceLength = MAX_TITLE_LENGTH - forkPrefix.length - forkSuffix.length; + const truncatedSource = sourceTitle.length > maxSourceLength + ? sourceTitle.slice(0, maxSourceLength - 1) + '…' + : sourceTitle; + let title = forkPrefix + truncatedSource + forkSuffix; + + const existingTitles = new Set(this.plugin.getConversationList().map(c => c.title)); + if (existingTitles.has(title)) { + let n = 2; + while (existingTitles.has(`${title} ${n}`)) n++; + title = `${title} ${n}`; + } + + return title; + } + + // ============================================ + // Persistence + // ============================================ + + /** Gets the state to persist. */ + getPersistedState(): PersistedTabManagerState { + const openTabs: PersistedTabState[] = []; + + for (const tab of this.tabs.values()) { + openTabs.push({ + ...(tab.lifecycleState === 'blank' && tab.draftModel + ? { draftModel: tab.draftModel } + : {}), + tabId: tab.id, + conversationId: tab.conversationId, + }); + } + + return { + openTabs, + activeTabId: this.activeTabId, + }; + } + + /** Restores state from persisted data. */ + async restoreState(state: PersistedTabManagerState): Promise { + this.isRestoringState = true; + try { + // Create tabs from persisted state with error handling. + for (const tabState of state.openTabs) { + try { + await this.createTab(tabState.conversationId, tabState.tabId, { + activate: false, + ...(typeof tabState.draftModel === 'string' ? { draftModel: tabState.draftModel } : {}), + }); + } catch { + // Continue restoring other tabs + } + } + } finally { + this.isRestoringState = false; + } + + const fallbackTabId = state.openTabs.find((tabState) => this.tabs.has(tabState.tabId))?.tabId + ?? Array.from(this.tabs.keys())[0] + ?? null; + const targetTabId = state.activeTabId && this.tabs.has(state.activeTabId) + ? state.activeTabId + : fallbackTabId; + + // Switch to the previously active tab after all tabs are restored so background + // restore does not warm the first restored tab by accident. + if (targetTabId) { + try { + await this.switchToTab(targetTabId); + } catch { + // Ignore switch errors + } + } + + // If no tabs were restored, create a default one + if (this.tabs.size === 0) { + await this.createTab(); + } + } + + // ============================================ + // SDK Commands (Shared) + // ============================================ + + /** + * Gets provider-scoped SDK supported commands for a tab. + * Reuses a ready runtime from the same provider when available to avoid + * leaking commands across providers in mixed-provider workspaces. + * @returns Array of SDK commands, or empty array if no service is ready. + */ + async getSdkCommands(tabId?: TabId): Promise { + const targetTab = (tabId ? this.tabs.get(tabId) : this.getActiveTab()) ?? null; + if (!targetTab) { + return []; + } + + const providerId = getTabProviderId(targetTab, this.plugin); + const staticCapabilities = ProviderRegistry.getCapabilities(providerId); + if (!staticCapabilities.supportsProviderCommands) { + return []; + } + + const catalog = ProviderWorkspaceRegistry.getCommandCatalog(providerId); + const runtimeCommandLoader = ProviderWorkspaceRegistry.getRuntimeCommandLoader(providerId); + const context = await this.buildProviderWarmupContext(targetTab, providerId); + if ( + targetTab.lifecycleState === 'blank' + && runtimeCommandLoader + && (context.warmupMode !== 'commands' || targetTab.id !== this.activeTabId) + ) { + catalog?.setRuntimeCommands([]); + return []; + } + + let sdkCommands: SlashCommand[] = []; + + const targetService = targetTab.service; + if (targetService?.providerId === providerId && targetService.isReady()) { + sdkCommands = await targetService.getSupportedCommands(); + } else if (!runtimeCommandLoader) { + for (const tab of this.tabs.values()) { + if (tab.id === targetTab.id) { + continue; + } + if (tab.service?.providerId === providerId && tab.service.isReady()) { + sdkCommands = await tab.service.getSupportedCommands(); + break; + } + } + } + + if (sdkCommands.length === 0) { + sdkCommands = await this.ensureProviderCommandRuntime(targetTab, providerId, context); + } + + catalog?.setRuntimeCommands(sdkCommands); + + return sdkCommands; + } + + private async ensureProviderCommandRuntime( + tab: TabData, + providerId: ProviderId, + warmupContext?: ProviderWarmupContext, + ): Promise { + if (!this.isProviderCommandLoaderAvailable(providerId)) { + return []; + } + + const resolvedWarmupContext = warmupContext + ?? await this.buildProviderWarmupContext(tab, providerId); + const context = this.buildProviderCommandContext( + tab, + providerId, + resolvedWarmupContext, + ); + const cached = this.providerCommandCache.get(tab.id); + if ( + (!context.runtime || !context.runtime.isReady()) + && cached + && cached.key === context.cacheKey + ) { + return cached.commands.map((command) => ({ ...command })); + } + + const existing = this.providerCommandWarmups.get(tab.id); + if (existing?.key === context.cacheKey) { + return await existing.promise; + } + this.providerCommandWarmups.delete(tab.id); + + const warmup = this.warmProviderCommandRuntime(tab, providerId, context).finally(() => { + if (this.providerCommandWarmups.get(tab.id)?.promise === warmup) { + this.providerCommandWarmups.delete(tab.id); + } + }); + this.providerCommandWarmups.set(tab.id, { + key: context.cacheKey, + promise: warmup, + }); + return await warmup; + } + + private maybePrimeProviderRuntime(tab: TabData): void { + void this.prewarmProviderTab(tab).catch(() => {}); + } + + private isProviderCommandLoaderAvailable(providerId: ProviderId): boolean { + const loader = ProviderWorkspaceRegistry.getRuntimeCommandLoader(providerId); + if (!loader) return false; + return loader.isAvailable(this.plugin.settings); + } + + private async prewarmProviderTab(tab: TabData): Promise { + const providerId = tab.service?.providerId ?? tab.providerId; + const context = await this.buildProviderWarmupContext(tab, providerId); + const hasReadyRuntime = tab.service?.providerId === providerId && tab.service.isReady(); + if (!hasReadyRuntime && tab.id !== this.activeTabId) { + return; + } + + switch (context.warmupMode) { + case 'commands': + await this.getSdkCommands(tab.id); + return; + case 'runtime': + await this.ensureProviderTabRuntimeReady(tab, providerId, context); + return; + default: + return; + } + } + + private async ensureProviderTabRuntimeReady( + tab: TabData, + providerId: ProviderId, + context: ProviderWarmupContext, + ): Promise { + if (!context.runtime || context.runtime.providerId !== providerId || !tab.serviceInitialized) { + await initializeTabService(tab, this.plugin, context.conversation); + setupServiceCallbacks(tab, this.plugin); + } + + const runtime = tab.service?.providerId === providerId ? tab.service : null; + if (!runtime) { + return; + } + + runtime.syncConversationState(context.conversation, context.externalContextPaths); + await runtime.ensureReady(); + if (ProviderRegistry.getCapabilities(providerId).supportsProviderCommands) { + await this.getSdkCommands(tab.id); + } + } + + private async buildProviderWarmupContext( + tab: TabData, + providerId: ProviderId, + ): Promise { + const conversation = tab.conversationId + ? await this.plugin.getConversationById(tab.conversationId) + : null; + const hasConversationContext = (conversation?.messages.length ?? 0) > 0; + const externalContextPaths = tab.ui.externalContextSelector?.getExternalContexts() + ?? (hasConversationContext + ? conversation?.externalContextPaths ?? [] + : this.plugin.settings.persistentExternalContextPaths ?? []); + const runtime = tab.service?.providerId === providerId ? tab.service : null; + const warmupMode = this.resolveProviderTabWarmupMode({ + conversation, + externalContextPaths, + plugin: this.plugin.providerHost, + runtime, + tab: { + conversationId: tab.conversationId, + draftModel: tab.draftModel, + lifecycleState: tab.lifecycleState, + providerId, + }, + }); + + return { + conversation, + externalContextPaths, + runtime, + tab: { + conversationId: tab.conversationId, + draftModel: tab.draftModel, + lifecycleState: tab.lifecycleState, + providerId, + }, + warmupMode, + }; + } + + private resolveProviderTabWarmupMode(context: ProviderTabWarmupContext): ProviderTabWarmupMode { + return ProviderWorkspaceRegistry.getTabWarmupPolicy(context.tab.providerId)?.resolveMode(context) ?? 'none'; + } + + private buildProviderCommandContext( + tab: TabData, + providerId: ProviderId, + warmupContext: ProviderWarmupContext, + ): ProviderCommandContext { + const providerSettings = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + this.plugin.settings, + providerId, + ); + + return { + ...warmupContext, + cacheKey: JSON.stringify({ + allowSessionCreation: warmupContext.warmupMode === 'commands' + && tab.lifecycleState === 'blank' + && tab.id === this.activeTabId, + conversationId: warmupContext.conversation?.id ?? null, + draftModel: tab.draftModel ?? null, + externalContextPaths: warmupContext.externalContextPaths, + lifecycleState: tab.lifecycleState, + providerId, + providerSettings, + providerState: warmupContext.conversation?.providerState ?? null, + sessionId: warmupContext.conversation?.sessionId ?? null, + warmupMode: warmupContext.warmupMode, + }), + }; + } + + private async warmProviderCommandRuntime( + tab: TabData, + providerId: ProviderId, + context: ProviderCommandContext, + ): Promise { + const catalog = ProviderWorkspaceRegistry.getCommandCatalog(providerId); + const loader = ProviderWorkspaceRegistry.getRuntimeCommandLoader(providerId); + if (!catalog || !loader) { + return []; + } + const commands = await loader.loadCommands({ + allowSessionCreation: context.warmupMode === 'commands' + && tab.lifecycleState === 'blank' + && tab.id === this.activeTabId, + conversation: context.conversation, + externalContextPaths: context.externalContextPaths, + plugin: this.plugin.providerHost, + runtime: context.runtime, + }); + + if (!context.runtime || !context.runtime.isReady()) { + this.providerCommandCache.set(tab.id, { + key: context.cacheKey, + commands: commands.map((command) => ({ ...command })), + }); + } else { + this.providerCommandCache.delete(tab.id); + } + catalog.setRuntimeCommands(commands); + return commands; + } + + // ============================================ + // Provider Command Catalog + // ============================================ + + private getProviderCatalogConfig(tab: TabData) { + const providerId = getTabProviderId(tab, this.plugin); + const catalog = ProviderWorkspaceRegistry.getCommandCatalog(providerId); + if (!catalog) return null; + + return { + config: catalog.getDropdownConfig(), + getEntries: async () => { + await this.getSdkCommands(tab.id); + return catalog.listDropdownEntries({ includeBuiltIns: false }); + }, + }; + } + + // ============================================ + // Broadcast + // ============================================ + + /** + * Broadcasts a function call to all initialized tab runtimes. + * Used by settings managers to apply configuration changes to all tabs. + * @param fn Function to call on each runtime. + */ + async broadcastToAllTabs(fn: (service: ChatRuntime) => Promise): Promise { + await this.broadcastToTabs(this.tabs.values(), fn); + } + + async broadcastToProviderTabs( + providerIds: ProviderId | ProviderId[], + fn: (service: ChatRuntime) => Promise, + ): Promise { + await this.broadcastToTabs( + this.filterTabsByProvider(providerIds, (tab) => tab.service?.providerId ?? tab.providerId), + fn, + ); + } + + async recycleProviderRuntimes(providerIds: ProviderId | ProviderId[]): Promise { + const tabs = this.filterTabsByProvider( + providerIds, + (tab) => tab.service?.providerId ?? tab.providerId, + ); + for (const tab of tabs) { + tab.runtimeSupervisor.cleanup(); + tab.service = null; + tab.serviceInitialized = false; + if (tab.lifecycleState === 'bound_active') { + tab.lifecycleState = tab.conversationId ? 'bound_cold' : 'blank'; + } + } + } + + private async broadcastToTabs( + tabs: Iterable, + fn: (service: ChatRuntime) => Promise, + ): Promise { + const promises: Promise[] = []; + + for (const tab of tabs) { + if (tab.service && tab.serviceInitialized) { + promises.push( + fn(tab.service).catch(() => { + // Silently ignore broadcast errors + }) + ); + } + } + + await Promise.all(promises); + } + + // ============================================ + // Cleanup + // ============================================ + + /** Destroys all tabs and cleans up resources. */ + async destroy(): Promise { + // Save all conversations in parallel (independent per-tab) + await Promise.all( + Array.from(this.tabs.values()).map( + tab => tab.controllers.conversationController?.save() ?? Promise.resolve() + ) + ); + + // Destroy all tabs in parallel (independent per-tab, must run after saves complete) + await Promise.all(Array.from(this.tabs.values()).map(tab => destroyTab(tab))); + + this.tabs.clear(); + this.activeTabId = null; + } +} diff --git a/src/features/chat/tabs/TabSession.ts b/src/features/chat/tabs/TabSession.ts new file mode 100644 index 0000000..e914400 --- /dev/null +++ b/src/features/chat/tabs/TabSession.ts @@ -0,0 +1,28 @@ +import type { ProviderId } from '../../../core/providers/types'; +import { RuntimeSupervisor } from './RuntimeSupervisor'; +import type { TabLifecycleState } from './types'; + +export interface TabSessionState { + conversationId: string | null; + draftModel: string | null; + id: string; + lifecycleState: TabLifecycleState; + providerId: ProviderId; +} + +export class TabSession { + readonly runtimeSupervisor = new RuntimeSupervisor(); + activeTurn: Promise | null = null; + + constructor(private readonly state: TabSessionState) {} + + get id(): string { return this.state.id; } + get lifecycleState(): TabLifecycleState { return this.state.lifecycleState; } + set lifecycleState(value: TabLifecycleState) { this.state.lifecycleState = value; } + get providerId(): ProviderId { return this.state.providerId; } + set providerId(value: ProviderId) { this.state.providerId = value; } + get conversationId(): string | null { return this.state.conversationId; } + set conversationId(value: string | null) { this.state.conversationId = value; } + get draftModel(): string | null { return this.state.draftModel; } + set draftModel(value: string | null) { this.state.draftModel = value; } +} diff --git a/src/features/chat/tabs/providerResolution.ts b/src/features/chat/tabs/providerResolution.ts new file mode 100644 index 0000000..a0b39e8 --- /dev/null +++ b/src/features/chat/tabs/providerResolution.ts @@ -0,0 +1,34 @@ +import { getEnabledProviderForModel } from '../../../core/providers/modelRouting'; +import type { ProviderId } from '../../../core/providers/types'; +import type { Conversation } from '../../../core/types'; +import type { FeatureHost } from '../../FeatureHost'; +import type { TabProviderContext } from './types'; + +function getStoredConversationProviderId( + tab: TabProviderContext, + plugin: FeatureHost, +): ProviderId { + if (tab.conversationId) { + const conversation = plugin.getConversationSync(tab.conversationId); + if (conversation?.providerId) { + return conversation.providerId; + } + } + + if (tab.lifecycleState === 'blank' && tab.draftModel) { + return getEnabledProviderForModel( + tab.draftModel, + plugin.settings, + ); + } + + return tab.service?.providerId ?? tab.providerId; +} + +export function getTabProviderId( + tab: TabProviderContext, + plugin: FeatureHost, + conversation?: Conversation | null, +): ProviderId { + return conversation?.providerId ?? getStoredConversationProviderId(tab, plugin); +} diff --git a/src/features/chat/tabs/types.ts b/src/features/chat/tabs/types.ts new file mode 100644 index 0000000..25386af --- /dev/null +++ b/src/features/chat/tabs/types.ts @@ -0,0 +1,293 @@ +import type { Component, WorkspaceLeaf } from 'obsidian'; + +import type { InstructionRefineService, ProviderId, TitleGenerationService } from '../../../core/providers/types'; +import type { ChatRuntime } from '../../../core/runtime/ChatRuntime'; +import type { SlashCommandDropdown } from '../../../shared/components/SlashCommandDropdown'; +import type { BrowserSelectionController } from '../controllers/BrowserSelectionController'; +import type { CanvasSelectionController } from '../controllers/CanvasSelectionController'; +import type { ConversationController } from '../controllers/ConversationController'; +import type { InputController } from '../controllers/InputController'; +import type { NavigationController } from '../controllers/NavigationController'; +import type { SelectionController } from '../controllers/SelectionController'; +import type { StreamController } from '../controllers/StreamController'; +import type { MessageRenderer } from '../rendering/MessageRenderer'; +import type { SubagentManager } from '../services/SubagentManager'; +import type { ChatState } from '../state/ChatState'; +import type { BangBashModeManager } from '../ui/BangBashModeManager'; +import type { ComposerContextTray } from '../ui/ComposerContextTray'; +import type { FileContextManager } from '../ui/FileContext'; +import type { ImageContextManager } from '../ui/ImageContext'; +import type { + ContextUsageMeter, + ExternalContextSelector, + McpServerSelector, + ModelSelector, + ModeSelector, + PermissionToggle, + ServiceTierToggle, + ThinkingBudgetSelector, +} from '../ui/InputToolbar'; +import type { InstructionModeManager } from '../ui/InstructionModeManager'; +import type { NavigationSidebar } from '../ui/NavigationSidebar'; +import type { StatusPanel } from '../ui/StatusPanel'; +import type { RuntimeSupervisor } from './RuntimeSupervisor'; +import type { TabSession } from './TabSession'; + +/** + * Default number of tabs allowed. + * + * Set to 3 to balance usability with resource usage: + * - Each tab has its own chat runtime and persistent query + * - More tabs = more memory and potential SDK processes + * - 3 tabs allows multi-tasking without excessive overhead + */ +export const DEFAULT_MAX_TABS = 3; + +/** + * Minimum number of tabs allowed (settings floor). + */ +export const MIN_TABS = 3; + +/** + * Maximum number of tabs allowed (settings ceiling). + * Users can configure up to this many tabs via settings. + */ +export const MAX_TABS = 10; + +/** + * Minimal interface for the ClaudianView methods used by TabManager and Tab. + * Extends Component for Obsidian integration (event handling, cleanup). + * Avoids circular dependency by not importing ClaudianView directly. + */ +export interface TabManagerViewHost extends Component { + /** Reference to the workspace leaf for revealing the view. */ + leaf: WorkspaceLeaf; + + /** Gets the tab manager instance (used for cross-view coordination). */ + getTabManager(): TabManagerInterface | null; + + /** Gets view-owned elements that should preserve active tab selection context. */ + getSharedSelectionFocusScopeEls?(): HTMLElement[]; +} + +/** + * Minimal interface for TabManager methods used by external code. + * Used to break circular dependencies. + */ +export interface TabManagerInterface { + /** Switches to a specific tab. */ + switchToTab(tabId: TabId): Promise; + + /** Gets all tabs. */ + getAllTabs(): TabData[]; +} + +/** Tab identifier type. */ +export type TabId = string; + +/** Generates a unique tab ID. */ +export function generateTabId(): TabId { + return `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Controllers managed per-tab. + * Each tab has its own set of controllers for independent operation. + */ +export interface TabControllers { + selectionController: SelectionController | null; + browserSelectionController: BrowserSelectionController | null; + canvasSelectionController: CanvasSelectionController | null; + conversationController: ConversationController | null; + streamController: StreamController | null; + inputController: InputController | null; + navigationController: NavigationController | null; +} + +/** + * Services managed per-tab. + */ +export interface TabServices { + subagentManager: SubagentManager; + instructionRefineService: InstructionRefineService | null; + titleGenerationService: TitleGenerationService | null; +} + +/** + * UI components managed per-tab. + */ +export interface TabUIComponents { + contextTray: ComposerContextTray | null; + fileContextManager: FileContextManager | null; + imageContextManager: ImageContextManager | null; + modelSelector: ModelSelector | null; + modeSelector: ModeSelector | null; + thinkingBudgetSelector: ThinkingBudgetSelector | null; + externalContextSelector: ExternalContextSelector | null; + mcpServerSelector: McpServerSelector | null; + permissionToggle: PermissionToggle | null; + serviceTierToggle: ServiceTierToggle | null; + slashCommandDropdown: SlashCommandDropdown | null; + instructionModeManager: InstructionModeManager | null; + bangBashModeManager: BangBashModeManager | null; + contextUsageMeter: ContextUsageMeter | null; + statusPanel: StatusPanel | null; + navigationSidebar: NavigationSidebar | null; +} + +/** + * DOM elements managed per-tab. + */ +export interface TabDOMElements { + contentEl: HTMLElement; + messagesEl: HTMLElement; + welcomeEl: HTMLElement | null; + + /** Container for status panel (fixed between messages and input). */ + statusPanelContainerEl: HTMLElement; + + /** Per-tab composer root. Inline prompts render here as siblings of the input container. */ + inputComposerEl: HTMLElement; + inputContainerEl: HTMLElement; + queueIndicatorEl: HTMLElement; + inputWrapper: HTMLElement; + inputEl: HTMLTextAreaElement; + + /** Nav row for tab badges and header icons (above input wrapper). */ + navRowEl: HTMLElement; + + /** Composer-owned context tray container inside the input wrapper. */ + contextRowEl: HTMLElement; + + /** Cleanup functions for event listeners (prevents memory leaks). */ + eventCleanups: Array<() => void>; +} + +/** + * Tab lifecycle states: + * - `blank`: No conversation binding, no runtime. Draft model selection only. + * - `bound_cold`: Bound to a conversation, but runtime not started yet. + * - `bound_active`: Bound to a conversation with a running runtime. + * - `closing`: Tab is being torn down. + */ +export type TabLifecycleState = 'blank' | 'bound_cold' | 'bound_active' | 'closing'; + +/** + * Represents a single tab in the multi-tab system. + * Each tab is an independent chat session with its own runtime instance. + */ +export interface TabData { + /** Authoritative identity and runtime owner for the tab. */ + session: TabSession; + /** Unique tab identifier. */ + id: TabId; + + /** Explicit lifecycle state. */ + lifecycleState: TabLifecycleState; + + /** + * Draft model selected in a blank tab (before first send). + * Used to derive provider on first send. Null after binding. + */ + draftModel: string | null; + + /** Active provider for this tab's current conversation/runtime. */ + providerId: ProviderId; + + /** Conversation ID bound to this tab (null for new/empty tabs). */ + conversationId: string | null; + + /** Per-tab chat runtime instance for independent streaming. */ + service: ChatRuntime | null; + + /** Named owner of the per-tab runtime reference. */ + runtimeSupervisor: RuntimeSupervisor; + + /** Whether the service has been initialized (lazy start). */ + serviceInitialized: boolean; + + /** Per-tab chat state. */ + state: ChatState; + + /** Per-tab controllers. */ + controllers: TabControllers; + + /** Per-tab services. */ + services: TabServices; + + /** Per-tab UI components. */ + ui: TabUIComponents; + + /** Per-tab DOM elements. */ + dom: TabDOMElements; + + /** Per-tab renderer. */ + renderer: MessageRenderer | null; +} + +export type TabProviderContext = Pick; + +/** + * Persisted tab state for restoration on plugin reload. + */ +export interface PersistedTabState { + tabId: TabId; + conversationId: string | null; + draftModel?: string | null; +} + +/** + * Tab manager state persisted to data.json. + */ +export interface PersistedTabManagerState { + openTabs: PersistedTabState[]; + activeTabId: TabId | null; + expandedTitleTabIds?: TabId[]; +} + +/** + * Callbacks for tab state changes. + */ +export interface TabManagerCallbacks { + /** Called when a tab is created. */ + onTabCreated?: (tab: TabData) => void; + + /** Called immediately after the active tab changes, before async tab loading completes. */ + onActiveTabChanged?: (fromTabId: TabId | null, toTabId: TabId) => void; + + /** Called when switching to a different tab. */ + onTabSwitched?: (fromTabId: TabId | null, toTabId: TabId) => void; + + /** Called when a tab is closed. */ + onTabClosed?: (tabId: TabId) => void; + + /** Called when tab streaming state changes. */ + onTabStreamingChanged?: (tabId: TabId, isStreaming: boolean) => void; + + /** Called when tab title changes. */ + onTabTitleChanged?: (tabId: TabId, title: string) => void; + + /** Called when tab attention state changes (approval pending, etc.). */ + onTabAttentionChanged?: (tabId: TabId, needsAttention: boolean) => void; + + /** Called when a tab's conversation changes (loaded different conversation in same tab). */ + onTabConversationChanged?: (tabId: TabId, conversationId: string | null) => void; + + /** Called when the active provider changes within a tab (blank tab model selection). */ + onTabProviderChanged?: (tabId: TabId, providerId: ProviderId) => void; +} + +/** + * Tab bar item representation for rendering. + */ +export interface TabBarItem { + id: TabId; + /** 1-based index for display. */ + index: number; + title: string; + providerId: ProviderId; + isActive: boolean; + isStreaming: boolean; + needsAttention: boolean; + canClose: boolean; +} diff --git a/src/features/chat/ui/BangBashModeManager.ts b/src/features/chat/ui/BangBashModeManager.ts new file mode 100644 index 0000000..698e4a4 --- /dev/null +++ b/src/features/chat/ui/BangBashModeManager.ts @@ -0,0 +1,121 @@ +import { Notice } from 'obsidian'; + +import { t } from '../../../i18n/i18n'; + +export interface BangBashModeCallbacks { + onSubmit: (command: string) => Promise; + getInputWrapper: () => HTMLElement | null; + resetInputHeight?: () => void; +} + +export interface BangBashModeState { + active: boolean; + rawCommand: string; +} + +export class BangBashModeManager { + private inputEl: HTMLTextAreaElement; + private callbacks: BangBashModeCallbacks; + private state: BangBashModeState = { active: false, rawCommand: '' }; + private isSubmitting = false; + private originalPlaceholder: string = ''; + + constructor( + inputEl: HTMLTextAreaElement, + callbacks: BangBashModeCallbacks + ) { + this.inputEl = inputEl; + this.callbacks = callbacks; + this.originalPlaceholder = inputEl.placeholder; + } + + handleTriggerKey(e: KeyboardEvent): boolean { + if (!this.state.active && this.inputEl.value === '' && e.key === '!') { + if (this.enterMode()) { + e.preventDefault(); + return true; + } + } + return false; + } + + handleInputChange(): void { + if (!this.state.active) return; + this.state.rawCommand = this.inputEl.value; + } + + private enterMode(): boolean { + const wrapper = this.callbacks.getInputWrapper(); + if (!wrapper) return false; + + wrapper.addClass('claudian-input-bang-bash-mode'); + this.state = { active: true, rawCommand: '' }; + this.inputEl.placeholder = t('chat.bangBash.placeholder'); + return true; + } + + private exitMode(): void { + const wrapper = this.callbacks.getInputWrapper(); + if (wrapper) { + wrapper.removeClass('claudian-input-bang-bash-mode'); + } + this.state = { active: false, rawCommand: '' }; + this.inputEl.placeholder = this.originalPlaceholder; + } + + handleKeydown(e: KeyboardEvent): boolean { + if (!this.state.active) return false; + + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { + e.preventDefault(); + if (this.state.rawCommand.trim()) { + void this.submit(); + } + return true; + } + + if (e.key === 'Escape' && !e.isComposing) { + e.preventDefault(); + this.clear(); + return true; + } + + return false; + } + + isActive(): boolean { + return this.state.active; + } + + getRawCommand(): string { + return this.state.rawCommand; + } + + private async submit(): Promise { + if (this.isSubmitting) return; + + const rawCommand = this.state.rawCommand.trim(); + if (!rawCommand) return; + + this.isSubmitting = true; + + try { + this.clear(); + await this.callbacks.onSubmit(rawCommand); + } catch (e) { + new Notice(`Command failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + this.isSubmitting = false; + } + } + + clear(): void { + this.inputEl.value = ''; + this.exitMode(); + this.callbacks.resetInputHeight?.(); + } + + destroy(): void { + this.exitMode(); + } +} diff --git a/src/features/chat/ui/ComposerContextTray.ts b/src/features/chat/ui/ComposerContextTray.ts new file mode 100644 index 0000000..ab3e9d3 --- /dev/null +++ b/src/features/chat/ui/ComposerContextTray.ts @@ -0,0 +1,261 @@ +import { setIcon } from 'obsidian'; + +import { + cancelScheduledAnimationFrame, + scheduleAnimationFrame, + type ScheduledAnimationFrame, +} from '../../../utils/animationFrame'; + +export type ComposerContextSlot = + | 'current-note' + | 'editor-selection' + | 'browser-selection' + | 'canvas-selection' + | 'images'; + +export type ComposerContextItemKind = 'note' | 'selection' | 'image'; + +export interface ComposerContextItem { + id: string; + kind: ComposerContextItemKind; + label: string; + icon?: string; + title?: string; + ariaLabel?: string; + onActivate?: () => void; + onRemove?: () => void; +} + +export interface ComposerContextTrayOptions { + onDidChange?: () => void; +} + +const SLOT_ORDER: readonly ComposerContextSlot[] = [ + 'current-note', + 'editor-selection', + 'browser-selection', + 'canvas-selection', + 'images', +]; + +const MAX_COLLAPSED_ROWS = 1; +const ROW_OVERLAP_TOLERANCE = 1; + +interface ContextTrayRow { + top: number; + bottom: number; + firstIndex: number; + lastIndex: number; +} + +/** + * Owns composer context layout while context managers retain their data and behavior. + */ +export class ComposerContextTray { + private readonly containerEl: HTMLElement; + private readonly options: ComposerContextTrayOptions; + private readonly itemsBySlot = new Map(); + private resizeObserver: ResizeObserver | null = null; + private pendingLayout: ScheduledAnimationFrame | null = null; + private expanded = false; + + constructor(containerEl: HTMLElement, options: ComposerContextTrayOptions = {}) { + this.containerEl = containerEl; + this.options = options; + this.containerEl.addClass('claudian-context-row'); + this.observeSize(); + this.render(); + } + + setItems(slot: ComposerContextSlot, items: readonly ComposerContextItem[]): void { + if (items.length === 0) { + this.itemsBySlot.delete(slot); + } else { + this.itemsBySlot.set(slot, [...items]); + } + this.expanded = false; + this.render(); + } + + clearItems(slot: ComposerContextSlot): void { + if (!this.itemsBySlot.delete(slot)) return; + this.expanded = false; + this.render(); + } + + refreshLayout(): void { + const chips = Array.from( + this.containerEl.querySelectorAll('.claudian-context-chip') + ); + const moreButton = this.containerEl.querySelector('.claudian-context-more'); + if (!moreButton || chips.length === 0) return; + + for (const chip of chips) { + chip.removeClass('claudian-context-chip--overflow-hidden'); + } + moreButton.addClass('claudian-hidden'); + + const rows = this.getRows(chips); + const hasOverflow = rows.length > MAX_COLLAPSED_ROWS; + if (!hasOverflow) { + this.expanded = false; + this.containerEl.removeClass('claudian-context-row--expanded'); + moreButton.setAttribute('aria-expanded', 'false'); + return; + } + + moreButton.removeClass('claudian-hidden'); + if (this.expanded) { + this.containerEl.addClass('claudian-context-row--expanded'); + moreButton.textContent = 'Show less'; + moreButton.setAttribute('aria-expanded', 'true'); + return; + } + + this.containerEl.removeClass('claudian-context-row--expanded'); + moreButton.setAttribute('aria-expanded', 'false'); + + const lastVisibleRow = rows[MAX_COLLAPSED_ROWS - 1]; + let visibleCount = lastVisibleRow.lastIndex + 1; + + for (let index = visibleCount; index < chips.length; index++) { + chips[index].addClass('claudian-context-chip--overflow-hidden'); + } + + const minimumVisibleCount = Math.max(1, lastVisibleRow.firstIndex); + while (visibleCount > minimumVisibleCount && moreButton.offsetTop >= lastVisibleRow.bottom) { + visibleCount -= 1; + chips[visibleCount].addClass('claudian-context-chip--overflow-hidden'); + } + + const hiddenCount = chips.length - visibleCount; + moreButton.textContent = `+${hiddenCount} more`; + moreButton.setAttribute('aria-label', `Show ${hiddenCount} more context items`); + } + + destroy(): void { + if (this.pendingLayout) { + cancelScheduledAnimationFrame(this.pendingLayout); + this.pendingLayout = null; + } + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.itemsBySlot.clear(); + this.containerEl.empty(); + this.containerEl.removeClass('has-content'); + this.containerEl.removeClass('claudian-context-row--expanded'); + } + + private render(): void { + this.containerEl.empty(); + this.containerEl.removeClass('claudian-context-row--expanded'); + + const entries = SLOT_ORDER.flatMap(slot => + (this.itemsBySlot.get(slot) ?? []).map(item => ({ item, slot })) + ); + this.containerEl.toggleClass('has-content', entries.length > 0); + + for (const { item, slot } of entries) { + this.renderItem(slot, item); + } + + if (entries.length > 0) { + const moreButton = this.containerEl.createEl('button', { + cls: 'claudian-context-more claudian-hidden', + attr: { + type: 'button', + 'aria-expanded': 'false', + }, + }); + moreButton.addEventListener('click', () => { + this.expanded = !this.expanded; + this.refreshLayout(); + this.options.onDidChange?.(); + }); + } + + this.options.onDidChange?.(); + this.scheduleLayout(); + } + + private renderItem(slot: ComposerContextSlot, item: ComposerContextItem): void { + const chipEl = this.containerEl.createDiv({ + cls: `claudian-context-chip claudian-context-chip--${item.kind}`, + }); + chipEl.dataset.contextSlot = slot; + chipEl.dataset.contextId = item.id; + + const contentEl = item.onActivate + ? chipEl.createEl('button', { + cls: 'claudian-context-chip-main', + attr: { type: 'button' }, + }) + : chipEl.createSpan({ cls: 'claudian-context-chip-main' }); + + if (item.title) { + contentEl.setAttribute('title', item.title); + } + contentEl.setAttribute('aria-label', item.ariaLabel ?? item.label); + if (item.onActivate) { + contentEl.addEventListener('click', item.onActivate); + } + + if (item.icon) { + const iconEl = contentEl.createSpan({ cls: 'claudian-context-chip-icon' }); + setIcon(iconEl, item.icon); + } + + contentEl.createSpan({ cls: 'claudian-context-chip-label', text: item.label }); + + if (item.onRemove) { + const removeButton = chipEl.createEl('button', { + cls: 'claudian-context-chip-remove', + text: '\u00D7', + attr: { + type: 'button', + 'aria-label': `Remove ${item.ariaLabel ?? item.label}`, + }, + }); + removeButton.addEventListener('click', item.onRemove); + } + } + + private getRows(chips: readonly HTMLElement[]): ContextTrayRow[] { + const rows: ContextTrayRow[] = []; + for (const [index, chip] of chips.entries()) { + const top = chip.offsetTop; + const height = chip.offsetHeight > 0 ? chip.offsetHeight : 1; + const bottom = top + height; + const row = rows.find(candidate => + top < candidate.bottom + ROW_OVERLAP_TOLERANCE + && bottom > candidate.top - ROW_OVERLAP_TOLERANCE + ); + if (row) { + row.top = Math.min(row.top, top); + row.bottom = Math.max(row.bottom, bottom); + row.lastIndex = index; + } else { + rows.push({ top, bottom, firstIndex: index, lastIndex: index }); + } + } + return rows.sort((left, right) => left.top - right.top); + } + + private scheduleLayout(): void { + if (this.pendingLayout) { + cancelScheduledAnimationFrame(this.pendingLayout); + } + this.pendingLayout = scheduleAnimationFrame(() => { + this.pendingLayout = null; + this.refreshLayout(); + }, this.containerEl.ownerDocument.defaultView); + } + + private observeSize(): void { + const ResizeObserverConstructor = this.containerEl.ownerDocument.defaultView?.ResizeObserver; + if (typeof ResizeObserverConstructor !== 'function') return; + + this.resizeObserver = new ResizeObserverConstructor(() => this.scheduleLayout()); + this.resizeObserver.observe(this.containerEl); + } +} diff --git a/src/features/chat/ui/FileContext.ts b/src/features/chat/ui/FileContext.ts new file mode 100644 index 0000000..d180a6a --- /dev/null +++ b/src/features/chat/ui/FileContext.ts @@ -0,0 +1,392 @@ +import type { App, EventRef } from 'obsidian'; +import { Notice, TFile } from 'obsidian'; + +import type { McpServerManager } from '../../../core/mcp/McpServerManager'; +import type { AgentMentionProvider } from '../../../shared/mention/MentionDropdownController'; +import { MentionDropdownController } from '../../../shared/mention/MentionDropdownController'; +import { VaultMentionDataProvider } from '../../../shared/mention/VaultMentionDataProvider'; +import { + createExternalContextLookupGetter, + isMentionStart, + resolveExternalMentionAtIndex, +} from '../../../utils/contextMentionResolver'; +import { buildExternalContextDisplayEntries } from '../../../utils/externalContext'; +import { externalContextScanner } from '../../../utils/externalContextScanner'; +import { getVaultPath, normalizePathForVault as normalizePathForVaultUtil } from '../../../utils/path'; +import { ComposerContextTray } from './ComposerContextTray'; +import { FileContextState } from './file-context/state/FileContextState'; +import { FileChipsView } from './file-context/view/FileChipsView'; + +export interface FileContextCallbacks { + getExcludedTags: () => string[]; + onChipsChanged?: () => void; + getExternalContexts?: () => string[]; + /** Called when an agent is selected from the @ mention dropdown. */ + onAgentMentionSelect?: (agentId: string) => void; +} + +export class FileContextManager { + private app: App; + private callbacks: FileContextCallbacks; + private dropdownContainerEl: HTMLElement; + private inputEl: HTMLTextAreaElement; + private state: FileContextState; + private mentionDataProvider: VaultMentionDataProvider; + private chipsView: FileChipsView; + private mentionDropdown: MentionDropdownController; + private ownedContextTray: ComposerContextTray | null = null; + private deleteEventRef: EventRef | null = null; + private renameEventRef: EventRef | null = null; + + // Current note (shown as chip) + private currentNotePath: string | null = null; + + // MCP server support + private onMcpMentionChange: ((servers: Set) => void) | null = null; + + constructor( + app: App, + chipsContainerEl: HTMLElement, + inputEl: HTMLTextAreaElement, + callbacks: FileContextCallbacks, + dropdownContainerEl?: HTMLElement, + contextTray?: ComposerContextTray, + ) { + this.app = app; + this.dropdownContainerEl = dropdownContainerEl ?? chipsContainerEl; + this.inputEl = inputEl; + this.callbacks = callbacks; + + this.state = new FileContextState(); + this.mentionDataProvider = new VaultMentionDataProvider(this.app); + this.mentionDataProvider.initializeInBackground(); + + const resolvedContextTray = contextTray ?? new ComposerContextTray(chipsContainerEl); + if (!contextTray) { + this.ownedContextTray = resolvedContextTray; + } + this.chipsView = new FileChipsView(resolvedContextTray, { + onRemoveAttachment: (filePath) => { + if (filePath === this.currentNotePath) { + this.currentNotePath = null; + this.state.detachFile(filePath); + this.refreshCurrentNoteChip(); + } + }, + onOpenFile: (filePath) => { + void (async (): Promise => { + const file = this.app.vault.getAbstractFileByPath(filePath); + if (!(file instanceof TFile)) { + new Notice(`Could not open file: ${filePath}`); + return; + } + try { + await this.app.workspace.getLeaf().openFile(file); + } catch (error) { + new Notice(`Failed to open file: ${error instanceof Error ? error.message : String(error)}`); + } + })(); + }, + }); + + this.mentionDropdown = new MentionDropdownController( + this.dropdownContainerEl, + this.inputEl, + { + onAttachFile: (filePath) => this.state.attachFile(filePath), + onMcpMentionChange: (servers) => this.onMcpMentionChange?.(servers), + onAgentMentionSelect: (agentId) => this.callbacks.onAgentMentionSelect?.(agentId), + getMentionedMcpServers: () => this.state.getMentionedMcpServers(), + setMentionedMcpServers: (mentions) => this.state.setMentionedMcpServers(mentions), + addMentionedMcpServer: (name) => this.state.addMentionedMcpServer(name), + getExternalContexts: () => this.callbacks.getExternalContexts?.() || [], + getCachedVaultFolders: () => this.mentionDataProvider.getCachedVaultFolders(), + getCachedVaultFiles: () => this.mentionDataProvider.getCachedVaultFiles(), + normalizePathForVault: (rawPath) => this.normalizePathForVault(rawPath), + } + ); + + this.deleteEventRef = this.app.vault.on('delete', (file) => { + if (file instanceof TFile) this.handleFileDeleted(file.path); + }); + + this.renameEventRef = this.app.vault.on('rename', (file, oldPath) => { + if (file instanceof TFile) this.handleFileRenamed(oldPath, file.path); + }); + } + + /** Returns the current note path (shown as chip). */ + getCurrentNotePath(): string | null { + return this.currentNotePath; + } + + getAttachedFiles(): Set { + return this.state.getAttachedFiles(); + } + + /** Checks whether current note should be sent for this session. */ + shouldSendCurrentNote(notePath?: string | null): boolean { + const resolvedPath = notePath ?? this.currentNotePath; + return !!resolvedPath && !this.state.hasSentCurrentNote(); + } + + /** Marks current note as sent (call after sending a message). */ + markCurrentNoteSent() { + this.state.markCurrentNoteSent(); + } + + isSessionStarted(): boolean { + return this.state.isSessionStarted(); + } + + startSession() { + this.state.startSession(); + } + + /** Resets state for a new conversation. */ + resetForNewConversation() { + this.currentNotePath = null; + this.state.resetForNewConversation(); + this.refreshCurrentNoteChip(); + } + + /** Resets state for loading an existing conversation. */ + resetForLoadedConversation(hasMessages: boolean) { + this.currentNotePath = null; + this.state.resetForLoadedConversation(hasMessages); + this.refreshCurrentNoteChip(); + } + + /** Sets current note (for restoring persisted state). */ + setCurrentNote(notePath: string | null) { + this.currentNotePath = notePath; + if (notePath) { + this.state.attachFile(notePath); + } + this.refreshCurrentNoteChip(); + } + + /** Auto-attaches the currently focused file (for new sessions). */ + autoAttachActiveFile() { + const activeFile = this.app.workspace.getActiveFile(); + if (activeFile && !this.hasExcludedTag(activeFile)) { + const normalizedPath = this.normalizePathForVault(activeFile.path); + if (normalizedPath) { + this.currentNotePath = normalizedPath; + this.state.attachFile(normalizedPath); + this.refreshCurrentNoteChip(); + } + } + } + + /** Handles file open event. */ + handleFileOpen(file: TFile) { + const normalizedPath = this.normalizePathForVault(file.path); + if (!normalizedPath) return; + + if (!this.state.isSessionStarted()) { + this.state.clearAttachments(); + if (!this.hasExcludedTag(file)) { + this.currentNotePath = normalizedPath; + this.state.attachFile(normalizedPath); + } else { + this.currentNotePath = null; + } + this.refreshCurrentNoteChip(); + } + } + + markFileCacheDirty() { + this.mentionDataProvider.markFilesDirty(); + } + + markFolderCacheDirty() { + this.mentionDataProvider.markFoldersDirty(); + } + + /** Handles input changes to detect @ mentions. */ + handleInputChange() { + this.mentionDropdown.handleInputChange(); + } + + /** Handles keyboard navigation in mention dropdown. Returns true if handled. */ + handleMentionKeydown(e: KeyboardEvent): boolean { + return this.mentionDropdown.handleKeydown(e); + } + + isMentionDropdownVisible(): boolean { + return this.mentionDropdown.isVisible(); + } + + hideMentionDropdown() { + this.mentionDropdown.hide(); + } + + containsElement(el: Node): boolean { + return this.mentionDropdown.containsElement(el); + } + + transformContextMentions(text: string): string { + const externalContexts = this.callbacks.getExternalContexts?.() || []; + if (externalContexts.length === 0 || !text.includes('@')) return text; + + const contextEntries = buildExternalContextDisplayEntries(externalContexts) + .sort((a, b) => b.displayNameLower.length - a.displayNameLower.length); + const getContextLookup = createExternalContextLookupGetter( + contextRoot => externalContextScanner.scanPaths([contextRoot]) + ); + + let replaced = false; + let cursor = 0; + const chunks: string[] = []; + + for (let index = 0; index < text.length; index++) { + if (!isMentionStart(text, index)) continue; + + const resolved = resolveExternalMentionAtIndex(text, index, contextEntries, getContextLookup); + if (!resolved) continue; + + chunks.push(text.slice(cursor, index)); + chunks.push(`${resolved.resolvedPath}${resolved.trailingPunctuation}`); + cursor = resolved.endIndex; + index = resolved.endIndex - 1; + replaced = true; + } + + if (!replaced) return text; + chunks.push(text.slice(cursor)); + return chunks.join(''); + } + + /** Cleans up event listeners (call on view close). */ + destroy() { + if (this.deleteEventRef) this.app.vault.offref(this.deleteEventRef); + if (this.renameEventRef) this.app.vault.offref(this.renameEventRef); + this.mentionDropdown.destroy(); + this.chipsView.destroy(); + this.ownedContextTray?.destroy(); + this.ownedContextTray = null; + } + + /** Normalizes a file path to be vault-relative with forward slashes. */ + normalizePathForVault(rawPath: string | undefined | null): string | null { + const vaultPath = getVaultPath(this.app); + return normalizePathForVaultUtil(rawPath, vaultPath); + } + + private refreshCurrentNoteChip(): void { + this.chipsView.renderCurrentNote(this.currentNotePath); + this.callbacks.onChipsChanged?.(); + } + + private handleFileRenamed(oldPath: string, newPath: string) { + const normalizedOld = this.normalizePathForVault(oldPath); + const normalizedNew = this.normalizePathForVault(newPath); + if (!normalizedOld) return; + + let needsUpdate = false; + + // Update current note path if renamed + if (this.currentNotePath === normalizedOld) { + this.currentNotePath = normalizedNew; + needsUpdate = true; + } + + // Update attached files + if (this.state.getAttachedFiles().has(normalizedOld)) { + this.state.detachFile(normalizedOld); + if (normalizedNew) { + this.state.attachFile(normalizedNew); + } + needsUpdate = true; + } + + if (needsUpdate) { + this.refreshCurrentNoteChip(); + } + } + + private handleFileDeleted(deletedPath: string): void { + const normalized = this.normalizePathForVault(deletedPath); + if (!normalized) return; + + let needsUpdate = false; + + // Clear current note if deleted + if (this.currentNotePath === normalized) { + this.currentNotePath = null; + needsUpdate = true; + } + + // Remove from attached files + if (this.state.getAttachedFiles().has(normalized)) { + this.state.detachFile(normalized); + needsUpdate = true; + } + + if (needsUpdate) { + this.refreshCurrentNoteChip(); + } + } + + // ======================================== + // MCP Server Support + // ======================================== + + setMcpManager(manager: McpServerManager | null): void { + this.mentionDropdown.setMcpManager(manager); + } + + setAgentService(agentService: AgentMentionProvider | null): void { + this.mentionDropdown.setAgentService(agentService); + } + + setOnMcpMentionChange(callback: (servers: Set) => void): void { + this.onMcpMentionChange = callback; + } + + /** + * Pre-scans external context paths in the background to warm the cache. + * Should be called when external context paths are added/changed. + */ + preScanExternalContexts(): void { + this.mentionDropdown.preScanExternalContexts(); + } + + getMentionedMcpServers(): Set { + return this.state.getMentionedMcpServers(); + } + + clearMcpMentions(): void { + this.state.clearMcpMentions(); + } + + updateMcpMentionsFromText(text: string): void { + this.mentionDropdown.updateMcpMentionsFromText(text); + } + + private hasExcludedTag(file: TFile): boolean { + const excludedTags = this.callbacks.getExcludedTags(); + if (excludedTags.length === 0) return false; + + const cache = this.app.metadataCache.getFileCache(file); + if (!cache) return false; + + const fileTags: string[] = []; + + if (cache.frontmatter?.tags) { + const fmTags: unknown = cache.frontmatter.tags; + if (Array.isArray(fmTags)) { + fileTags.push(...fmTags.filter((tag): tag is string => typeof tag === 'string').map((tag) => tag.replace(/^#/, ''))); + } else if (typeof fmTags === 'string') { + fileTags.push(fmTags.replace(/^#/, '')); + } + } + + if (cache.tags) { + fileTags.push(...cache.tags.map(t => t.tag.replace(/^#/, ''))); + } + + return fileTags.some(tag => excludedTags.includes(tag)); + } +} diff --git a/src/features/chat/ui/ImageContext.ts b/src/features/chat/ui/ImageContext.ts new file mode 100644 index 0000000..1e5f0d2 --- /dev/null +++ b/src/features/chat/ui/ImageContext.ts @@ -0,0 +1,335 @@ +import { Notice } from 'obsidian'; +import * as path from 'path'; + +import type { ImageAttachment, ImageMediaType } from '../../../core/types'; +import { ComposerContextTray } from './ComposerContextTray'; + +const MAX_IMAGE_SIZE = 5 * 1024 * 1024; + +const IMAGE_EXTENSIONS: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', +}; + +export interface ImageContextCallbacks { + onImagesChanged?: () => void; +} + +export class ImageContextManager { + private callbacks: ImageContextCallbacks; + private containerEl: HTMLElement; + private contextTray: ComposerContextTray; + private ownedContextTray: ComposerContextTray | null = null; + private inputEl: HTMLTextAreaElement; + private dropOverlay: HTMLElement | null = null; + private attachedImages: Map = new Map(); + private enabled = true; + + constructor( + containerEl: HTMLElement, + inputEl: HTMLTextAreaElement, + callbacks: ImageContextCallbacks, + previewContainerEl?: HTMLElement, + contextTray?: ComposerContextTray, + ) { + this.containerEl = containerEl; + this.inputEl = inputEl; + this.callbacks = callbacks; + const ownedTrayContainer = contextTray + ? null + : (previewContainerEl ?? containerEl).createDiv({ cls: 'claudian-context-row' }); + this.contextTray = contextTray ?? new ComposerContextTray(ownedTrayContainer!); + if (!contextTray) { + this.ownedContextTray = this.contextTray; + } + + this.setupDragAndDrop(); + this.setupPasteHandler(); + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + if (!enabled && this.attachedImages.size > 0) { + this.clearImages(); + } + } + + getAttachedImages(): ImageAttachment[] { + return Array.from(this.attachedImages.values()); + } + + hasImages(): boolean { + return this.attachedImages.size > 0; + } + + clearImages() { + this.attachedImages.clear(); + this.updateImagePreview(); + this.callbacks.onImagesChanged?.(); + } + + /** Sets images directly (used for queued messages). */ + setImages(images: ImageAttachment[]) { + this.attachedImages.clear(); + for (const image of images) { + this.attachedImages.set(image.id, image); + } + this.updateImagePreview(); + this.callbacks.onImagesChanged?.(); + } + + destroy(): void { + this.contextTray.clearItems('images'); + this.ownedContextTray?.destroy(); + this.ownedContextTray = null; + } + + private setupDragAndDrop() { + const inputWrapper = this.containerEl.querySelector('.claudian-input-wrapper') as HTMLElement; + if (!inputWrapper) return; + + this.dropOverlay = inputWrapper.createDiv({ cls: 'claudian-drop-overlay' }); + const dropContent = this.dropOverlay.createDiv({ cls: 'claudian-drop-content' }); + const ownerDocument = inputWrapper.ownerDocument ?? window.document; + const svg = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('width', '32'); + svg.setAttribute('height', '32'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2'); + const pathEl = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'path'); + pathEl.setAttribute('d', 'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'); + const polyline = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'polyline'); + polyline.setAttribute('points', '17 8 12 3 7 8'); + const line = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'line'); + line.setAttribute('x1', '12'); + line.setAttribute('y1', '3'); + line.setAttribute('x2', '12'); + line.setAttribute('y2', '15'); + svg.appendChild(pathEl); + svg.appendChild(polyline); + svg.appendChild(line); + dropContent.appendChild(svg); + dropContent.createSpan({ text: 'Drop image here' }); + + const dropZone = inputWrapper; + + dropZone.addEventListener('dragenter', (e) => this.handleDragEnter(e)); + dropZone.addEventListener('dragover', (e) => this.handleDragOver(e)); + dropZone.addEventListener('dragleave', (e) => this.handleDragLeave(e)); + dropZone.addEventListener('drop', (e) => { + void this.handleDrop(e); + }); + } + + private handleDragEnter(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + if (e.dataTransfer?.types.includes('Files')) { + this.dropOverlay?.addClass('visible'); + } + } + + private handleDragOver(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + } + + private handleDragLeave(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + const inputWrapper = this.containerEl.querySelector('.claudian-input-wrapper'); + if (!inputWrapper) { + this.dropOverlay?.removeClass('visible'); + return; + } + + const rect = inputWrapper.getBoundingClientRect(); + if ( + e.clientX <= rect.left || + e.clientX >= rect.right || + e.clientY <= rect.top || + e.clientY >= rect.bottom + ) { + this.dropOverlay?.removeClass('visible'); + } + } + + private async handleDrop(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + this.dropOverlay?.removeClass('visible'); + + const files = e.dataTransfer?.files; + if (!files) return; + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + if (this.isImageFile(file)) { + await this.addImageFromFile(file, 'drop'); + } + } + } + + private setupPasteHandler() { + this.inputEl.addEventListener('paste', (e) => { + void (async (): Promise => { + const items = e.clipboardData?.items; + if (!items) return; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.type.startsWith('image/')) { + e.preventDefault(); + const file = item.getAsFile(); + if (file) { + await this.addImageFromFile(file, 'paste'); + } + return; + } + } + })(); + }); + } + + private isImageFile(file: File): boolean { + return file.type.startsWith('image/') && this.getMediaType(file.name) !== null; + } + + private getMediaType(filename: string): ImageMediaType | null { + const ext = path.extname(filename).toLowerCase(); + return IMAGE_EXTENSIONS[ext] || null; + } + + private async addImageFromFile(file: File, source: 'paste' | 'drop'): Promise { + if (!this.enabled) { + new Notice('Image attachments are not supported by this provider.'); + return false; + } + + if (file.size > MAX_IMAGE_SIZE) { + this.notifyImageError(`Image exceeds ${this.formatSize(MAX_IMAGE_SIZE)} limit.`); + return false; + } + + const mediaType = this.getMediaType(file.name) || (file.type as ImageMediaType); + if (!mediaType) { + this.notifyImageError('Unsupported image type.'); + return false; + } + + try { + const base64 = await this.fileToBase64(file); + + const attachment: ImageAttachment = { + id: this.generateId(), + name: file.name || `image-${Date.now()}.${mediaType.split('/')[1]}`, + mediaType, + data: base64, + size: file.size, + source, + }; + + this.attachedImages.set(attachment.id, attachment); + this.updateImagePreview(); + this.callbacks.onImagesChanged?.(); + return true; + } catch (error) { + this.notifyImageError('Failed to attach image.', error); + return false; + } + } + + private async fileToBase64(file: File): Promise { + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + return buffer.toString('base64'); + } + + // ============================================ + // Private: Image Preview + // ============================================ + + private updateImagePreview() { + if (this.attachedImages.size === 0) { + this.contextTray.clearItems('images'); + return; + } + + const images = Array.from(this.attachedImages); + this.contextTray.setItems('images', images.map(([id, image], index) => ({ + id, + kind: 'image' as const, + label: images.length === 1 ? 'Image' : `Image ${index + 1}`, + title: `${image.name} · ${this.formatSize(image.size)}`, + ariaLabel: `Image attachment: ${image.name}`, + onActivate: () => this.showFullImage(image), + onRemove: () => { + this.attachedImages.delete(id); + this.updateImagePreview(); + this.callbacks.onImagesChanged?.(); + }, + }))); + } + + private showFullImage(image: ImageAttachment) { + const ownerDocument = this.containerEl.ownerDocument ?? window.document; + const overlay = ownerDocument.body.createDiv({ cls: 'claudian-image-modal-overlay' }); + const modal = overlay.createDiv({ cls: 'claudian-image-modal' }); + + modal.createEl('img', { + attr: { + src: `data:${image.mediaType};base64,${image.data}`, + alt: image.name, + }, + }); + + const closeBtn = modal.createDiv({ cls: 'claudian-image-modal-close' }); + closeBtn.setText('\u00D7'); + + const handleEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + close(); + } + }; + + const close = () => { + ownerDocument.removeEventListener('keydown', handleEsc); + overlay.remove(); + }; + + closeBtn.addEventListener('click', close); + overlay.addEventListener('click', (e) => { + if (e.target === overlay) close(); + }); + ownerDocument.addEventListener('keydown', handleEsc); + } + + private generateId(): string { + return `img-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; + } + + private formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + private notifyImageError(message: string, error?: unknown) { + let userMessage = message; + if (error instanceof Error) { + if (error.message.includes('ENOENT') || error.message.includes('no such file')) { + userMessage = `${message} (File not found)`; + } else if (error.message.includes('EACCES') || error.message.includes('permission denied')) { + userMessage = `${message} (Permission denied)`; + } + } + new Notice(userMessage); + } +} diff --git a/src/features/chat/ui/InputToolbar.ts b/src/features/chat/ui/InputToolbar.ts new file mode 100644 index 0000000..da50436 --- /dev/null +++ b/src/features/chat/ui/InputToolbar.ts @@ -0,0 +1,1247 @@ +import { Notice, setIcon } from 'obsidian'; +import * as os from 'os'; +import * as path from 'path'; + +import type { McpServerManager } from '../../../core/mcp/McpServerManager'; +import type { + ProviderCapabilities, + ProviderChatUIConfig, + ProviderModeSelectorConfig, + ProviderPermissionModeToggleConfig, + ProviderReasoningOption, + ProviderServiceTierToggleConfig, + ProviderUIOption, +} from '../../../core/providers/types'; +import type { + ManagedMcpServer, + UsageInfo, +} from '../../../core/types'; +import { appendCheckIcon, appendMcpIcon, createProviderIconSvg } from '../../../shared/icons'; +import { filterValidPaths, findConflictingPath, isDuplicatePath, isValidDirectoryPath, validateDirectoryPath } from '../../../utils/externalContext'; +import { expandHomePath, normalizePathForFilesystem } from '../../../utils/path'; + +interface ElectronOpenDialogResult { + canceled: boolean; + filePaths: string[]; +} + +interface ElectronRemoteApi { + dialog: { + showOpenDialog(options: { properties: string[]; title: string }): Promise; + }; +} + +function runToolbarAction(action: () => Promise, failureMessage: string): void { + void action().catch(() => { + new Notice(failureMessage); + }); +} + +export interface ToolbarSettings { + model: string; + thinkingBudget: string; + effortLevel: string; + serviceTier: string; + permissionMode: string; + [key: string]: unknown; +} + +export interface ToolbarCallbacks { + onModelChange: (model: string) => Promise; + onModeChange: (mode: string) => Promise; + onThinkingBudgetChange: (budget: string) => Promise; + onEffortLevelChange: (effort: string) => Promise; + onServiceTierChange: (serviceTier: string) => Promise; + onPermissionModeChange: (mode: string) => Promise; + getSettings: () => ToolbarSettings; + getEnvironmentVariables?: () => string; + getUIConfig: () => ProviderChatUIConfig; + getCapabilities: () => ProviderCapabilities; +} + +export class ModelSelector { + private container: HTMLElement; + private buttonEl: HTMLElement | null = null; + private dropdownEl: HTMLElement | null = null; + private callbacks: ToolbarCallbacks; + constructor(parentEl: HTMLElement, callbacks: ToolbarCallbacks) { + this.callbacks = callbacks; + this.container = parentEl.createDiv({ cls: 'claudian-model-selector' }); + this.render(); + } + + private getAvailableModels() { + const settings = this.callbacks.getSettings(); + const uiConfig = this.callbacks.getUIConfig(); + return uiConfig.getModelOptions({ + ...settings, + environmentVariables: this.callbacks.getEnvironmentVariables?.(), + }); + } + + private render() { + this.container.empty(); + + this.buttonEl = this.container.createDiv({ cls: 'claudian-model-btn' }); + this.updateDisplay(); + + this.dropdownEl = this.container.createDiv({ cls: 'claudian-model-dropdown' }); + this.renderOptions(); + } + + updateDisplay() { + if (!this.buttonEl) return; + const currentModel = this.callbacks.getSettings().model; + const models = this.getAvailableModels(); + const modelInfo = models.find(m => m.value === currentModel); + + const displayModel = modelInfo || models[0]; + + this.buttonEl.empty(); + + const labelEl = this.buttonEl.createSpan({ cls: 'claudian-model-label' }); + labelEl.setText(displayModel?.label || 'Unknown'); + } + + renderOptions() { + if (!this.dropdownEl) return; + this.dropdownEl.empty(); + + const currentModel = this.callbacks.getSettings().model; + const models = this.getAvailableModels(); + const reversed = [...models].reverse(); + + let lastGroup: string | undefined; + for (const model of reversed) { + if (model.group && model.group !== lastGroup) { + const separator = this.dropdownEl.createDiv({ cls: 'claudian-model-group' }); + separator.setText(model.group); + lastGroup = model.group; + } + + const option = this.dropdownEl.createDiv({ cls: 'claudian-model-option' }); + if (model.value === currentModel) { + option.addClass('selected'); + } + + const icon = model.providerIcon ?? this.callbacks.getUIConfig().getProviderIcon?.(); + if (icon) { + option.appendChild(createProviderIconSvg(icon, { + className: 'claudian-model-provider-icon', + height: 12, + ownerDocument: option.ownerDocument, + width: 12, + })); + } + option.createSpan({ text: model.label }); + if (model.description) { + option.setAttribute('title', model.description); + } + + option.addEventListener('click', (e) => { + e.stopPropagation(); + runToolbarAction(async () => { + await this.callbacks.onModelChange(model.value); + this.updateDisplay(); + this.renderOptions(); + }, 'Failed to change model'); + }); + } + } +} + +export class ModeSelector { + private container: HTMLElement; + private labelEl: HTMLElement | null = null; + private toggleEl: HTMLElement | null = null; + private callbacks: ToolbarCallbacks; + + constructor(parentEl: HTMLElement, callbacks: ToolbarCallbacks) { + this.callbacks = callbacks; + this.container = parentEl.createDiv({ cls: 'claudian-mode-selector' }); + this.render(); + } + + private getSelectorConfig(): ProviderModeSelectorConfig | null { + return this.callbacks.getUIConfig().getModeSelector?.(this.callbacks.getSettings()) ?? null; + } + + private render() { + this.container.empty(); + + this.labelEl = this.container.createSpan({ cls: 'claudian-mode-label' }); + this.toggleEl = this.container.createDiv({ cls: 'claudian-toggle-switch' }); + + this.toggleEl.addEventListener('click', () => { + runToolbarAction(() => this.toggle(), 'Failed to change mode'); + }); + + this.updateDisplay(); + } + + /** Resolves the active/inactive option pair for a two-option toggle. */ + private resolveOptionPair( + selectorConfig: ProviderModeSelectorConfig, + ): { active: ProviderUIOption; inactive: ProviderUIOption } { + const [first, second] = selectorConfig.options; + const active = selectorConfig.activeValue + ? selectorConfig.options.find((option) => option.value === selectorConfig.activeValue) ?? second + : second; + const inactive = active.value === first.value ? second : first; + return { active, inactive }; + } + + updateDisplay() { + if (!this.toggleEl || !this.labelEl) { + return; + } + + const selectorConfig = this.getSelectorConfig(); + if (!selectorConfig || selectorConfig.options.length !== 2) { + this.container.addClass('claudian-hidden'); + return; + } + + this.container.removeClass('claudian-hidden'); + const { active, inactive } = this.resolveOptionPair(selectorConfig); + const currentOption = selectorConfig.options.find((option) => option.value === selectorConfig.value) + ?? selectorConfig.options[0]; + const isActive = currentOption.value === active.value; + + this.labelEl.setText(currentOption.label || selectorConfig.label); + this.labelEl.toggleClass('active', isActive); + if (isActive) { + this.toggleEl.addClass('active'); + } else { + this.toggleEl.removeClass('active'); + } + + const titleParts = [`${inactive.label} <-> ${active.label}`]; + if (currentOption.description) { + titleParts.push(currentOption.description); + } + this.container.setAttribute('title', titleParts.join('\n')); + } + + renderOptions() { + this.updateDisplay(); + } + + private async toggle() { + const selectorConfig = this.getSelectorConfig(); + if (!selectorConfig || selectorConfig.options.length !== 2) { + return; + } + + const { active, inactive } = this.resolveOptionPair(selectorConfig); + const nextValue = selectorConfig.value === active.value ? inactive.value : active.value; + await this.callbacks.onModeChange(nextValue); + this.updateDisplay(); + } +} + +export class ThinkingBudgetSelector { + private container: HTMLElement; + private effortEl: HTMLElement | null = null; + private effortGearsEl: HTMLElement | null = null; + private budgetEl: HTMLElement | null = null; + private budgetGearsEl: HTMLElement | null = null; + private callbacks: ToolbarCallbacks; + + constructor(parentEl: HTMLElement, callbacks: ToolbarCallbacks) { + this.callbacks = callbacks; + this.container = parentEl.createDiv({ cls: 'claudian-thinking-selector' }); + this.render(); + } + + private render() { + this.container.empty(); + + // Effort selector (for adaptive thinking models) + this.effortEl = this.container.createDiv({ cls: 'claudian-thinking-effort' }); + const effortLabel = this.effortEl.createSpan({ cls: 'claudian-thinking-label-text' }); + effortLabel.setText('Effort:'); + this.effortGearsEl = this.effortEl.createDiv({ cls: 'claudian-thinking-gears' }); + + // Legacy budget selector (for custom models) + this.budgetEl = this.container.createDiv({ cls: 'claudian-thinking-budget' }); + const budgetLabel = this.budgetEl.createSpan({ cls: 'claudian-thinking-label-text' }); + budgetLabel.setText('Thinking:'); + this.budgetGearsEl = this.budgetEl.createDiv({ cls: 'claudian-thinking-gears' }); + + this.updateDisplay(); + } + + private renderEffortGears() { + if (!this.effortGearsEl) return; + this.effortGearsEl.empty(); + + const currentEffort = this.callbacks.getSettings().effortLevel; + const uiConfig = this.callbacks.getUIConfig(); + const settings = this.callbacks.getSettings(); + const model = settings.model; + const options = uiConfig.getReasoningOptions(model, settings); + const currentInfo = options.find(e => e.value === currentEffort); + + const currentEl = this.effortGearsEl.createDiv({ cls: 'claudian-thinking-current' }); + currentEl.setText(currentInfo?.label || options[0]?.label || 'High'); + + const optionsEl = this.effortGearsEl.createDiv({ cls: 'claudian-thinking-options' }); + + for (const effort of [...options].reverse()) { + const gearEl = optionsEl.createDiv({ cls: 'claudian-thinking-gear' }); + gearEl.setText(effort.label); + if (effort.description) { + gearEl.setAttribute('title', effort.description); + } + + if (effort.value === currentEffort) { + gearEl.addClass('selected'); + } + + gearEl.addEventListener('click', (e) => { + e.stopPropagation(); + runToolbarAction(async () => { + await this.callbacks.onEffortLevelChange(effort.value); + this.updateDisplay(); + }, 'Failed to change effort level'); + }); + } + } + + private renderBudgetGears() { + if (!this.budgetGearsEl) return; + this.budgetGearsEl.empty(); + + const currentBudget = this.callbacks.getSettings().thinkingBudget; + const uiConfig = this.callbacks.getUIConfig(); + const settings = this.callbacks.getSettings(); + const model = settings.model; + const options: ProviderReasoningOption[] = uiConfig.getReasoningOptions(model, settings); + const currentBudgetInfo = options.find(b => b.value === currentBudget); + + const currentEl = this.budgetGearsEl.createDiv({ cls: 'claudian-thinking-current' }); + currentEl.setText(currentBudgetInfo?.label || options[0]?.label || 'Off'); + + const optionsEl = this.budgetGearsEl.createDiv({ cls: 'claudian-thinking-options' }); + + for (const budget of [...options].reverse()) { + const gearEl = optionsEl.createDiv({ cls: 'claudian-thinking-gear' }); + gearEl.setText(budget.label); + const tokens = budget.tokens ?? 0; + gearEl.setAttribute('title', tokens > 0 ? `${tokens.toLocaleString()} tokens` : 'Disabled'); + + if (budget.value === currentBudget) { + gearEl.addClass('selected'); + } + + gearEl.addEventListener('click', (e) => { + e.stopPropagation(); + runToolbarAction(async () => { + await this.callbacks.onThinkingBudgetChange(budget.value); + this.updateDisplay(); + }, 'Failed to change thinking budget'); + }); + } + } + + updateDisplay() { + const capabilities = this.callbacks.getCapabilities(); + if (capabilities.reasoningControl === 'none') { + this.effortEl?.addClass('claudian-hidden'); + this.budgetEl?.addClass('claudian-hidden'); + return; + } + + const settings = this.callbacks.getSettings(); + const model = settings.model; + const uiConfig = this.callbacks.getUIConfig(); + const options = uiConfig.getReasoningOptions(model, settings); + const defaultValue = uiConfig.getDefaultReasoningValue(model, settings); + const shouldHide = options.length === 0 + || (options.length === 1 && options[0]?.value === defaultValue); + + if (shouldHide) { + this.effortEl?.addClass('claudian-hidden'); + this.budgetEl?.addClass('claudian-hidden'); + return; + } + + const adaptive = uiConfig.isAdaptiveReasoningModel(model, settings); + + if (this.effortEl) { + this.effortEl.toggleClass('claudian-hidden', !adaptive); + } + if (this.budgetEl) { + this.budgetEl.toggleClass('claudian-hidden', adaptive); + } + + if (adaptive) { + this.renderEffortGears(); + } else { + this.renderBudgetGears(); + } + } +} + +export class PermissionToggle { + private container: HTMLElement; + private toggleEl: HTMLElement | null = null; + private labelEl: HTMLElement | null = null; + private callbacks: ToolbarCallbacks; + private visible = true; + + constructor(parentEl: HTMLElement, callbacks: ToolbarCallbacks) { + this.callbacks = callbacks; + this.container = parentEl.createDiv({ cls: 'claudian-permission-toggle' }); + this.render(); + } + + setVisible(visible: boolean): void { + this.visible = visible; + this.updateDisplay(); + } + + private render() { + this.container.empty(); + + this.labelEl = this.container.createSpan({ cls: 'claudian-permission-label' }); + this.toggleEl = this.container.createDiv({ cls: 'claudian-toggle-switch' }); + + this.updateDisplay(); + + this.toggleEl.addEventListener('click', () => { + runToolbarAction(() => this.toggle(), 'Failed to change permission mode'); + }); + } + + private getToggleConfig(): ProviderPermissionModeToggleConfig | null { + const uiConfig = this.callbacks.getUIConfig(); + return uiConfig.getPermissionModeToggle?.() ?? null; + } + + updateDisplay() { + if (!this.toggleEl || !this.labelEl) return; + + const toggleConfig = this.getToggleConfig(); + const capabilities = this.callbacks.getCapabilities(); + if (!this.visible || !toggleConfig) { + this.container.addClass('claudian-hidden'); + return; + } + + this.container.removeClass('claudian-hidden'); + const mode = this.callbacks.getSettings().permissionMode; + const planValue = toggleConfig.planValue; + const planLabel = toggleConfig.planLabel ?? 'PLAN'; + const canShowPlan = Boolean(planValue) && capabilities.supportsPlanMode; + + if (canShowPlan && planValue && mode === planValue) { + this.toggleEl.addClass('claudian-hidden'); + this.labelEl.setText(planLabel); + this.labelEl.addClass('plan-active'); + } else { + this.toggleEl.removeClass('claudian-hidden'); + this.labelEl.removeClass('plan-active'); + if (mode === toggleConfig.activeValue) { + this.toggleEl.addClass('active'); + this.labelEl.setText(toggleConfig.activeLabel); + } else { + this.toggleEl.removeClass('active'); + this.labelEl.setText(toggleConfig.inactiveLabel); + } + } + } + + private async toggle() { + const toggleConfig = this.getToggleConfig(); + if (!toggleConfig) return; + + const current = this.callbacks.getSettings().permissionMode; + const newMode = current === toggleConfig.activeValue + ? toggleConfig.inactiveValue + : toggleConfig.activeValue; + await this.callbacks.onPermissionModeChange(newMode); + this.updateDisplay(); + } +} + +export class ServiceTierToggle { + private container: HTMLElement; + private buttonEl: HTMLElement | null = null; + private iconEl: HTMLElement | null = null; + private callbacks: ToolbarCallbacks; + + constructor(parentEl: HTMLElement, callbacks: ToolbarCallbacks) { + this.callbacks = callbacks; + this.container = parentEl.createDiv({ cls: 'claudian-service-tier-toggle' }); + this.render(); + } + + private render() { + this.container.empty(); + + this.buttonEl = this.container.createDiv({ cls: 'claudian-service-tier-button' }); + this.iconEl = this.buttonEl.createSpan({ cls: 'claudian-service-tier-icon' }); + setIcon(this.iconEl, 'zap'); + + this.updateDisplay(); + + this.buttonEl.addEventListener('click', () => { + runToolbarAction(() => this.toggle(), 'Failed to change service tier'); + }); + } + + private getToggleConfig(): ProviderServiceTierToggleConfig | null { + const uiConfig = this.callbacks.getUIConfig(); + return uiConfig.getServiceTierToggle?.(this.callbacks.getSettings()) ?? null; + } + + updateDisplay() { + if (!this.buttonEl || !this.iconEl) return; + + const toggleConfig = this.getToggleConfig(); + if (!toggleConfig) { + this.container.addClass('claudian-hidden'); + return; + } + + this.container.removeClass('claudian-hidden'); + const current = this.callbacks.getSettings().serviceTier; + const isActive = current === toggleConfig.activeValue; + if (isActive) { + this.buttonEl.addClass('active'); + } else { + this.buttonEl.removeClass('active'); + } + + this.container.setAttribute('title', 'Toggle on/off fast mode'); + } + + private async toggle() { + const toggleConfig = this.getToggleConfig(); + if (!toggleConfig) return; + + const current = this.callbacks.getSettings().serviceTier; + const next = current === toggleConfig.activeValue + ? toggleConfig.inactiveValue + : toggleConfig.activeValue; + await this.callbacks.onServiceTierChange(next); + this.updateDisplay(); + } +} + +export type AddExternalContextResult = + | { success: true; normalizedPath: string } + | { success: false; error: string }; + +export class ExternalContextSelector { + private container: HTMLElement; + private iconEl: HTMLElement | null = null; + private badgeEl: HTMLElement | null = null; + private dropdownEl: HTMLElement | null = null; + private callbacks: ToolbarCallbacks; + /** + * Current external context paths. May contain: + * - Persistent paths only (new sessions via clearExternalContexts) + * - Restored session paths (loaded sessions via setExternalContexts) + * - Mixed paths during active sessions + */ + private externalContextPaths: string[] = []; + /** Paths that persist across all sessions (stored in settings). */ + private persistentPaths: Set = new Set(); + private onChangeCallback: ((paths: string[]) => void) | null = null; + private onPersistenceChangeCallback: ((paths: string[]) => void) | null = null; + + constructor(parentEl: HTMLElement, callbacks: ToolbarCallbacks) { + this.callbacks = callbacks; + this.container = parentEl.createDiv({ cls: 'claudian-external-context-selector' }); + this.render(); + } + + setOnChange(callback: (paths: string[]) => void): void { + this.onChangeCallback = callback; + } + + setOnPersistenceChange(callback: (paths: string[]) => void): void { + this.onPersistenceChangeCallback = callback; + } + + getExternalContexts(): string[] { + return [...this.externalContextPaths]; + } + + getPersistentPaths(): string[] { + return [...this.persistentPaths]; + } + + setPersistentPaths(paths: string[]): void { + // Validate paths - remove non-existent directories + const validPaths = filterValidPaths(paths); + const invalidPaths = paths.filter(p => !validPaths.includes(p)); + + this.persistentPaths = new Set(validPaths); + // Merge persistent paths into external context paths + this.mergePersistentPaths(); + this.updateDisplay(); + this.renderDropdown(); + + // If invalid paths were removed, notify user and save updated list + if (invalidPaths.length > 0) { + const pathNames = invalidPaths.map(p => this.shortenPath(p)).join(', '); + new Notice(`Removed ${invalidPaths.length} invalid external context path(s): ${pathNames}`, 5000); + this.onPersistenceChangeCallback?.([...this.persistentPaths]); + } + } + + togglePersistence(path: string): void { + if (this.persistentPaths.has(path)) { + this.persistentPaths.delete(path); + } else { + // Validate path still exists before persisting + if (!isValidDirectoryPath(path)) { + new Notice(`Cannot persist "${this.shortenPath(path)}" - directory no longer exists`, 4000); + return; + } + this.persistentPaths.add(path); + } + this.onPersistenceChangeCallback?.([...this.persistentPaths]); + this.renderDropdown(); + } + + private mergePersistentPaths(): void { + const pathSet = new Set(this.externalContextPaths); + for (const path of this.persistentPaths) { + pathSet.add(path); + } + this.externalContextPaths = [...pathSet]; + } + + /** + * Restore exact external context paths from a saved conversation. + * Does NOT merge with persistent paths - preserves the session's historical state. + * Use clearExternalContexts() for new sessions to start with current persistent paths. + */ + setExternalContexts(paths: string[]): void { + this.externalContextPaths = [...paths]; + this.updateDisplay(); + this.renderDropdown(); + } + + /** + * Remove a path from external contexts (and persistent paths if applicable). + * Exposed for testing the remove button behavior. + */ + removePath(pathStr: string): void { + this.externalContextPaths = this.externalContextPaths.filter(p => p !== pathStr); + // Also remove from persistent paths if it was persistent + if (this.persistentPaths.has(pathStr)) { + this.persistentPaths.delete(pathStr); + this.onPersistenceChangeCallback?.([...this.persistentPaths]); + } + this.onChangeCallback?.(this.externalContextPaths); + this.updateDisplay(); + this.renderDropdown(); + } + + /** + * Add an external context path programmatically (e.g., from /add-dir command). + * Validates the path and handles duplicates/conflicts. + * @param pathInput - Path string (supports ~/ expansion) + * @returns Result with success status and normalized path, or error message on failure + */ + addExternalContext(pathInput: string): AddExternalContextResult { + const trimmed = pathInput?.trim(); + if (!trimmed) { + return { success: false, error: 'No path provided. Usage: /add-dir /absolute/path' }; + } + + // Strip surrounding quotes if present (e.g., "/path/with spaces") + let cleanPath = trimmed; + if ((cleanPath.startsWith('"') && cleanPath.endsWith('"')) || + (cleanPath.startsWith("'") && cleanPath.endsWith("'"))) { + cleanPath = cleanPath.slice(1, -1); + } + + // Expand home directory and normalize path + const expandedPath = expandHomePath(cleanPath); + const normalizedPath = normalizePathForFilesystem(expandedPath); + + if (!path.isAbsolute(normalizedPath)) { + return { success: false, error: 'Path must be absolute. Usage: /add-dir /absolute/path' }; + } + + // Validate path exists and is a directory with specific error messages + const validation = validateDirectoryPath(normalizedPath); + if (!validation.valid) { + return { success: false, error: `${validation.error}: ${pathInput}` }; + } + + // Check for duplicate (normalized comparison for cross-platform support) + if (isDuplicatePath(normalizedPath, this.externalContextPaths)) { + return { success: false, error: 'This folder is already added as an external context.' }; + } + + // Check for nested/overlapping paths + const conflict = findConflictingPath(normalizedPath, this.externalContextPaths); + if (conflict) { + return { success: false, error: this.formatConflictMessage(normalizedPath, conflict) }; + } + + // Add the path + this.externalContextPaths = [...this.externalContextPaths, normalizedPath]; + this.onChangeCallback?.(this.externalContextPaths); + this.updateDisplay(); + this.renderDropdown(); + + return { success: true, normalizedPath }; + } + + /** + * Clear session-only external context paths (call on new conversation). + * Uses persistent paths from settings if provided, otherwise falls back to local cache. + * Validates paths before using them (silently filters invalid during session init). + */ + clearExternalContexts(persistentPathsFromSettings?: string[]): void { + // Use settings value if provided (most up-to-date), otherwise use local cache + if (persistentPathsFromSettings) { + // Validate paths - silently filter during session initialization (not user action) + const validPaths = filterValidPaths(persistentPathsFromSettings); + this.persistentPaths = new Set(validPaths); + } + this.externalContextPaths = [...this.persistentPaths]; + this.updateDisplay(); + this.renderDropdown(); + } + + private render() { + this.container.empty(); + + const iconWrapper = this.container.createDiv({ cls: 'claudian-external-context-icon-wrapper' }); + + this.iconEl = iconWrapper.createDiv({ cls: 'claudian-external-context-icon' }); + setIcon(this.iconEl, 'folder'); + + this.badgeEl = iconWrapper.createDiv({ cls: 'claudian-external-context-badge' }); + + this.updateDisplay(); + + // Click to open native folder picker + iconWrapper.addEventListener('click', (e) => { + e.stopPropagation(); + void this.openFolderPicker(); + }); + + this.dropdownEl = this.container.createDiv({ cls: 'claudian-external-context-dropdown' }); + this.renderDropdown(); + } + + private async openFolderPicker() { + try { + // Access Electron's dialog through remote + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Electron remote is exposed only at runtime in Obsidian's renderer. + const { remote } = require('electron') as { remote?: ElectronRemoteApi }; + if (!remote) { + throw new Error('Electron remote API is unavailable'); + } + const result = await remote.dialog.showOpenDialog({ + properties: ['openDirectory'], + title: 'Select External Context', + }); + + if (!result.canceled && result.filePaths.length > 0) { + const selectedPath = result.filePaths[0]; + + // Check for duplicate (normalized comparison for cross-platform support) + if (isDuplicatePath(selectedPath, this.externalContextPaths)) { + new Notice('This folder is already added as an external context.', 3000); + return; + } + + // Check for nested/overlapping paths + const conflict = findConflictingPath(selectedPath, this.externalContextPaths); + if (conflict) { + new Notice(this.formatConflictMessage(selectedPath, conflict), 5000); + return; + } + + this.externalContextPaths = [...this.externalContextPaths, selectedPath]; + this.onChangeCallback?.(this.externalContextPaths); + this.updateDisplay(); + this.renderDropdown(); + } + } catch { + new Notice('Unable to open folder picker.', 5000); + } + } + + /** Formats a conflict error message for display. */ + private formatConflictMessage(newPath: string, conflict: { path: string; type: 'parent' | 'child' }): string { + const shortNew = this.shortenPath(newPath); + const shortExisting = this.shortenPath(conflict.path); + return conflict.type === 'parent' + ? `Cannot add "${shortNew}" - it's inside existing path "${shortExisting}"` + : `Cannot add "${shortNew}" - it contains existing path "${shortExisting}"`; + } + + private renderDropdown() { + if (!this.dropdownEl) return; + + this.dropdownEl.empty(); + + // Header + const headerEl = this.dropdownEl.createDiv({ cls: 'claudian-external-context-header' }); + headerEl.setText('External contexts'); + + // Path list + const listEl = this.dropdownEl.createDiv({ cls: 'claudian-external-context-list' }); + + if (this.externalContextPaths.length === 0) { + const emptyEl = listEl.createDiv({ cls: 'claudian-external-context-empty' }); + emptyEl.setText('Click folder icon to add'); + } else { + for (const pathStr of this.externalContextPaths) { + const itemEl = listEl.createDiv({ cls: 'claudian-external-context-item' }); + + const pathTextEl = itemEl.createSpan({ cls: 'claudian-external-context-text' }); + // Show shortened path for display + const displayPath = this.shortenPath(pathStr); + pathTextEl.setText(displayPath); + pathTextEl.setAttribute('title', pathStr); + + // Lock toggle button + const isPersistent = this.persistentPaths.has(pathStr); + const lockBtn = itemEl.createSpan({ cls: 'claudian-external-context-lock' }); + if (isPersistent) { + lockBtn.addClass('locked'); + } + setIcon(lockBtn, isPersistent ? 'lock' : 'unlock'); + lockBtn.setAttribute('title', isPersistent ? 'Persistent (click to make session-only)' : 'Session-only (click to persist)'); + lockBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.togglePersistence(pathStr); + }); + + const removeBtn = itemEl.createSpan({ cls: 'claudian-external-context-remove' }); + setIcon(removeBtn, 'x'); + removeBtn.setAttribute('title', 'Remove path'); + removeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.removePath(pathStr); + }); + } + } + } + + /** Shorten path for display (replace home dir with ~) */ + private shortenPath(fullPath: string): string { + try { + const homeDir = os.homedir(); + const normalize = (value: string) => value.replace(/\\/g, '/'); + const normalizedFull = normalize(fullPath); + const normalizedHome = normalize(homeDir); + const compareFull = process.platform === 'win32' + ? normalizedFull.toLowerCase() + : normalizedFull; + const compareHome = process.platform === 'win32' + ? normalizedHome.toLowerCase() + : normalizedHome; + if (compareFull.startsWith(compareHome)) { + // Use normalized path length and normalize the result for consistent display + const remainder = normalizedFull.slice(normalizedHome.length); + return '~' + remainder; + } + } catch { + // Fall through to return full path + } + return fullPath; + } + + updateDisplay() { + if (!this.iconEl || !this.badgeEl) return; + + const count = this.externalContextPaths.length; + + if (count > 0) { + this.iconEl.addClass('active'); + this.iconEl.setAttribute('title', `${count} external context${count > 1 ? 's' : ''} (click to add more)`); + + // Show badge only when more than 1 path + if (count > 1) { + this.badgeEl.setText(String(count)); + this.badgeEl.addClass('visible'); + } else { + this.badgeEl.removeClass('visible'); + } + } else { + this.iconEl.removeClass('active'); + this.iconEl.setAttribute('title', 'Add external contexts (click)'); + this.badgeEl.removeClass('visible'); + } + } +} + +export class McpServerSelector { + private container: HTMLElement; + private iconEl: HTMLElement | null = null; + private badgeEl: HTMLElement | null = null; + private dropdownEl: HTMLElement | null = null; + private mcpManager: McpServerManager | null = null; + private enabledServers: Set = new Set(); + private onChangeCallback: ((enabled: Set) => void) | null = null; + private visible = true; + + constructor(parentEl: HTMLElement) { + this.container = parentEl.createDiv({ cls: 'claudian-mcp-selector' }); + this.render(); + } + + setVisible(visible: boolean): void { + this.visible = visible; + if (!visible) { + this.container.addClass('claudian-hidden'); + } else { + this.updateDisplay(); + } + } + + setMcpManager(manager: McpServerManager | null): void { + this.mcpManager = manager; + if (!manager && this.enabledServers.size > 0) { + this.enabledServers.clear(); + this.onChangeCallback?.(this.enabledServers); + } + this.pruneEnabledServers(); + this.updateDisplay(); + this.renderDropdown(); + } + + setOnChange(callback: (enabled: Set) => void): void { + this.onChangeCallback = callback; + } + + getEnabledServers(): Set { + return new Set(this.enabledServers); + } + + addMentionedServers(names: Set): void { + let changed = false; + for (const name of names) { + if (!this.enabledServers.has(name)) { + this.enabledServers.add(name); + changed = true; + } + } + if (changed) { + this.updateDisplay(); + this.renderDropdown(); + } + } + + clearEnabled(): void { + this.enabledServers.clear(); + this.updateDisplay(); + this.renderDropdown(); + } + + setEnabledServers(names: string[]): void { + this.enabledServers = new Set(names); + this.pruneEnabledServers(); + this.updateDisplay(); + this.renderDropdown(); + } + + private pruneEnabledServers(): void { + if (!this.mcpManager) return; + const activeNames = new Set(this.mcpManager.getServers().filter((s) => s.enabled).map((s) => s.name)); + let changed = false; + for (const name of this.enabledServers) { + if (!activeNames.has(name)) { + this.enabledServers.delete(name); + changed = true; + } + } + if (changed) { + this.onChangeCallback?.(this.enabledServers); + } + } + + private render() { + this.container.empty(); + + const iconWrapper = this.container.createDiv({ cls: 'claudian-mcp-selector-icon-wrapper' }); + + this.iconEl = iconWrapper.createDiv({ cls: 'claudian-mcp-selector-icon' }); + appendMcpIcon(this.iconEl); + + this.badgeEl = iconWrapper.createDiv({ cls: 'claudian-mcp-selector-badge' }); + + this.updateDisplay(); + + this.dropdownEl = this.container.createDiv({ cls: 'claudian-mcp-selector-dropdown' }); + this.renderDropdown(); + + // Re-render dropdown content on hover (CSS handles visibility) + this.container.addEventListener('mouseenter', () => { + this.renderDropdown(); + }); + } + + private renderDropdown() { + if (!this.dropdownEl) return; + this.pruneEnabledServers(); + this.dropdownEl.empty(); + + // Header + const headerEl = this.dropdownEl.createDiv({ cls: 'claudian-mcp-selector-header' }); + headerEl.setText('Mcp servers'); + + // Server list + const listEl = this.dropdownEl.createDiv({ cls: 'claudian-mcp-selector-list' }); + + const allServers = this.mcpManager?.getServers() || []; + const servers = allServers.filter(s => s.enabled); + + if (servers.length === 0) { + const emptyEl = listEl.createDiv({ cls: 'claudian-mcp-selector-empty' }); + emptyEl.setText(allServers.length === 0 ? 'No MCP servers configured' : 'All MCP servers disabled'); + return; + } + + for (const server of servers) { + this.renderServerItem(listEl, server); + } + } + + private renderServerItem(listEl: HTMLElement, server: ManagedMcpServer) { + const itemEl = listEl.createDiv({ cls: 'claudian-mcp-selector-item' }); + itemEl.dataset.serverName = server.name; + + const isEnabled = this.enabledServers.has(server.name); + if (isEnabled) { + itemEl.addClass('enabled'); + } + + // Checkbox + const checkEl = itemEl.createDiv({ cls: 'claudian-mcp-selector-check' }); + if (isEnabled) { + appendCheckIcon(checkEl); + } + + // Info + const infoEl = itemEl.createDiv({ cls: 'claudian-mcp-selector-item-info' }); + + const nameEl = infoEl.createSpan({ cls: 'claudian-mcp-selector-item-name' }); + nameEl.setText(server.name); + + // Badges + if (server.contextSaving) { + const csEl = infoEl.createSpan({ cls: 'claudian-mcp-selector-cs-badge' }); + csEl.setText('@'); + csEl.setAttribute('title', 'Context-saving: can also enable via @' + server.name); + } + + // Click to toggle (use mousedown for more reliable capture) + itemEl.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + this.toggleServer(server.name, itemEl); + }); + } + + private toggleServer(name: string, itemEl: HTMLElement) { + if (this.enabledServers.has(name)) { + this.enabledServers.delete(name); + } else { + this.enabledServers.add(name); + } + + // Update item visually in-place (immediate feedback) + const isEnabled = this.enabledServers.has(name); + const checkEl = itemEl.querySelector('.claudian-mcp-selector-check'); + + if (isEnabled) { + itemEl.addClass('enabled'); + if (checkEl) appendCheckIcon(checkEl); + } else { + itemEl.removeClass('enabled'); + if (checkEl) checkEl.empty(); + } + + this.updateDisplay(); + this.onChangeCallback?.(this.enabledServers); + } + + updateDisplay() { + this.pruneEnabledServers(); + if (!this.iconEl || !this.badgeEl) return; + + const count = this.enabledServers.size; + const hasServers = (this.mcpManager?.getServers().length || 0) > 0; + + // Show/hide container based on whether there are servers and visibility + if (!hasServers || !this.visible) { + this.container.addClass('claudian-hidden'); + return; + } + this.container.removeClass('claudian-hidden'); + + if (count > 0) { + this.iconEl.addClass('active'); + this.iconEl.setAttribute('title', `${count} MCP server${count > 1 ? 's' : ''} enabled (click to manage)`); + + // Show badge only when more than 1 + if (count > 1) { + this.badgeEl.setText(String(count)); + this.badgeEl.addClass('visible'); + } else { + this.badgeEl.removeClass('visible'); + } + } else { + this.iconEl.removeClass('active'); + this.iconEl.setAttribute('title', 'Mcp servers (click to enable)'); + this.badgeEl.removeClass('visible'); + } + } +} + +export class ContextUsageMeter { + private container: HTMLElement; + private fillPath: SVGPathElement | null = null; + private percentEl: HTMLElement | null = null; + private circumference: number = 0; + + constructor(parentEl: HTMLElement) { + this.container = parentEl.createDiv({ cls: 'claudian-context-meter' }); + this.render(); + // Initially hidden + this.container.addClass('claudian-hidden'); + } + + setVisible(visible: boolean): void { + this.container.toggleClass('claudian-hidden', !visible); + } + + private render() { + const size = 16; + const strokeWidth = 2; + const radius = (size - strokeWidth) / 2; + const cx = size / 2; + const cy = size / 2; + + // 240° arc: from 150° to 390° (upper-left through bottom to upper-right) + const startAngle = 150; + const endAngle = 390; + const arcDegrees = endAngle - startAngle; + const arcRadians = (arcDegrees * Math.PI) / 180; + this.circumference = radius * arcRadians; + + const startRad = (startAngle * Math.PI) / 180; + const endRad = (endAngle * Math.PI) / 180; + const x1 = cx + radius * Math.cos(startRad); + const y1 = cy + radius * Math.sin(startRad); + const x2 = cx + radius * Math.cos(endRad); + const y2 = cy + radius * Math.sin(endRad); + + const gaugeEl = this.container.createDiv({ cls: 'claudian-context-meter-gauge' }); + const svg = gaugeEl.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', String(size)); + svg.setAttribute('height', String(size)); + svg.setAttribute('viewBox', `0 0 ${size} ${size}`); + + const pathData = `M ${x1} ${y1} A ${radius} ${radius} 0 1 1 ${x2} ${y2}`; + const backgroundPath = gaugeEl.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'path'); + backgroundPath.classList.add('claudian-meter-bg'); + backgroundPath.setAttribute('d', pathData); + backgroundPath.setAttribute('fill', 'none'); + backgroundPath.setAttribute('stroke-width', String(strokeWidth)); + backgroundPath.setAttribute('stroke-linecap', 'round'); + + const fillPath = gaugeEl.ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'path'); + fillPath.classList.add('claudian-meter-fill'); + fillPath.setAttribute('d', pathData); + fillPath.setAttribute('fill', 'none'); + fillPath.setAttribute('stroke-width', String(strokeWidth)); + fillPath.setAttribute('stroke-linecap', 'round'); + fillPath.setAttribute('stroke-dasharray', String(this.circumference)); + fillPath.setAttribute('stroke-dashoffset', String(this.circumference)); + + svg.appendChild(backgroundPath); + svg.appendChild(fillPath); + gaugeEl.appendChild(svg); + this.fillPath = fillPath; + + this.percentEl = this.container.createSpan({ cls: 'claudian-context-meter-percent' }); + } + + update(usage: UsageInfo | null): void { + if (!usage || usage.contextTokens <= 0) { + this.container.addClass('claudian-hidden'); + return; + } + this.container.removeClass('claudian-hidden'); + const fillLength = (usage.percentage / 100) * this.circumference; + if (this.fillPath) { + this.fillPath.setAttribute('stroke-dashoffset', String(this.circumference - fillLength)); + } + + if (this.percentEl) { + this.percentEl.setText(`${usage.percentage}%`); + } + + // Toggle warning class for > 80% + if (usage.percentage > 80) { + this.container.addClass('warning'); + } else { + this.container.removeClass('warning'); + } + + // Set tooltip with detailed usage + let tooltip = `${this.formatTokens(usage.contextTokens)} / ${this.formatTokens(usage.contextWindow)}`; + if (usage.percentage > 80) { + tooltip += ' (Approaching limit, run `/compact` to continue)'; + } + this.container.setAttribute('data-tooltip', tooltip); + } + + private formatTokens(tokens: number): string { + if (tokens >= 1000) { + return `${Math.round(tokens / 1000)}k`; + } + return String(tokens); + } +} + +export function createInputToolbar( + parentEl: HTMLElement, + callbacks: ToolbarCallbacks +): { + modelSelector: ModelSelector; + modeSelector: ModeSelector; + thinkingBudgetSelector: ThinkingBudgetSelector; + contextUsageMeter: ContextUsageMeter | null; + externalContextSelector: ExternalContextSelector; + mcpServerSelector: McpServerSelector; + permissionToggle: PermissionToggle; + serviceTierToggle: ServiceTierToggle; +} { + const modelSelector = new ModelSelector(parentEl, callbacks); + const thinkingBudgetSelector = new ThinkingBudgetSelector(parentEl, callbacks); + const serviceTierToggle = new ServiceTierToggle(parentEl, callbacks); + const contextUsageMeter = new ContextUsageMeter(parentEl); + const externalContextSelector = new ExternalContextSelector(parentEl, callbacks); + const mcpServerSelector = new McpServerSelector(parentEl); + const permissionToggle = new PermissionToggle(parentEl, callbacks); + const modeSelector = new ModeSelector(parentEl, callbacks); + + return { + modelSelector, + modeSelector, + thinkingBudgetSelector, + serviceTierToggle, + contextUsageMeter, + externalContextSelector, + mcpServerSelector, + permissionToggle, + }; +} diff --git a/src/features/chat/ui/InstructionModeManager.ts b/src/features/chat/ui/InstructionModeManager.ts new file mode 100644 index 0000000..4157a49 --- /dev/null +++ b/src/features/chat/ui/InstructionModeManager.ts @@ -0,0 +1,158 @@ +export interface InstructionModeCallbacks { + onSubmit: (rawInstruction: string) => Promise; + getInputWrapper: () => HTMLElement | null; + resetInputHeight?: () => void; +} + +export interface InstructionModeState { + active: boolean; + rawInstruction: string; +} + +const INSTRUCTION_MODE_PLACEHOLDER = '# Save in custom system prompt'; + +export class InstructionModeManager { + private inputEl: HTMLTextAreaElement; + private callbacks: InstructionModeCallbacks; + private state: InstructionModeState = { active: false, rawInstruction: '' }; + private isSubmitting = false; + private originalPlaceholder: string = ''; + + constructor( + inputEl: HTMLTextAreaElement, + callbacks: InstructionModeCallbacks + ) { + this.inputEl = inputEl; + this.callbacks = callbacks; + this.originalPlaceholder = inputEl.placeholder; + } + + /** + * Handles keydown to detect # trigger. + * Returns true if the event was consumed (should prevent default). + */ + handleTriggerKey(e: KeyboardEvent): boolean { + // Only trigger on # keystroke when input is empty and not already in mode + if (!this.state.active && this.inputEl.value === '' && e.key === '#') { + if (this.enterMode()) { + e.preventDefault(); + return true; + } + } + return false; + } + + /** Handles input changes to track instruction text. */ + handleInputChange(): void { + if (!this.state.active) return; + + const text = this.inputEl.value; + if (text === '') { + this.exitMode(); + } else { + this.state.rawInstruction = text; + } + } + + /** + * Enters instruction mode. + * Only enters if the indicator can be successfully shown. + * Returns true if mode was entered, false otherwise. + */ + private enterMode(): boolean { + // Indicator is single source of truth - only enter mode if we can show it + const wrapper = this.callbacks.getInputWrapper(); + if (!wrapper) return false; + + wrapper.addClass('claudian-input-instruction-mode'); + this.state = { active: true, rawInstruction: '' }; + this.inputEl.placeholder = INSTRUCTION_MODE_PLACEHOLDER; + return true; + } + + /** Exits instruction mode, restoring original state. */ + private exitMode(): void { + const wrapper = this.callbacks.getInputWrapper(); + if (wrapper) { + wrapper.removeClass('claudian-input-instruction-mode'); + } + this.state = { active: false, rawInstruction: '' }; + this.inputEl.placeholder = this.originalPlaceholder; + } + + /** Handles keydown events. Returns true if handled. */ + handleKeydown(e: KeyboardEvent): boolean { + if (!this.state.active) return false; + + // Check !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { + // Don't handle if instruction is empty + if (!this.state.rawInstruction.trim()) { + return false; + } + + e.preventDefault(); + void this.submit(); + return true; + } + + // Check !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) + if (e.key === 'Escape' && !e.isComposing) { + e.preventDefault(); + this.cancel(); + return true; + } + + return false; + } + + /** Checks if instruction mode is active. */ + isActive(): boolean { + return this.state.active; + } + + /** Gets the current raw instruction text. */ + getRawInstruction(): string { + return this.state.rawInstruction; + } + + /** Submits the instruction for refinement. */ + private async submit(): Promise { + if (this.isSubmitting) return; + + const rawInstruction = this.state.rawInstruction.trim(); + if (!rawInstruction) return; + + this.isSubmitting = true; + + try { + await this.callbacks.onSubmit(rawInstruction); + } finally { + this.isSubmitting = false; + } + } + + /** Cancels instruction mode and clears input. */ + private cancel(): void { + this.inputEl.value = ''; + this.exitMode(); + this.callbacks.resetInputHeight?.(); + } + + /** Clears the input and resets state (called after successful submission). */ + clear(): void { + this.inputEl.value = ''; + this.exitMode(); + this.callbacks.resetInputHeight?.(); + } + + /** Cleans up event listeners. */ + destroy(): void { + // Remove indicator class and restore placeholder on destroy + const wrapper = this.callbacks.getInputWrapper(); + if (wrapper) { + wrapper.removeClass('claudian-input-instruction-mode'); + } + this.inputEl.placeholder = this.originalPlaceholder; + } +} diff --git a/src/features/chat/ui/NavigationSidebar.ts b/src/features/chat/ui/NavigationSidebar.ts new file mode 100644 index 0000000..2232832 --- /dev/null +++ b/src/features/chat/ui/NavigationSidebar.ts @@ -0,0 +1,284 @@ +import { setIcon } from 'obsidian'; + +import { + cancelScheduledAnimationFrame, + scheduleAnimationFrame, + type ScheduledAnimationFrame, +} from '../../../utils/animationFrame'; +import { formatConversationDirectoryTitle } from '../utils/conversationDirectoryTitle'; + +/** + * Floating sidebar for navigating chat history. + * Provides quick access to top/bottom and previous/next user messages. + */ +export class NavigationSidebar { + private container: HTMLElement; + private topBtn: HTMLElement; + private prevBtn: HTMLElement; + private tocBtn: HTMLElement; + private nextBtn: HTMLElement; + private bottomBtn: HTMLElement; + private tocPopover: HTMLElement | null = null; + private scrollHandler: () => void = () => {}; + private outsideClickHandler: ((event: MouseEvent) => void) | null = null; + private mutationObserver: MutationObserver | null = null; + private pendingVisibilityFrame: ScheduledAnimationFrame | null = null; + private isVisible: boolean | null = null; + + constructor( + private parentEl: HTMLElement, + private messagesEl: HTMLElement + ) { + this.container = this.parentEl.createDiv({ cls: 'claudian-nav-sidebar' }); + + // Create buttons + this.topBtn = this.createButton('claudian-nav-btn-top', 'chevrons-up', 'Scroll to top'); + this.prevBtn = this.createButton('claudian-nav-btn-prev', 'chevron-up', 'Previous message'); + this.tocBtn = this.createButton('claudian-nav-btn-toc', 'list-tree', 'Conversation directory'); + this.nextBtn = this.createButton('claudian-nav-btn-next', 'chevron-down', 'Next message'); + this.bottomBtn = this.createButton('claudian-nav-btn-bottom', 'chevrons-down', 'Scroll to bottom'); + + this.setupEventListeners(); + this.applyVisibility(); + } + + private createButton(cls: string, icon: string, label: string): HTMLElement { + const btn = this.container.createDiv({ cls: `claudian-nav-btn ${cls}` }); + setIcon(btn, icon); + btn.setAttribute('aria-label', label); + return btn; + } + + private setupEventListeners(): void { + // Scroll handling to toggle visibility + this.scrollHandler = () => this.updateVisibility(); + this.messagesEl.addEventListener('scroll', this.scrollHandler, { passive: true }); + + // Button clicks + this.topBtn.addEventListener('click', () => { + this.messagesEl.scrollTo({ top: 0, behavior: 'smooth' }); + }); + + this.bottomBtn.addEventListener('click', () => { + this.messagesEl.scrollTo({ top: this.messagesEl.scrollHeight, behavior: 'smooth' }); + }); + + this.prevBtn.addEventListener('click', () => this.scrollToMessage('prev')); + this.nextBtn.addEventListener('click', () => this.scrollToMessage('next')); + this.tocBtn.addEventListener('click', (event) => { + event.stopPropagation(); + this.toggleDirectory(); + }); + + this.outsideClickHandler = (event: MouseEvent) => { + const target = event.target as Node | null; + if (!target) return; + const containerContainsTarget = typeof this.container.contains === 'function' + && this.container.contains(target); + const popoverContainsTarget = typeof this.tocPopover?.contains === 'function' + && this.tocPopover.contains(target); + if (!containerContainsTarget && !popoverContainsTarget) { + this.closeDirectory(); + } + }; + this.parentEl.ownerDocument?.addEventListener?.('click', this.outsideClickHandler); + + if (typeof MutationObserver !== 'undefined') { + this.mutationObserver = new MutationObserver((mutations) => { + this.updateVisibility(); + if (this.shouldRefreshDirectory(mutations)) { + this.refreshOpenDirectory(); + } + }); + this.mutationObserver.observe(this.messagesEl, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['data-toc-title'], + }); + } + } + + /** + * Updates visibility of the sidebar based on scroll state. + * Visible if content overflows. + */ + updateVisibility(): void { + if (this.pendingVisibilityFrame !== null) return; + this.pendingVisibilityFrame = scheduleAnimationFrame(() => { + this.pendingVisibilityFrame = null; + this.applyVisibility(); + }, this.messagesEl.ownerDocument.defaultView ?? null); + } + + private applyVisibility(): void { + const { scrollHeight, clientHeight } = this.messagesEl; + const isScrollable = scrollHeight > clientHeight + 50; // Small buffer + this.tocBtn.classList.remove('claudian-hidden'); + if (this.isVisible === isScrollable) return; + this.isVisible = isScrollable; + this.container.classList.toggle('visible', isScrollable); + } + + private getDirectoryEntries(): Array<{ el: HTMLElement; title: string }> { + return Array.from(this.messagesEl.querySelectorAll('.claudian-message-user, [data-role="user"]')) + .map(el => ({ + el, + title: this.getDirectoryTitle(el), + })) + .filter((entry): entry is { el: HTMLElement; title: string } => entry.title.length > 0); + } + + private getDirectoryTitle(el: HTMLElement): string { + const explicitTitle = (el.getAttribute('data-toc-title') ?? '').trim(); + if (explicitTitle) return explicitTitle; + + const contentEl = el.querySelector('.claudian-message-content'); + return formatConversationDirectoryTitle(contentEl?.textContent ?? el.textContent ?? ''); + } + + private shouldRefreshDirectory(mutations: MutationRecord[]): boolean { + if (!this.tocPopover) return false; + return mutations.some((mutation) => { + if (mutation.type === 'attributes') { + return mutation.attributeName === 'data-toc-title' + && this.isDirectoryMessageElement(mutation.target); + } + if (mutation.type !== 'childList') return false; + return Array.from(mutation.addedNodes).some(node => this.nodeContainsDirectoryMessage(node)) + || Array.from(mutation.removedNodes).some(node => this.nodeContainsDirectoryMessage(node)); + }); + } + + private nodeContainsDirectoryMessage(node: Node): boolean { + if (this.isDirectoryMessageElement(node)) return true; + const candidate = node as { querySelector?: (selector: string) => Element | null }; + return typeof candidate.querySelector === 'function' + && candidate.querySelector('.claudian-message-user, [data-role="user"]') !== null; + } + + private isDirectoryMessageElement(node: Node): boolean { + const candidate = node as { + matches?: (selector: string) => boolean; + classList?: { contains?: (className: string) => boolean }; + getAttribute?: (name: string) => string | null; + }; + if (typeof candidate.matches === 'function') { + return candidate.matches('.claudian-message-user, [data-role="user"]'); + } + return candidate.classList?.contains?.('claudian-message-user') === true + || candidate.getAttribute?.('data-role') === 'user'; + } + + private toggleDirectory(): void { + if (this.tocPopover) { + this.closeDirectory(); + return; + } + this.openDirectory(); + } + + private openDirectory(): void { + const entries = this.getDirectoryEntries(); + this.closeDirectory(); + this.tocPopover = this.parentEl.createDiv({ cls: 'claudian-nav-toc-popover' }); + this.tocPopover.createDiv({ cls: 'claudian-nav-toc-title', text: 'Conversation directory' }); + const listEl = this.tocPopover.createDiv({ cls: 'claudian-nav-toc-list' }); + + if (entries.length === 0) { + listEl.createDiv({ + cls: 'claudian-nav-toc-empty', + text: 'No user prompts in this conversation', + }); + return; + } + + entries.forEach((entry, index) => { + const itemEl = listEl.createDiv({ + cls: 'claudian-nav-toc-item', + text: `${index + 1}. ${entry.title}`, + }); + itemEl.setAttribute('role', 'button'); + itemEl.setAttribute('tabindex', '0'); + itemEl.setAttribute('title', entry.title); + + const selectEntry = () => { + this.scrollToElement(entry.el); + this.closeDirectory(); + }; + itemEl.addEventListener('click', selectEntry); + itemEl.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + selectEntry(); + }); + }); + } + + private refreshOpenDirectory(): void { + if (!this.tocPopover) return; + this.openDirectory(); + } + + private closeDirectory(): void { + this.tocPopover?.remove(); + this.tocPopover = null; + } + + private scrollToElement(el: HTMLElement): void { + this.messagesEl.scrollTo({ + top: Math.max(el.offsetTop - 10, 0), + behavior: 'smooth', + }); + } + + /** + * Scrolls to previous or next user message, skipping assistant messages. + */ + private scrollToMessage(direction: 'prev' | 'next'): void { + const messages = Array.from(this.messagesEl.querySelectorAll('.claudian-message-user')); + + if (messages.length === 0) return; + + const scrollTop = this.messagesEl.scrollTop; + const threshold = 30; + + if (direction === 'prev') { + // Find the last message strictly above the current scroll position + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].offsetTop < scrollTop - threshold) { + this.scrollToElement(messages[i]); + return; + } + } + // Already at or above the first message — scroll to top + this.messagesEl.scrollTo({ top: 0, behavior: 'smooth' }); + } else { + // Find the first message strictly below the current scroll position + for (let i = 0; i < messages.length; i++) { + if (messages[i].offsetTop > scrollTop + threshold) { + this.scrollToElement(messages[i]); + return; + } + } + // Already at or past the last message — scroll to bottom + this.messagesEl.scrollTo({ top: this.messagesEl.scrollHeight, behavior: 'smooth' }); + } + } + + destroy(): void { + if (this.pendingVisibilityFrame !== null) { + cancelScheduledAnimationFrame(this.pendingVisibilityFrame); + this.pendingVisibilityFrame = null; + } + this.closeDirectory(); + if (this.outsideClickHandler) { + this.parentEl.ownerDocument?.removeEventListener?.('click', this.outsideClickHandler); + this.outsideClickHandler = null; + } + this.mutationObserver?.disconnect(); + this.mutationObserver = null; + this.messagesEl.removeEventListener('scroll', this.scrollHandler); + this.container.remove(); + } +} diff --git a/src/features/chat/ui/StatusPanel.ts b/src/features/chat/ui/StatusPanel.ts new file mode 100644 index 0000000..2da9138 --- /dev/null +++ b/src/features/chat/ui/StatusPanel.ts @@ -0,0 +1,601 @@ +import { Notice, setIcon } from 'obsidian'; + +import type { TodoItem } from '../../../core/tools/todo'; +import { getToolIcon } from '../../../core/tools/toolIcons'; +import { TOOL_TODO_WRITE } from '../../../core/tools/toolNames'; +import { t } from '../../../i18n/i18n'; +import { renderTodoItems } from '../rendering/todoUtils'; + +export interface PanelBashOutput { + id: string; + command: string; + status: 'running' | 'completed' | 'error'; + output: string; + exitCode?: number; +} + +const MAX_BASH_OUTPUTS = 50; + +/** + * StatusPanel - persistent bottom panel for todos and command output. + */ +export class StatusPanel { + private containerEl: HTMLElement | null = null; + private panelEl: HTMLElement | null = null; + + // Bash output section + private bashOutputContainerEl: HTMLElement | null = null; + private bashHeaderEl: HTMLElement | null = null; + private bashContentEl: HTMLElement | null = null; + private isBashExpanded = true; + private currentBashOutputs: Map = new Map(); + private bashEntryExpanded: Map = new Map(); + + // Todo section + private todoContainerEl: HTMLElement | null = null; + private todoHeaderEl: HTMLElement | null = null; + private todoContentEl: HTMLElement | null = null; + private isTodoExpanded = false; + private currentTodos: TodoItem[] | null = null; + + // Event handler references for cleanup + private todoClickHandler: (() => void) | null = null; + private todoKeydownHandler: ((e: KeyboardEvent) => void) | null = null; + private bashClickHandler: (() => void) | null = null; + private bashKeydownHandler: ((e: KeyboardEvent) => void) | null = null; + + /** + * Mount the panel into the messages container. + * Appends to the end of the messages area. + */ + mount(containerEl: HTMLElement): void { + this.containerEl = containerEl; + this.createPanel(); + } + + /** + * Remount the panel to restore state after conversation changes. + * Re-creates the panel structure and re-renders current state. + */ + remount(): void { + if (!this.containerEl) { + return; + } + + // Remove old event listeners before removing DOM + if (this.todoHeaderEl) { + if (this.todoClickHandler) { + this.todoHeaderEl.removeEventListener('click', this.todoClickHandler); + } + if (this.todoKeydownHandler) { + this.todoHeaderEl.removeEventListener('keydown', this.todoKeydownHandler); + } + } + this.todoClickHandler = null; + this.todoKeydownHandler = null; + + if (this.bashHeaderEl) { + if (this.bashClickHandler) { + this.bashHeaderEl.removeEventListener('click', this.bashClickHandler); + } + if (this.bashKeydownHandler) { + this.bashHeaderEl.removeEventListener('keydown', this.bashKeydownHandler); + } + } + this.bashClickHandler = null; + this.bashKeydownHandler = null; + + // Remove old panel from DOM + if (this.panelEl) { + this.panelEl.remove(); + } + + // Clear references and recreate + this.panelEl = null; + this.bashOutputContainerEl = null; + this.bashHeaderEl = null; + this.bashContentEl = null; + this.todoContainerEl = null; + this.todoHeaderEl = null; + this.todoContentEl = null; + this.createPanel(); + + // Re-render current state + this.renderBashOutputs(); + if (this.currentTodos && this.currentTodos.length > 0) { + this.updateTodos(this.currentTodos); + } + } + + /** + * Create the panel structure. + */ + private createPanel(): void { + if (!this.containerEl) { + return; + } + + const ownerDocument = this.containerEl.ownerDocument ?? window.document; + + // Create panel element (no border/background - seamless) + this.panelEl = ownerDocument.createElement('div'); + this.panelEl.className = 'claudian-status-panel'; + + // Bash output container - hidden by default + this.bashOutputContainerEl = ownerDocument.createElement('div'); + this.bashOutputContainerEl.className = 'claudian-status-panel-bash claudian-hidden'; + + this.bashHeaderEl = ownerDocument.createElement('div'); + this.bashHeaderEl.className = 'claudian-tool-header claudian-status-panel-bash-header'; + this.bashHeaderEl.setAttribute('tabindex', '0'); + this.bashHeaderEl.setAttribute('role', 'button'); + + this.bashClickHandler = () => this.toggleBashSection(); + this.bashKeydownHandler = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.toggleBashSection(); + } + }; + this.bashHeaderEl.addEventListener('click', this.bashClickHandler); + this.bashHeaderEl.addEventListener('keydown', this.bashKeydownHandler); + + this.bashContentEl = ownerDocument.createElement('div'); + this.bashContentEl.className = 'claudian-status-panel-bash-content'; + + this.bashOutputContainerEl.appendChild(this.bashHeaderEl); + this.bashOutputContainerEl.appendChild(this.bashContentEl); + this.panelEl.appendChild(this.bashOutputContainerEl); + + // Todo container + this.todoContainerEl = ownerDocument.createElement('div'); + this.todoContainerEl.className = 'claudian-status-panel-todos claudian-hidden'; + this.panelEl.appendChild(this.todoContainerEl); + + // Todo header (collapsed view) + this.todoHeaderEl = ownerDocument.createElement('div'); + this.todoHeaderEl.className = 'claudian-status-panel-header'; + this.todoHeaderEl.setAttribute('tabindex', '0'); + this.todoHeaderEl.setAttribute('role', 'button'); + + // Store handler references for cleanup + this.todoClickHandler = () => this.toggleTodos(); + this.todoKeydownHandler = (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.toggleTodos(); + } + }; + this.todoHeaderEl.addEventListener('click', this.todoClickHandler); + this.todoHeaderEl.addEventListener('keydown', this.todoKeydownHandler); + this.todoContainerEl.appendChild(this.todoHeaderEl); + + // Todo content (expanded list) + this.todoContentEl = ownerDocument.createElement('div'); + this.todoContentEl.className = 'claudian-status-panel-content claudian-todo-list-container claudian-hidden'; + this.todoContainerEl.appendChild(this.todoContentEl); + + this.containerEl.appendChild(this.panelEl); + } + + private syncPanelVisibility(): void { + if (!this.panelEl) return; + + const hasTodos = (this.currentTodos?.length ?? 0) > 0; + const hasBashOutputs = this.currentBashOutputs.size > 0; + this.panelEl.toggleClass('claudian-status-panel--visible', hasTodos || hasBashOutputs); + } + + /** + * Update the panel with new todo items. + * Called by ChatState.onTodosChanged callback when TodoWrite tool is used. + * Passing null or empty array hides the panel. + */ + updateTodos(todos: TodoItem[] | null): void { + if (!this.todoContainerEl || !this.todoHeaderEl || !this.todoContentEl) { + // Component not ready - don't update internal state to keep it consistent with display + return; + } + + // Update internal state only after confirming component is ready + this.currentTodos = todos; + + if (!todos || todos.length === 0) { + this.todoContainerEl.addClass('claudian-hidden'); + this.todoHeaderEl.empty(); + this.todoContentEl.empty(); + this.syncPanelVisibility(); + return; + } + + this.todoContainerEl.removeClass('claudian-hidden'); + this.syncPanelVisibility(); + + // Count completed and find current task + const completedCount = todos.filter(t => t.status === 'completed').length; + const totalCount = todos.length; + const currentTask = todos.find(t => t.status === 'in_progress'); + + // Update header + this.renderTodoHeader(completedCount, totalCount, currentTask); + + // Update content + this.renderTodoContent(todos); + + // Update ARIA + this.updateTodoAriaLabel(completedCount, totalCount); + + this.scrollToBottom(); + } + + /** + * Render the todo collapsed header. + */ + private renderTodoHeader(completedCount: number, totalCount: number, currentTask: TodoItem | undefined): void { + if (!this.todoHeaderEl) return; + + this.todoHeaderEl.empty(); + const ownerDocument = this.todoHeaderEl.ownerDocument ?? window.document; + + // List icon + const icon = ownerDocument.createElement('span'); + icon.className = 'claudian-status-panel-icon'; + setIcon(icon, getToolIcon(TOOL_TODO_WRITE)); + this.todoHeaderEl.appendChild(icon); + + // Label + const label = ownerDocument.createElement('span'); + label.className = 'claudian-status-panel-label'; + label.textContent = `Tasks (${completedCount}/${totalCount})`; + this.todoHeaderEl.appendChild(label); + + // Collapsed-only elements: status indicator and current task preview + if (!this.isTodoExpanded) { + // Status indicator (tick only when all todos complete) + if (completedCount === totalCount && totalCount > 0) { + const status = ownerDocument.createElement('span'); + status.className = 'claudian-status-panel-status status-completed'; + setIcon(status, 'check'); + this.todoHeaderEl.appendChild(status); + } + + // Current task preview + if (currentTask) { + const current = ownerDocument.createElement('span'); + current.className = 'claudian-status-panel-current'; + current.textContent = currentTask.activeForm; + this.todoHeaderEl.appendChild(current); + } + } + } + + /** + * Render the expanded todo content. + */ + private renderTodoContent(todos: TodoItem[]): void { + if (!this.todoContentEl) return; + renderTodoItems(this.todoContentEl, todos); + } + + /** + * Toggle todo expanded/collapsed state. + */ + private toggleTodos(): void { + this.isTodoExpanded = !this.isTodoExpanded; + this.updateTodoDisplay(); + } + + /** + * Update todo display based on expanded state. + */ + private updateTodoDisplay(): void { + if (!this.todoContentEl || !this.todoHeaderEl) return; + + // Show/hide content + this.todoContentEl.toggleClass('claudian-hidden', !this.isTodoExpanded); + + // Re-render header to update current task visibility + if (this.currentTodos && this.currentTodos.length > 0) { + const completedCount = this.currentTodos.filter(t => t.status === 'completed').length; + const totalCount = this.currentTodos.length; + const currentTask = this.currentTodos.find(t => t.status === 'in_progress'); + this.renderTodoHeader(completedCount, totalCount, currentTask); + this.updateTodoAriaLabel(completedCount, totalCount); + } + + this.scrollToBottom(); + } + + /** + * Update todo ARIA label. + */ + private updateTodoAriaLabel(completedCount: number, totalCount: number): void { + if (!this.todoHeaderEl) return; + + const action = this.isTodoExpanded ? 'Collapse' : 'Expand'; + this.todoHeaderEl.setAttribute( + 'aria-label', + `${action} task list - ${completedCount} of ${totalCount} completed` + ); + this.todoHeaderEl.setAttribute('aria-expanded', String(this.isTodoExpanded)); + } + + /** + * Scroll messages container to bottom. + */ + private scrollToBottom(): void { + if (this.containerEl) { + this.containerEl.scrollTop = this.containerEl.scrollHeight; + } + } + + // ============================================ + // Bash Output Methods + // ============================================ + + private truncateDescription(description: string, maxLength = 50): string { + if (description.length <= maxLength) return description; + return description.substring(0, maxLength) + '...'; + } + + addBashOutput(info: PanelBashOutput): void { + this.currentBashOutputs.set(info.id, info); + while (this.currentBashOutputs.size > MAX_BASH_OUTPUTS) { + const oldest = this.currentBashOutputs.keys().next().value; + if (!oldest) break; + this.currentBashOutputs.delete(oldest); + this.bashEntryExpanded.delete(oldest); + } + this.renderBashOutputs(); + } + + updateBashOutput(id: string, updates: Partial>): void { + const existing = this.currentBashOutputs.get(id); + if (!existing) return; + this.currentBashOutputs.set(id, { ...existing, ...updates }); + this.renderBashOutputs(); + } + + clearBashOutputs(): void { + this.currentBashOutputs.clear(); + this.bashEntryExpanded.clear(); + this.renderBashOutputs(); + } + + private renderBashOutputs(options: { scroll?: boolean } = {}): void { + if (!this.bashOutputContainerEl || !this.bashHeaderEl || !this.bashContentEl) return; + const scroll = options.scroll ?? true; + + if (this.currentBashOutputs.size === 0) { + this.bashOutputContainerEl.addClass('claudian-hidden'); + this.syncPanelVisibility(); + return; + } + + this.bashOutputContainerEl.removeClass('claudian-hidden'); + this.syncPanelVisibility(); + this.bashHeaderEl.empty(); + this.bashContentEl.empty(); + const ownerDocument = this.bashHeaderEl.ownerDocument ?? window.document; + + const headerIconEl = ownerDocument.createElement('span'); + headerIconEl.className = 'claudian-tool-icon'; + headerIconEl.setAttribute('aria-hidden', 'true'); + setIcon(headerIconEl, 'terminal'); + this.bashHeaderEl.appendChild(headerIconEl); + + const latest = Array.from(this.currentBashOutputs.values()).at(-1); + + const headerLabelEl = ownerDocument.createElement('span'); + headerLabelEl.className = 'claudian-tool-label'; + if (this.isBashExpanded) { + headerLabelEl.textContent = t('chat.bangBash.commandPanel'); + } else { + headerLabelEl.textContent = latest ? this.truncateDescription(latest.command, 60) : t('chat.bangBash.commandPanel'); + } + this.bashHeaderEl.appendChild(headerLabelEl); + + const previewEl = ownerDocument.createElement('span'); + previewEl.className = 'claudian-tool-current'; + previewEl.classList.toggle('claudian-hidden', !this.isBashExpanded); + this.bashHeaderEl.appendChild(previewEl); + + const summaryStatusEl = ownerDocument.createElement('span'); + summaryStatusEl.className = 'claudian-tool-status'; + if (!this.isBashExpanded && latest) { + summaryStatusEl.classList.add(`status-${latest.status}`); + summaryStatusEl.setAttribute('aria-label', t('chat.bangBash.statusLabel', { status: latest.status })); + if (latest.status === 'completed') setIcon(summaryStatusEl, 'check'); + if (latest.status === 'error') setIcon(summaryStatusEl, 'x'); + } else { + summaryStatusEl.classList.add('claudian-hidden'); + } + this.bashHeaderEl.appendChild(summaryStatusEl); + + this.bashHeaderEl.setAttribute('aria-expanded', String(this.isBashExpanded)); + + const actionsEl = ownerDocument.createElement('span'); + actionsEl.className = 'claudian-status-panel-bash-actions'; + this.appendActionButton(actionsEl, 'copy', t('chat.bangBash.copyAriaLabel'), 'copy', () => { + void this.copyLatestBashOutput(); + }); + this.appendActionButton(actionsEl, 'clear', t('chat.bangBash.clearAriaLabel'), 'trash', () => { + this.clearBashOutputs(); + }); + this.bashHeaderEl.appendChild(actionsEl); + + this.bashContentEl.toggleClass('claudian-hidden', !this.isBashExpanded); + + if (!this.isBashExpanded) { + return; + } + + for (const info of this.currentBashOutputs.values()) { + this.bashContentEl.appendChild(this.renderBashEntry(info, ownerDocument)); + } + + if (scroll) { + this.bashContentEl.scrollTop = this.bashContentEl.scrollHeight; + this.scrollToBottom(); + } + } + + private renderBashEntry(info: PanelBashOutput, ownerDocument: Document): HTMLElement { + const entryEl = ownerDocument.createElement('div'); + entryEl.className = 'claudian-tool-call claudian-status-panel-bash-entry'; + + const entryHeaderEl = ownerDocument.createElement('div'); + entryHeaderEl.className = 'claudian-tool-header'; + entryHeaderEl.setAttribute('tabindex', '0'); + entryHeaderEl.setAttribute('role', 'button'); + + const entryIconEl = ownerDocument.createElement('span'); + entryIconEl.className = 'claudian-tool-icon'; + entryIconEl.setAttribute('aria-hidden', 'true'); + setIcon(entryIconEl, 'dollar-sign'); + entryHeaderEl.appendChild(entryIconEl); + + const entryLabelEl = ownerDocument.createElement('span'); + entryLabelEl.className = 'claudian-tool-label'; + entryLabelEl.textContent = t('chat.bangBash.commandLabel', { command: this.truncateDescription(info.command, 60) }); + entryHeaderEl.appendChild(entryLabelEl); + + const entryStatusEl = ownerDocument.createElement('span'); + entryStatusEl.className = 'claudian-tool-status'; + entryStatusEl.classList.add(`status-${info.status}`); + entryStatusEl.setAttribute('aria-label', t('chat.bangBash.statusLabel', { status: info.status })); + if (info.status === 'completed') setIcon(entryStatusEl, 'check'); + if (info.status === 'error') setIcon(entryStatusEl, 'x'); + entryHeaderEl.appendChild(entryStatusEl); + + entryEl.appendChild(entryHeaderEl); + + const contentEl = ownerDocument.createElement('div'); + contentEl.className = 'claudian-tool-content'; + const isEntryExpanded = this.bashEntryExpanded.get(info.id) ?? true; + contentEl.classList.toggle('claudian-hidden', !isEntryExpanded); + entryHeaderEl.setAttribute('aria-expanded', String(isEntryExpanded)); + entryHeaderEl.setAttribute('aria-label', isEntryExpanded ? t('chat.bangBash.collapseOutput') : t('chat.bangBash.expandOutput')); + entryHeaderEl.addEventListener('click', () => { + this.bashEntryExpanded.set(info.id, !isEntryExpanded); + this.renderBashOutputs({ scroll: false }); + }); + entryHeaderEl.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.bashEntryExpanded.set(info.id, !isEntryExpanded); + this.renderBashOutputs({ scroll: false }); + } + }); + + const rowEl = ownerDocument.createElement('div'); + rowEl.className = 'claudian-tool-result-row'; + + const textEl = ownerDocument.createElement('span'); + textEl.className = 'claudian-tool-result-text'; + if (info.status === 'running' && !info.output) { + textEl.textContent = t('chat.bangBash.running'); + } else if (info.output) { + textEl.textContent = info.output; + } + + rowEl.appendChild(textEl); + contentEl.appendChild(rowEl); + + entryEl.appendChild(contentEl); + return entryEl; + } + + private async copyLatestBashOutput(): Promise { + const latest = Array.from(this.currentBashOutputs.values()).at(-1); + if (!latest) return; + + const output = latest.output?.trim() || (latest.status === 'running' ? t('chat.bangBash.running') : ''); + const text = output ? `$ ${latest.command}\n${output}` : `$ ${latest.command}`; + try { + await navigator.clipboard.writeText(text); + } catch { + new Notice(t('chat.bangBash.copyFailed')); + } + } + + private appendActionButton( + parent: HTMLElement, + name: string, + ariaLabel: string, + icon: string, + action: () => void + ): void { + const el = (parent.ownerDocument ?? window.document).createElement('span'); + el.className = `claudian-status-panel-bash-action claudian-status-panel-bash-action-${name}`; + el.setAttribute('role', 'button'); + el.setAttribute('tabindex', '0'); + el.setAttribute('aria-label', ariaLabel); + setIcon(el, icon); + el.addEventListener('click', (e) => { + e.stopPropagation(); + action(); + }); + el.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + action(); + } + }); + parent.appendChild(el); + } + + private toggleBashSection(): void { + this.isBashExpanded = !this.isBashExpanded; + this.renderBashOutputs({ scroll: false }); + } + + // ============================================ + // Cleanup + // ============================================ + + /** + * Destroy the panel. + */ + destroy(): void { + // Remove event listeners before removing elements + if (this.todoHeaderEl) { + if (this.todoClickHandler) { + this.todoHeaderEl.removeEventListener('click', this.todoClickHandler); + } + if (this.todoKeydownHandler) { + this.todoHeaderEl.removeEventListener('keydown', this.todoKeydownHandler); + } + } + this.todoClickHandler = null; + this.todoKeydownHandler = null; + + if (this.bashHeaderEl) { + if (this.bashClickHandler) { + this.bashHeaderEl.removeEventListener('click', this.bashClickHandler); + } + if (this.bashKeydownHandler) { + this.bashHeaderEl.removeEventListener('keydown', this.bashKeydownHandler); + } + } + this.bashClickHandler = null; + this.bashKeydownHandler = null; + + // Clear bash output tracking + this.currentBashOutputs.clear(); + + if (this.panelEl) { + this.panelEl.remove(); + this.panelEl = null; + } + this.bashOutputContainerEl = null; + this.bashHeaderEl = null; + this.bashContentEl = null; + this.todoContainerEl = null; + this.todoHeaderEl = null; + this.todoContentEl = null; + this.containerEl = null; + this.currentTodos = null; + } +} diff --git a/src/features/chat/ui/file-context/state/FileContextState.ts b/src/features/chat/ui/file-context/state/FileContextState.ts new file mode 100644 index 0000000..1936ed0 --- /dev/null +++ b/src/features/chat/ui/file-context/state/FileContextState.ts @@ -0,0 +1,83 @@ +export class FileContextState { + private attachedFiles: Set = new Set(); + private sessionStarted = false; + private mentionedMcpServers: Set = new Set(); + private currentNoteSent = false; + + getAttachedFiles(): Set { + return new Set(this.attachedFiles); + } + + hasSentCurrentNote(): boolean { + return this.currentNoteSent; + } + + markCurrentNoteSent(): void { + this.currentNoteSent = true; + } + + isSessionStarted(): boolean { + return this.sessionStarted; + } + + startSession(): void { + this.sessionStarted = true; + } + + resetForNewConversation(): void { + this.sessionStarted = false; + this.currentNoteSent = false; + this.attachedFiles.clear(); + this.clearMcpMentions(); + } + + resetForLoadedConversation(hasMessages: boolean): void { + this.currentNoteSent = hasMessages; + this.attachedFiles.clear(); + this.sessionStarted = hasMessages; + this.clearMcpMentions(); + } + + setAttachedFiles(files: string[]): void { + this.attachedFiles.clear(); + for (const file of files) { + this.attachedFiles.add(file); + } + } + + attachFile(path: string): void { + this.attachedFiles.add(path); + } + + detachFile(path: string): void { + this.attachedFiles.delete(path); + } + + clearAttachments(): void { + this.attachedFiles.clear(); + } + + getMentionedMcpServers(): Set { + return new Set(this.mentionedMcpServers); + } + + clearMcpMentions(): void { + this.mentionedMcpServers.clear(); + } + + setMentionedMcpServers(mentions: Set): boolean { + const changed = + mentions.size !== this.mentionedMcpServers.size || + [...mentions].some(name => !this.mentionedMcpServers.has(name)); + + if (changed) { + this.mentionedMcpServers = new Set(mentions); + } + + return changed; + } + + addMentionedMcpServer(name: string): void { + this.mentionedMcpServers.add(name); + } +} diff --git a/src/features/chat/ui/file-context/view/FileChipsView.ts b/src/features/chat/ui/file-context/view/FileChipsView.ts new file mode 100644 index 0000000..4c2151e --- /dev/null +++ b/src/features/chat/ui/file-context/view/FileChipsView.ts @@ -0,0 +1,40 @@ +import type { ComposerContextTray } from '../../ComposerContextTray'; + +export interface FileChipsViewCallbacks { + onRemoveAttachment: (path: string) => void; + onOpenFile: (path: string) => void; +} + +export class FileChipsView { + private contextTray: ComposerContextTray; + private callbacks: FileChipsViewCallbacks; + + constructor(contextTray: ComposerContextTray, callbacks: FileChipsViewCallbacks) { + this.contextTray = contextTray; + this.callbacks = callbacks; + } + + destroy(): void { + this.contextTray.clearItems('current-note'); + } + + renderCurrentNote(filePath: string | null): void { + if (!filePath) { + this.contextTray.clearItems('current-note'); + return; + } + + const normalizedPath = filePath.replace(/\\/g, '/'); + const filename = normalizedPath.split('/').pop() || filePath; + this.contextTray.setItems('current-note', [{ + id: filePath, + kind: 'note', + label: filename, + icon: 'file-text', + title: filePath, + ariaLabel: `Linked note: ${filePath}`, + onActivate: () => this.callbacks.onOpenFile(filePath), + onRemove: () => this.callbacks.onRemoveAttachment(filePath), + }]); + } +} diff --git a/src/features/chat/ui/textareaResize.ts b/src/features/chat/ui/textareaResize.ts new file mode 100644 index 0000000..d0f6c60 --- /dev/null +++ b/src/features/chat/ui/textareaResize.ts @@ -0,0 +1,47 @@ +export const TEXTAREA_BASE_MIN_HEIGHT = 60; +export const TEXTAREA_MIN_MAX_HEIGHT = 150; +export const TEXTAREA_MAX_HEIGHT_PERCENT = 0.55; + +interface TextareaMinHeightInput { + contentHeight: number; + flexAllocatedHeight: number; +} + +export function calculateTextareaMaxHeight(viewHeight: number): number { + return Math.max(TEXTAREA_MIN_MAX_HEIGHT, viewHeight * TEXTAREA_MAX_HEIGHT_PERCENT); +} + +export function calculateTextareaMinHeight({ + contentHeight, + flexAllocatedHeight, +}: TextareaMinHeightInput): number { + return contentHeight > flexAllocatedHeight ? contentHeight : TEXTAREA_BASE_MIN_HEIGHT; +} + +/** + * Auto-resizes a textarea based on its content. + * + * Logic: + * - At minimum wrapper height: let flexbox allocate space (textarea fills available) + * - When content exceeds flex allocation: set min-height to force wrapper growth + * - When content shrinks: remove min-height override to let wrapper shrink + * - Max height is capped at 55% of view height (minimum 150px) + */ +export function autoResizeTextarea(textarea: HTMLTextAreaElement): void { + const viewHeight = textarea.closest('.claudian-container')?.clientHeight ?? window.innerHeight; + const maxHeight = calculateTextareaMaxHeight(viewHeight); + + textarea.setCssProps({ + '--claudian-textarea-min-height': `${TEXTAREA_BASE_MIN_HEIGHT}px`, + '--claudian-textarea-max-height': `${maxHeight}px`, + }); + + const flexAllocatedHeight = textarea.offsetHeight; + const contentHeight = Math.min(textarea.scrollHeight, maxHeight); + const minHeight = calculateTextareaMinHeight({ contentHeight, flexAllocatedHeight }); + + textarea.setCssProps({ + '--claudian-textarea-min-height': `${minHeight}px`, + '--claudian-textarea-max-height': `${maxHeight}px`, + }); +} diff --git a/src/features/chat/utils/conversationDirectoryTitle.ts b/src/features/chat/utils/conversationDirectoryTitle.ts new file mode 100644 index 0000000..9847ae4 --- /dev/null +++ b/src/features/chat/utils/conversationDirectoryTitle.ts @@ -0,0 +1,11 @@ +const CONVERSATION_DIRECTORY_TITLE_MAX_LENGTH = 80; + +export function formatConversationDirectoryTitle(text: string): string { + const firstLine = text + .split(/\r?\n/) + .map(line => line.trim()) + .find(Boolean); + if (!firstLine) return ''; + if (firstLine.length <= CONVERSATION_DIRECTORY_TITLE_MAX_LENGTH) return firstLine; + return `${firstLine.slice(0, CONVERSATION_DIRECTORY_TITLE_MAX_LENGTH - 3)}...`; +} diff --git a/src/features/chat/utils/usageInfo.ts b/src/features/chat/utils/usageInfo.ts new file mode 100644 index 0000000..85296ba --- /dev/null +++ b/src/features/chat/utils/usageInfo.ts @@ -0,0 +1,26 @@ +import type { UsageInfo } from '../../../core/types'; + +export function calculateUsagePercentage(contextTokens: number, contextWindow: number): number { + return contextWindow > 0 + ? Math.min(100, Math.max(0, Math.round((contextTokens / contextWindow) * 100))) + : 0; +} + +export function recalculateUsageForModel( + usage: UsageInfo, + model: string, + fallbackContextWindow: number, +): UsageInfo { + const preserveAuthoritativeWindow = usage.contextWindowIsAuthoritative === true + && usage.contextWindow > 0 + && usage.model === model; + const contextWindow = preserveAuthoritativeWindow ? usage.contextWindow : fallbackContextWindow; + + return { + ...usage, + model, + contextWindow, + contextWindowIsAuthoritative: preserveAuthoritativeWindow, + percentage: calculateUsagePercentage(usage.contextTokens, contextWindow), + }; +} diff --git a/src/features/inline-edit/ui/InlineEditModal.ts b/src/features/inline-edit/ui/InlineEditModal.ts new file mode 100644 index 0000000..800c9ae --- /dev/null +++ b/src/features/inline-edit/ui/InlineEditModal.ts @@ -0,0 +1,1017 @@ +import { StateEffect, StateField, type Text } from '@codemirror/state'; +import type { DecorationSet } from '@codemirror/view'; +import { Decoration, EditorView, WidgetType } from '@codemirror/view'; +import type { App, Component, Editor, MarkdownView } from 'obsidian'; +import { Notice } from 'obsidian'; + +import { getHiddenProviderCommandSet } from '../../../core/providers/commands/hiddenCommands'; +import { resolveConversationModel } from '../../../core/providers/conversationModel'; +import { ProviderRegistry } from '../../../core/providers/ProviderRegistry'; +import { ProviderWorkspaceRegistry } from '../../../core/providers/ProviderWorkspaceRegistry'; +import { DEFAULT_CHAT_PROVIDER_ID, type InlineEditMode, type InlineEditService, type ProviderId } from '../../../core/providers/types'; +import { hideSelectionHighlight, showSelectionHighlight } from '../../../shared/components/SelectionHighlight'; +import { SlashCommandDropdown } from '../../../shared/components/SlashCommandDropdown'; +import { MentionDropdownController } from '../../../shared/mention/MentionDropdownController'; +import { VaultMentionDataProvider } from '../../../shared/mention/VaultMentionDataProvider'; +import { + createExternalContextLookupGetter, + findBestMentionLookupMatch, + isMentionStart, + normalizeForPlatformLookup, + normalizeMentionPath, + resolveExternalMentionAtIndex, +} from '../../../utils/contextMentionResolver'; +import { type CursorContext, getEditorView } from '../../../utils/editor'; +import { buildExternalContextDisplayEntries } from '../../../utils/externalContext'; +import { externalContextScanner } from '../../../utils/externalContextScanner'; +import { normalizeInsertionText } from '../../../utils/inlineEdit'; +import { getVaultPath, normalizePathForVault as normalizePathForVaultUtil } from '../../../utils/path'; +import type { FeatureHost } from '../../FeatureHost'; +import { renderInlineEditMarkdownPreview } from './inlineEditMarkdownPreview'; + +type InlineEditHost = FeatureHost & Component; + +export type InlineEditContext = + | { mode: 'selection'; selectedText: string } + | { mode: 'cursor'; cursorContext: CursorContext }; + +const showInlineEdit = StateEffect.define<{ + inputPos: number; + selFrom: number; + selTo: number; + widget: InlineEditSession; + isInbetween?: boolean; +}>(); +const showDiff = StateEffect.define<{ + from: number; + to: number; + diffOps: DiffOp[]; + previewPos: number; + widget: InlineEditSession; +}>(); +const showInsertion = StateEffect.define<{ + diffOps: DiffOp[]; + previewPos: number; + widget: InlineEditSession; +}>(); +const hideInlineEdit = StateEffect.define(); + +let activeController: InlineEditSession | null = null; + +class InputWidget extends WidgetType { + constructor(private controller: InlineEditSession) { + super(); + } + toDOM(): HTMLElement { + return this.controller.createInputDOM(); + } + eq(): boolean { + return false; + } + ignoreEvent(): boolean { + return true; + } +} + +class MarkdownDiffWidget extends WidgetType { + constructor(private diffOps: DiffOp[], private controller: InlineEditSession) { + super(); + } + toDOM(): HTMLElement { + return this.controller.createDiffPreviewDOM(this.diffOps); + } + eq(other: MarkdownDiffWidget): boolean { + return diffOpsEqual(this.diffOps, other.diffOps); + } + ignoreEvent(): boolean { + return true; + } +} + +export function buildInlineEditInputDecorations(options: { + doc: Text; + inputPos: number; + isInbetween?: boolean; + widget: WidgetType; +}): DecorationSet { + // Decoration.set(..., true) sorts line and widget decorations by CodeMirror's + // internal range ordering, including equal-position block widgets at line start. + const isInbetween = options.isInbetween ?? false; + const lineStart = options.doc.lineAt(options.inputPos).from; + return Decoration.set([ + Decoration.line({ + class: 'claudian-inline-input-line', + }).range(lineStart), + Decoration.widget({ + widget: options.widget, + block: !isInbetween, + side: isInbetween ? 1 : -1, + }).range(options.inputPos), + ], true); +} + +const inlineEditField = StateField.define({ + create: () => Decoration.none, + update: (deco, tr) => { + deco = deco.map(tr.changes); + for (const e of tr.effects) { + if (e.is(showInlineEdit)) { + // Block above line for selection/inline mode, inline widget for inbetween mode + deco = buildInlineEditInputDecorations({ + doc: tr.state.doc, + inputPos: e.value.inputPos, + isInbetween: e.value.isInbetween, + widget: new InputWidget(e.value.widget), + }); + } else if (e.is(showDiff)) { + deco = Decoration.set([ + Decoration.widget({ + widget: new MarkdownDiffWidget(e.value.diffOps, e.value.widget), + block: true, + side: -1, + }).range(e.value.previewPos), + Decoration.replace({}).range(e.value.from, e.value.to), + ], true); + } else if (e.is(showInsertion)) { + deco = Decoration.set([ + Decoration.widget({ + widget: new MarkdownDiffWidget(e.value.diffOps, e.value.widget), + block: true, + side: -1, + }).range(e.value.previewPos), + ], true); + } else if (e.is(hideInlineEdit)) { + deco = Decoration.none; + } + } + return deco; + }, + provide: (f) => EditorView.decorations.from(f), +}); + +const installedEditors = new WeakSet(); + +interface DiffOp { type: 'equal' | 'insert' | 'delete'; text: string; } + +function splitLinesPreservingEndings(text: string): string[] { + if (!text) return []; + return text.match(/[^\n]*(?:\n|$)/g)?.filter(line => line.length > 0) ?? []; +} + +function computeMarkdownDiff(oldText: string, newText: string): DiffOp[] { + const oldLines = splitLinesPreservingEndings(oldText); + const newLines = splitLinesPreservingEndings(newText); + const m = oldLines.length, n = newLines.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = oldLines[i-1] === newLines[j-1] + ? dp[i-1][j-1] + 1 + : Math.max(dp[i-1][j], dp[i][j-1]); + } + } + + const temp: DiffOp[] = []; + let i = m, j = n; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldLines[i-1] === newLines[j-1]) { + temp.push({ type: 'equal', text: oldLines[i-1] }); + i--; j--; + } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) { + temp.push({ type: 'insert', text: newLines[j-1] }); + j--; + } else { + temp.push({ type: 'delete', text: oldLines[i-1] }); + i--; + } + } + + return mergeAdjacentDiffOps(temp.reverse()); +} + +function mergeAdjacentDiffOps(ops: DiffOp[]): DiffOp[] { + const merged: DiffOp[] = []; + for (const op of ops) { + if (merged.length > 0 && merged[merged.length-1].type === op.type) { + merged[merged.length-1].text += op.text; + } else { + merged.push({ ...op }); + } + } + return merged; +} + +function getDiffBlockClass(type: DiffOp['type']): string { + switch (type) { + case 'delete': + return 'claudian-diff-del'; + case 'insert': + return 'claudian-diff-ins'; + default: + return 'claudian-diff-equal'; + } +} + +function buildMarkdownDiffDocuments(diffOps: DiffOp[]): Array<{ type: DiffOp['type']; markdown: string }> { + const oldMarkdown = diffOps + .filter(op => op.type !== 'insert') + .map(op => op.text) + .join(''); + const newMarkdown = diffOps + .filter(op => op.type !== 'delete') + .map(op => op.text) + .join(''); + const hasDeletion = diffOps.some(op => op.type === 'delete'); + const hasInsertion = diffOps.some(op => op.type === 'insert'); + + const documents: Array<{ type: DiffOp['type']; markdown: string }> = []; + + if (hasDeletion && oldMarkdown) { + documents.push({ type: 'delete', markdown: oldMarkdown }); + } + + if (hasInsertion && newMarkdown) { + documents.push({ type: 'insert', markdown: newMarkdown }); + } + + if (documents.length === 0 && newMarkdown) { + documents.push({ type: 'equal', markdown: newMarkdown }); + } + + return documents; +} + +function diffOpsEqual(left: DiffOp[], right: DiffOp[]): boolean { + if (left.length !== right.length) return false; + return left.every((op, index) => { + const other = right[index]; + return op.type === other.type && op.text === other.text; + }); +} + +export type InlineEditDecision = 'accept' | 'edit' | 'reject'; + +interface InlineEditSourceSnapshot { + doc: Text; + from: number; + text: string; + to: number; +} + +export class InlineEditModal { + private controller: InlineEditSession | null = null; + + constructor( + private app: App, + private plugin: InlineEditHost, + private editor: Editor, + private view: MarkdownView, + private editContext: InlineEditContext, + private notePath: string, + private getExternalContexts: () => string[] = () => [] + ) {} + + async openAndWait(): Promise<{ decision: InlineEditDecision; editedText?: string }> { + if (activeController) { + activeController.reject(); + return { decision: 'reject' }; + } + + // Use the editor/view provided by Obsidian's editorCallback. + // This avoids timing issues during leaf/view transitions (e.g., navigating via Search in the same tab). + let editor = this.editor; + let editorView = getEditorView(editor); + + // Fallback: in rare cases Obsidian may re-initialize the editor between callback and modal open. + if (!editorView) { + editor = this.view.editor; + editorView = getEditorView(editor); + } + + if (!editorView) { + new Notice('Inline edit unavailable: could not access the active editor. Try reopening the note.'); + return { decision: 'reject' }; + } + + return new Promise((resolve) => { + this.controller = new InlineEditSession( + this.app, + this.plugin, + editorView, + editor, + this.editContext, + this.notePath, + this.getExternalContexts, + resolve + ); + activeController = this.controller; + this.controller.show(); + }); + } +} + +export class InlineEditSession { + private inputEl: HTMLInputElement | null = null; + private spinnerEl: HTMLElement | null = null; + private agentReplyEl: HTMLElement | null = null; + private containerEl: HTMLElement | null = null; + private editedText: string | null = null; + private insertedText: string | null = null; + private selFrom = 0; + private selTo = 0; + private selectedText: string; + private startLine: number = 0; // 1-indexed + private mode: InlineEditMode; + private cursorContext: CursorContext | null = null; + private inlineEditService: InlineEditService; + private escHandler: ((e: KeyboardEvent) => void) | null = null; + private selectionListener: ((e: Event) => void) | null = null; + private isConversing = false; + private resolvedProviderId: ProviderId; + private slashCommandDropdown: SlashCommandDropdown | null = null; + private mentionDropdown: MentionDropdownController | null = null; + private mentionDataProvider: VaultMentionDataProvider; + private agentReplyRenderVersion = 0; + private sourceSnapshot: InlineEditSourceSnapshot | null = null; + private settled = false; + private generation = 0; + + constructor( + private app: App, + private plugin: InlineEditHost, + private editorView: EditorView, + private editor: Editor, + editContext: InlineEditContext, + private notePath: string, + private getExternalContexts: () => string[], + private resolve: (result: { decision: InlineEditDecision; editedText?: string }) => void + ) { + const activeView = typeof plugin.getView === 'function' + ? plugin.getView() + : null; + const activeTab = activeView?.getActiveTab(); + const conversation = activeTab?.conversationId + ? plugin.getConversationSync(activeTab.conversationId) + : null; + const providerId: ProviderId = conversation?.providerId as ProviderId + ?? activeTab?.service?.providerId + ?? activeTab?.providerId + ?? DEFAULT_CHAT_PROVIDER_ID; + this.inlineEditService = ProviderRegistry.createInlineEditService( + plugin.providerHost, + providerId, + ); + const auxiliaryModel = conversation + ? resolveConversationModel(plugin.settings, providerId, conversation).model + : activeTab?.service?.providerId === providerId + ? activeTab.service.getAuxiliaryModel?.() + : activeTab?.providerId === providerId + ? activeTab?.draftModel + : null; + this.inlineEditService.setModelOverride?.(auxiliaryModel ?? undefined); + this.resolvedProviderId = providerId; + this.mentionDataProvider = new VaultMentionDataProvider(this.app, { + onFileLoadError: () => { + new Notice('Failed to load vault files. Vault @-mentions may be unavailable.'); + }, + }); + this.mentionDataProvider.initializeInBackground(); + this.mode = editContext.mode; + if (editContext.mode === 'cursor') { + this.cursorContext = editContext.cursorContext; + this.selectedText = ''; + } else { + this.selectedText = editContext.selectedText; + } + + this.updatePositionsFromEditor(); + } + + getOwnerDocument(): Document { + return this.editorView.dom.ownerDocument ?? window.document; + } + + private updatePositionsFromEditor() { + const doc = this.editorView.state.doc; + + if (this.mode === 'cursor') { + const ctx = this.cursorContext as CursorContext; + const line = doc.line(ctx.line + 1); + this.selFrom = line.from + ctx.column; + this.selTo = this.selFrom; + } else { + const from = this.editor.getCursor('from'); + const to = this.editor.getCursor('to'); + const fromLine = doc.line(from.line + 1); + const toLine = doc.line(to.line + 1); + this.selFrom = fromLine.from + from.ch; + this.selTo = toLine.from + to.ch; + this.selectedText = this.editor.getSelection() || this.selectedText; + this.startLine = from.line + 1; // 1-indexed + } + } + + show() { + if (!installedEditors.has(this.editorView)) { + this.editorView.dispatch({ + effects: StateEffect.appendConfig.of(inlineEditField), + }); + installedEditors.add(this.editorView); + } + + this.updateHighlight(); + + if (this.mode === 'selection') { + this.attachSelectionListeners(); + } + + // !e.isComposing: skip during IME composition (Chinese, Japanese, Korean, etc.) + this.escHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !e.isComposing && this.isKeyboardEventInContext(e)) { + this.reject(); + } + }; + this.getOwnerDocument().addEventListener('keydown', this.escHandler); + } + + private updateHighlight() { + const doc = this.editorView.state.doc; + const line = doc.lineAt(this.selFrom); + const isInbetween = this.mode === 'cursor' && this.cursorContext?.isInbetween; + + this.editorView.dispatch({ + effects: showInlineEdit.of({ + inputPos: isInbetween ? this.selFrom : line.from, + selFrom: this.selFrom, + selTo: this.selTo, + widget: this, + isInbetween, + }), + }); + this.updateSelectionHighlight(); + } + + private updateSelectionHighlight(): void { + if (this.mode === 'selection' && this.selFrom !== this.selTo) { + showSelectionHighlight(this.editorView, this.selFrom, this.selTo); + } else { + hideSelectionHighlight(this.editorView); + } + } + + private attachSelectionListeners() { + this.removeSelectionListeners(); + this.selectionListener = (e: Event) => { + const target = e.target as Node | null; + if (target && this.inputEl && (target === this.inputEl || this.inputEl.contains(target))) { + return; + } + const prevFrom = this.selFrom; + const prevTo = this.selTo; + const newSelection = this.editor.getSelection(); + if (newSelection && newSelection.length > 0) { + this.updatePositionsFromEditor(); + if (prevFrom !== this.selFrom || prevTo !== this.selTo) { + this.updateHighlight(); + } + } + }; + this.editorView.dom.addEventListener('mouseup', this.selectionListener); + this.editorView.dom.addEventListener('keyup', this.selectionListener); + } + + createInputDOM(): HTMLElement { + const ownerDocument = this.getOwnerDocument(); + const container = ownerDocument.createElement('div'); + container.className = 'claudian-inline-input-container'; + this.containerEl = container; + + this.agentReplyEl = ownerDocument.createElement('div'); + this.agentReplyEl.className = 'claudian-inline-agent-reply claudian-hidden'; + container.appendChild(this.agentReplyEl); + + const inputWrap = ownerDocument.createElement('div'); + inputWrap.className = 'claudian-inline-input-wrap'; + container.appendChild(inputWrap); + + this.inputEl = ownerDocument.createElement('input'); + this.inputEl.type = 'text'; + this.inputEl.className = 'claudian-inline-input'; + this.inputEl.placeholder = this.mode === 'cursor' ? 'Insert instructions...' : 'Edit instructions...'; + this.inputEl.spellcheck = false; + inputWrap.appendChild(this.inputEl); + + this.spinnerEl = ownerDocument.createElement('div'); + this.spinnerEl.className = 'claudian-inline-spinner claudian-hidden'; + inputWrap.appendChild(this.spinnerEl); + + const inlineCatalog = ProviderWorkspaceRegistry.getCommandCatalog(this.resolvedProviderId); + this.slashCommandDropdown = new SlashCommandDropdown( + ownerDocument.body, + this.inputEl, + { + onSelect: () => {}, + onHide: () => {}, + }, + { + fixed: true, + hiddenCommands: getHiddenProviderCommandSet(this.plugin.settings, this.resolvedProviderId), + ...(inlineCatalog ? { + providerConfig: inlineCatalog.getDropdownConfig(), + getProviderEntries: () => inlineCatalog.listDropdownEntries({ includeBuiltIns: false }), + } : {}), + } + ); + + this.mentionDropdown = new MentionDropdownController( + ownerDocument.body, + this.inputEl, + { + // Inline-edit resolves @mentions at send time from input text. + onAttachFile: () => {}, + onMcpMentionChange: () => {}, + getMentionedMcpServers: () => new Set(), + setMentionedMcpServers: () => false, + addMentionedMcpServer: () => {}, + getExternalContexts: this.getExternalContexts, + getCachedVaultFolders: () => this.mentionDataProvider.getCachedVaultFolders(), + getCachedVaultFiles: () => this.mentionDataProvider.getCachedVaultFiles(), + normalizePathForVault: (rawPath) => this.normalizePathForVault(rawPath), + }, + { fixed: true } + ); + + this.inputEl.addEventListener('keydown', (e) => this.handleKeydown(e)); + this.inputEl.addEventListener('input', () => this.mentionDropdown?.handleInputChange()); + + window.setTimeout(() => this.inputEl?.focus(), 50); + return container; + } + + createDiffPreviewDOM(diffOps: DiffOp[]): HTMLElement { + const ownerDocument = this.getOwnerDocument(); + const previewEl = ownerDocument.createElement('div'); + previewEl.className = 'claudian-inline-diff-preview'; + + const bodyEl = ownerDocument.createElement('div'); + bodyEl.className = 'claudian-inline-diff-preview-body markdown-rendered'; + previewEl.appendChild(bodyEl); + + const actionsEl = ownerDocument.createElement('div'); + actionsEl.className = 'claudian-inline-preview-actions'; + actionsEl.setAttribute('role', 'toolbar'); + actionsEl.setAttribute('aria-label', 'Inline edit actions'); + actionsEl.appendChild(this.createPreviewActionButton('Reject', 'reject', () => this.reject())); + actionsEl.appendChild(this.createPreviewActionButton('Accept', 'accept', () => this.accept())); + previewEl.appendChild(actionsEl); + + void this.renderMarkdownDiffPreview(bodyEl, diffOps); + return previewEl; + } + + private createPreviewActionButton( + label: string, + variant: 'accept' | 'reject', + onClick: () => void + ): HTMLButtonElement { + const ownerDocument = this.getOwnerDocument(); + const button = ownerDocument.createElement('button'); + button.type = 'button'; + button.className = `claudian-inline-preview-action ${variant}`; + button.textContent = label; + button.setAttribute('aria-label', `${label} inline edit`); + button.title = variant === 'accept' ? 'Accept (enter)' : 'Reject (esc)'; + button.addEventListener('click', (event) => { + event.preventDefault?.(); + event.stopPropagation?.(); + onClick(); + }); + return button; + } + + private async renderMarkdownPreview(container: HTMLElement, markdown: string): Promise { + await renderInlineEditMarkdownPreview({ + app: this.app, + component: this.plugin, + container, + markdown, + sourcePath: this.notePath, + mediaFolder: this.plugin.settings?.mediaFolder ?? '', + }); + } + + private async renderMarkdownDiffPreview(container: HTMLElement, diffOps: DiffOp[]): Promise { + container.empty(); + for (const document of buildMarkdownDiffDocuments(diffOps)) { + if (!document.markdown) continue; + + const opEl = this.getOwnerDocument().createElement('div'); + opEl.className = `claudian-diff-block ${getDiffBlockClass(document.type)}`; + container.appendChild(opEl); + await this.renderMarkdownPreview(opEl, document.markdown); + } + } + + private replaceRenderedPreview(target: HTMLElement, rendered: HTMLElement): void { + target.empty(); + + if (rendered.childNodes) { + for (const child of Array.from(rendered.childNodes)) { + target.appendChild(child); + } + return; + } + + for (const child of Array.from(rendered.children)) { + target.appendChild(child); + } + } + + private async generate(): Promise { + if (this.settled || !this.inputEl || !this.spinnerEl) return; + const userMessage = this.inputEl.value.trim(); + if (!userMessage) return; + const generation = ++this.generation; + + const sourceDoc = this.editorView.state.doc; + this.sourceSnapshot = { + doc: sourceDoc, + from: this.selFrom, + text: this.getDocumentSlice(sourceDoc, this.selFrom, this.selTo), + to: this.selTo, + }; + + // Slash commands are passed directly to SDK for handling + + this.removeSelectionListeners(); + + this.inputEl.disabled = true; + this.spinnerEl.removeClass('claudian-hidden'); + + const contextFiles = this.resolveContextFilesFromMessage(userMessage); + + let result; + try { + if (this.isConversing) { + result = await this.inlineEditService.continueConversation(userMessage, contextFiles); + } else { + if (this.mode === 'cursor') { + result = await this.inlineEditService.editText({ + mode: 'cursor', + instruction: userMessage, + notePath: this.notePath, + cursorContext: this.cursorContext as CursorContext, + contextFiles, + }); + } else { + const lineCount = this.selectedText.split(/\r?\n/).length; + result = await this.inlineEditService.editText({ + mode: 'selection', + instruction: userMessage, + notePath: this.notePath, + selectedText: this.selectedText, + startLine: this.startLine, + lineCount, + contextFiles, + }); + } + } + } catch (error) { + if (this.isGenerationActive(generation)) { + this.handleError(error instanceof Error ? error.message : 'Error - try again'); + } + return; + } finally { + if (this.isGenerationActive(generation)) { + this.spinnerEl?.addClass('claudian-hidden'); + } + } + + if (!this.isGenerationActive(generation)) { + return; + } + if (!this.isSourceUnchanged()) { + this.rejectStaleSource(); + return; + } + + if (result.success) { + if (result.editedText !== undefined) { + this.editedText = result.editedText; + this.showDiffInPlace(); + } else if (result.insertedText !== undefined) { + this.insertedText = result.insertedText; + this.showInsertionInPlace(); + } else if (result.clarification) { + this.showAgentReply(result.clarification); + this.isConversing = true; + this.inputEl.disabled = false; + this.inputEl.value = ''; + this.inputEl.placeholder = 'Reply to continue...'; + this.inputEl.focus(); + } else { + this.handleError('No response from agent'); + } + } else { + this.handleError(result.error || 'Error - try again'); + } + } + + private showAgentReply(message: string) { + if (!this.agentReplyEl || !this.containerEl) return; + const replyEl = this.agentReplyEl; + const renderVersion = ++this.agentReplyRenderVersion; + const renderedEl = this.getOwnerDocument().createElement('div'); + + replyEl.removeClass('claudian-hidden'); + replyEl.empty(); + void this.renderMarkdownPreview(renderedEl, message).then(() => { + if (renderVersion !== this.agentReplyRenderVersion || replyEl !== this.agentReplyEl) { + return; + } + this.replaceRenderedPreview(replyEl, renderedEl); + }); + this.containerEl.classList.add('has-agent-reply'); + } + + private handleError(errorMessage: string) { + if (!this.inputEl) return; + this.inputEl.disabled = false; + this.inputEl.placeholder = errorMessage; + this.updatePositionsFromEditor(); + this.updateHighlight(); + this.attachSelectionListeners(); + this.inputEl.focus(); + } + + private showDiffInPlace() { + if (this.editedText === null) return; + + hideSelectionHighlight(this.editorView); + + const diffOps = computeMarkdownDiff(this.selectedText, this.editedText); + const previewPos = this.editorView.state.doc.lineAt(this.selFrom).from; + + this.editorView.dispatch({ + effects: showDiff.of({ + from: this.selFrom, + to: this.selTo, + diffOps, + previewPos, + widget: this, + }), + }); + + this.installAcceptRejectHandler(); + } + + private showInsertionInPlace() { + if (this.insertedText === null) return; + + hideSelectionHighlight(this.editorView); + + const trimmedText = normalizeInsertionText(this.insertedText); + this.insertedText = trimmedText; + + const diffOps: DiffOp[] = [{ type: 'insert', text: trimmedText }]; + const previewPos = this.editorView.state.doc.lineAt(this.selFrom).from; + + this.editorView.dispatch({ + effects: showInsertion.of({ + diffOps, + previewPos, + widget: this, + }), + }); + + this.installAcceptRejectHandler(); + } + + private installAcceptRejectHandler() { + if (this.escHandler) { + this.getOwnerDocument().removeEventListener('keydown', this.escHandler); + } + this.escHandler = (e: KeyboardEvent) => { + if (!this.isKeyboardEventInContext(e)) { + return; + } + if (e.key === 'Escape' && !e.isComposing) { + this.reject(); + } else if (e.key === 'Enter' && !e.isComposing) { + this.accept(); + } + }; + this.getOwnerDocument().addEventListener('keydown', this.escHandler); + } + + accept() { + if (this.settled) { + return; + } + const textToInsert = this.editedText ?? this.insertedText; + if (textToInsert !== null) { + if (!this.isSourceUnchanged()) { + this.rejectStaleSource(); + return; + } + // Convert CM6 positions back to Obsidian Editor positions + const doc = this.editorView.state.doc; + const fromLine = doc.lineAt(this.selFrom); + const toLine = doc.lineAt(this.selTo); + const from = { line: fromLine.number - 1, ch: this.selFrom - fromLine.from }; + const to = { line: toLine.number - 1, ch: this.selTo - toLine.from }; + + this.settled = true; + this.cleanup(); + this.editor.replaceRange(textToInsert, from, to); + this.focusEditor(); + this.resolve({ decision: 'accept', editedText: textToInsert }); + } else { + this.settled = true; + this.cleanup(); + this.resolve({ decision: 'reject' }); + } + } + + reject() { + if (this.settled) { + return; + } + this.settled = true; + this.cleanup({ keepSelectionHighlight: true }); + this.restoreSelectionHighlight(); + this.focusEditor(); + this.resolve({ decision: 'reject' }); + } + + private removeSelectionListeners() { + if (this.selectionListener) { + this.editorView.dom.removeEventListener('mouseup', this.selectionListener); + this.editorView.dom.removeEventListener('keyup', this.selectionListener); + this.selectionListener = null; + } + } + + private cleanup(options?: { keepSelectionHighlight?: boolean }) { + this.generation += 1; + this.inlineEditService.cancel(); + this.inlineEditService.resetConversation(); + this.isConversing = false; + this.removeSelectionListeners(); + if (this.escHandler) { + this.getOwnerDocument().removeEventListener('keydown', this.escHandler); + } + this.slashCommandDropdown?.destroy(); + this.slashCommandDropdown = null; + + this.mentionDropdown?.destroy(); + this.mentionDropdown = null; + + if (activeController === this) { + activeController = null; + } + this.editorView.dispatch({ + effects: hideInlineEdit.of(null), + }); + if (!options?.keepSelectionHighlight) { + hideSelectionHighlight(this.editorView); + } + } + + private restoreSelectionHighlight(): void { + if (this.mode !== 'selection' || this.selFrom === this.selTo) { + return; + } + showSelectionHighlight(this.editorView, this.selFrom, this.selTo); + } + + private isSourceUnchanged(): boolean { + const snapshot = this.sourceSnapshot; + if (!snapshot) { + return false; + } + + const currentDoc = this.editorView.state.doc; + const currentLength = typeof currentDoc.length === 'number' + ? currentDoc.length + : Number.POSITIVE_INFINITY; + return currentDoc === snapshot.doc + && snapshot.from >= 0 + && snapshot.to >= snapshot.from + && snapshot.to <= currentLength + && this.getDocumentSlice(currentDoc, snapshot.from, snapshot.to) === snapshot.text; + } + + private isGenerationActive(generation: number): boolean { + return !this.settled && generation === this.generation; + } + + private rejectStaleSource(): void { + if (this.settled) { + return; + } + new Notice('Inline edit was not applied because the source document or selection changed.'); + this.settled = true; + this.cleanup(); + this.focusEditor(); + this.resolve({ decision: 'reject' }); + } + + private getDocumentSlice(doc: Text, from: number, to: number): string { + const sliceString = (doc as Text & { sliceString?: (start: number, end: number) => string }).sliceString; + if (typeof sliceString === 'function') { + return sliceString.call(doc, from, to); + } + return from === this.selFrom && to === this.selTo ? this.selectedText : ''; + } + + private isKeyboardEventInContext(event: KeyboardEvent): boolean { + const target = event.target as Node | null; + if (!target) { + return false; + } + return target === this.containerEl + || this.containerEl?.contains(target) === true + || target === this.editorView.dom + || this.editorView.dom.contains(target); + } + + private focusEditor(): void { + const focus = (this.editorView as EditorView & { focus?: () => void }).focus; + focus?.call(this.editorView); + } + + private handleKeydown(e: KeyboardEvent) { + if (this.mentionDropdown?.handleKeydown(e)) { + return; + } + + if (this.slashCommandDropdown?.handleKeydown(e)) { + return; + } + + if (e.key === 'Enter' && !e.isComposing) { + e.preventDefault(); + void this.generate(); + } + } + + private normalizePathForVault(rawPath: string | undefined | null): string | null { + try { + const vaultPath = getVaultPath(this.app); + return normalizePathForVaultUtil(rawPath, vaultPath); + } catch { + new Notice('Failed to attach file: invalid path'); + return null; + } + } + + private resolveContextFilesFromMessage(message: string): string[] { + if (!message.includes('@')) return []; + + const vaultFiles = this.mentionDataProvider.getCachedVaultFiles(); + + const pathLookup = new Map(); + for (const file of vaultFiles) { + const normalized = this.normalizePathForVault(file.path); + if (!normalized) continue; + const lookupKey = normalizeForPlatformLookup(normalizeMentionPath(normalized)); + if (!pathLookup.has(lookupKey)) { + pathLookup.set(lookupKey, normalized); + } + } + + const resolved = new Set(); + const externalEntries = buildExternalContextDisplayEntries(this.getExternalContexts()) + .sort((a, b) => b.displayNameLower.length - a.displayNameLower.length); + const getExternalLookup = createExternalContextLookupGetter( + contextRoot => externalContextScanner.scanPaths([contextRoot]) + ); + + for (let index = 0; index < message.length; index++) { + if (!isMentionStart(message, index)) continue; + + const externalMatch = resolveExternalMentionAtIndex( + message, index, externalEntries, getExternalLookup + ); + if (externalMatch) { + resolved.add(externalMatch.resolvedPath); + index = externalMatch.endIndex - 1; + continue; + } + + const vaultMatch = findBestMentionLookupMatch( + message, index + 1, pathLookup, normalizeMentionPath, normalizeForPlatformLookup + ); + if (vaultMatch) { + resolved.add(vaultMatch.resolvedPath); + index = vaultMatch.endIndex - 1; + } + } + + return [...resolved]; + } + +} diff --git a/src/features/inline-edit/ui/inlineEditMarkdownPreview.ts b/src/features/inline-edit/ui/inlineEditMarkdownPreview.ts new file mode 100644 index 0000000..64ffed7 --- /dev/null +++ b/src/features/inline-edit/ui/inlineEditMarkdownPreview.ts @@ -0,0 +1,55 @@ +import type { App, Component } from 'obsidian'; +import { MarkdownRenderer } from 'obsidian'; + +import { processFileLinks } from '../../../utils/fileLink'; +import { replaceImageEmbedsWithHtml } from '../../../utils/imageEmbed'; + +interface RenderInlineEditMarkdownPreviewOptions { + app: App; + component: Component; + container: HTMLElement; + markdown: string; + sourcePath: string; + mediaFolder?: string; +} + +function emptyElement(container: HTMLElement): void { + if (typeof container.empty === 'function') { + container.empty(); + return; + } + container.replaceChildren(); +} + +function appendFallback(container: HTMLElement, markdown: string): void { + const fallback = container.ownerDocument.createElement('div'); + fallback.className = 'claudian-inline-markdown-fallback'; + fallback.textContent = markdown; + container.appendChild(fallback); +} + +export async function renderInlineEditMarkdownPreview({ + app, + component, + container, + markdown, + sourcePath, + mediaFolder = '', +}: RenderInlineEditMarkdownPreviewOptions): Promise { + emptyElement(container); + + try { + const processedMarkdown = replaceImageEmbedsWithHtml(markdown, app, { + mediaFolder, + sourcePath, + }); + await MarkdownRenderer.render(app, processedMarkdown, container, sourcePath, component); + + if (processedMarkdown.includes('[[') && app.metadataCache) { + processFileLinks(app, container); + } + } catch { + emptyElement(container); + appendFallback(container, markdown); + } +} diff --git a/src/features/settings/ClaudianSettings.ts b/src/features/settings/ClaudianSettings.ts new file mode 100644 index 0000000..2f58f44 --- /dev/null +++ b/src/features/settings/ClaudianSettings.ts @@ -0,0 +1,684 @@ +import type { App, Plugin } from 'obsidian'; +import { Notice, Platform, PluginSettingTab, Setting } from 'obsidian'; + +import { + getHiddenProviderCommands, + normalizeHiddenCommandList, +} from '../../core/providers/commands/hiddenCommands'; +import { ProviderRegistry } from '../../core/providers/ProviderRegistry'; +import { ProviderWorkspaceRegistry } from '../../core/providers/ProviderWorkspaceRegistry'; +import type { ProviderId } from '../../core/providers/types'; +import type { ChatViewPlacement } from '../../core/types/settings'; +import { getAvailableLocales, getLocaleDisplayName, setLocale, t } from '../../i18n/i18n'; +import type { Locale, TranslationKey } from '../../i18n/types'; +import { renderEnvironmentSettingsSection } from '../../shared/settings/EnvironmentSettingsSection'; +import { formatContextLimit, parseContextLimit, parseEnvironmentVariables } from '../../utils/env'; +import type { FeatureHost } from '../FeatureHost'; +import { buildNavMappingText, parseNavMappings } from './keyboardNavigation'; + +type SettingsTabId = string; +type ObsidianHotkey = { modifiers: string[]; key: string }; +type ObsidianHotkeyManager = { + customKeys?: Record; + defaultKeys?: Record; +}; +type ObsidianHotkeyTab = { + searchInputEl?: HTMLInputElement; + searchComponent?: { inputEl?: HTMLInputElement }; + updateHotkeyVisibility?: () => void; +}; +type ObsidianSettingsController = { + activeTab?: ObsidianHotkeyTab; + open: () => void; + openTabById: (id: string) => void; +}; +type AppWithHotkeyInternals = App & { + hotkeyManager?: ObsidianHotkeyManager; + setting?: ObsidianSettingsController; +}; + +function formatHotkey(hotkey: ObsidianHotkey): string { + const isMac = Platform.isMacOS; + const modMap: Record = isMac + ? { Mod: '⌘', Ctrl: '⌃', Alt: '⌥', Shift: '⇧', Meta: '⌘' } + : { Mod: 'Ctrl', Ctrl: 'Ctrl', Alt: 'Alt', Shift: 'Shift', Meta: 'Win' }; + + const mods = hotkey.modifiers.map((modifier) => modMap[modifier] || modifier); + const key = hotkey.key.length === 1 ? hotkey.key.toUpperCase() : hotkey.key; + + return isMac ? [...mods, key].join('') : [...mods, key].join('+'); +} + +function openHotkeySettings(app: App): void { + const setting = (app as AppWithHotkeyInternals).setting; + if (!setting) { + return; + } + + setting.open(); + setting.openTabById('hotkeys'); + window.setTimeout(() => { + const tab = setting.activeTab; + if (!tab) { + return; + } + + const searchEl = tab.searchInputEl ?? tab.searchComponent?.inputEl; + if (!searchEl) { + return; + } + + searchEl.value = 'Claudian'; + tab.updateHotkeyVisibility?.(); + }, 100); +} + +function getHotkeyForCommand(app: App, commandId: string): string | null { + const hotkeyManager = (app as AppWithHotkeyInternals).hotkeyManager; + if (!hotkeyManager) return null; + + const customHotkeys = hotkeyManager.customKeys?.[commandId]; + const defaultHotkeys = hotkeyManager.defaultKeys?.[commandId]; + const hotkeys = customHotkeys && customHotkeys.length > 0 ? customHotkeys : defaultHotkeys; + + if (!hotkeys || hotkeys.length === 0) return null; + + return hotkeys.map(formatHotkey).join(', '); +} + +function addHotkeySettingRow( + containerEl: HTMLElement, + app: App, + commandId: string, + translationPrefix: string, +): void { + const hotkey = getHotkeyForCommand(app, commandId); + const item = containerEl.createDiv({ cls: 'claudian-hotkey-item' }); + item.createSpan({ + cls: 'claudian-hotkey-name', + text: t(`${translationPrefix}.name` as TranslationKey), + }); + if (hotkey) { + item.createSpan({ cls: 'claudian-hotkey-badge', text: hotkey }); + } + item.addEventListener('click', () => openHotkeySettings(app)); +} + +export class ClaudianSettingTab extends PluginSettingTab { + plugin: FeatureHost; + private activeTab: SettingsTabId = 'general'; + + constructor(app: App, plugin: FeatureHost & Plugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + containerEl.addClass('claudian-settings'); + + setLocale(this.plugin.settings.locale as Locale); + + const providerTabs = ProviderRegistry.getRegisteredProviderIds(); + const tabIds: SettingsTabId[] = ['general', ...providerTabs]; + if (!tabIds.includes(this.activeTab)) { + this.activeTab = 'general'; + } + + const tabBar = containerEl.createDiv({ cls: 'claudian-settings-tabs' }); + const tabButtons = new Map(); + const tabContents = new Map(); + + for (const id of tabIds) { + const label = id === 'general' + ? t('settings.tabs.general') + : ProviderRegistry.getProviderDisplayName(id); + const button = tabBar.createEl('button', { + cls: `claudian-settings-tab${id === this.activeTab ? ' claudian-settings-tab--active' : ''}`, + text: label, + }); + button.addEventListener('click', () => { + this.activeTab = id; + for (const tabId of tabIds) { + tabButtons.get(tabId)?.toggleClass('claudian-settings-tab--active', tabId === id); + tabContents.get(tabId)?.toggleClass('claudian-settings-tab-content--active', tabId === id); + } + }); + tabButtons.set(id, button); + } + + for (const id of tabIds) { + const content = containerEl.createDiv({ + cls: `claudian-settings-tab-content${id === this.activeTab ? ' claudian-settings-tab-content--active' : ''}`, + }); + tabContents.set(id, content); + } + + this.renderGeneralTab(tabContents.get('general')!); + + for (const providerId of providerTabs) { + const content = tabContents.get(providerId); + if (!content) { + continue; + } + + ProviderWorkspaceRegistry.getSettingsTabRenderer(providerId)?.render(content, { + plugin: this.plugin.providerHost, + renderHiddenProviderCommandSetting: ( + target, + targetProviderId, + copy, + ) => this.renderHiddenProviderCommandSetting(target, targetProviderId, copy), + refreshModelSelectors: () => { + for (const view of this.plugin.getAllViews()) { + view.refreshModelSelector(); + } + }, + renderCustomContextLimits: (target, providerId) => this.renderCustomContextLimits(target, providerId), + }); + } + } + + private renderGeneralTab(container: HTMLElement): void { + new Setting(container) + .setName(t('settings.language.name')) + .setDesc(t('settings.language.desc')) + .addDropdown((dropdown) => { + const locales = getAvailableLocales(); + for (const locale of locales) { + dropdown.addOption(locale, getLocaleDisplayName(locale)); + } + dropdown + .setValue(this.plugin.settings.locale) + .onChange(async (value) => { + const locale = value as Locale; + if (!setLocale(locale)) { + dropdown.setValue(this.plugin.settings.locale); + return; + } + await this.plugin.mutateSettings((settings) => { + settings.locale = locale; + }); + this.display(); + }); + }); + + // --- Display --- + + new Setting(container).setName(t('settings.display')).setHeading(); + + const maxTabsSetting = new Setting(container) + .setName(t('settings.maxTabs.name')) + .setDesc(t('settings.maxTabs.desc')); + + const maxTabsWarningEl = container.createDiv({ + cls: 'claudian-max-tabs-warning claudian-setting-validation claudian-setting-validation-warning claudian-hidden', + }); + maxTabsWarningEl.setText(t('settings.maxTabs.warning')); + + const updateMaxTabsWarning = (value: number): void => { + maxTabsWarningEl.toggleClass('claudian-hidden', value <= 5); + }; + + maxTabsSetting.addSlider((slider) => { + slider + .setLimits(3, 10, 1) + .setValue(this.plugin.settings.maxTabs ?? 3) + .setDynamicTooltip() + .onChange(async (value) => { + await this.plugin.mutateSettings((settings) => { + settings.maxTabs = value; + }); + updateMaxTabsWarning(value); + for (const view of this.plugin.getAllViews()) { + view.refreshTabControls(); + } + }); + updateMaxTabsWarning(this.plugin.settings.maxTabs ?? 3); + }); + + new Setting(container) + .setName(t('settings.chatViewPlacement.name')) + .setDesc(t('settings.chatViewPlacement.desc')) + .addDropdown((dropdown) => { + dropdown + .addOption('right-sidebar', t('settings.chatViewPlacement.rightSidebar')) + .addOption('left-sidebar', t('settings.chatViewPlacement.leftSidebar')) + .addOption('main-tab', t('settings.chatViewPlacement.mainTab')) + .setValue(this.plugin.settings.chatViewPlacement) + .onChange(async (value) => { + await this.plugin.mutateSettings((settings) => { + settings.chatViewPlacement = value as ChatViewPlacement; + }); + }); + }); + + new Setting(container) + .setName(t('settings.enableAutoScroll.name')) + .setDesc(t('settings.enableAutoScroll.desc')) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.enableAutoScroll ?? true) + .onChange(async (value) => { + await this.plugin.mutateSettings((settings) => { + settings.enableAutoScroll = value; + }); + }) + ); + + new Setting(container) + .setName(t('settings.deferMathRenderingDuringStreaming.name')) + .setDesc(t('settings.deferMathRenderingDuringStreaming.desc')) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.deferMathRenderingDuringStreaming ?? true) + .onChange(async (value) => { + await this.plugin.mutateSettings((settings) => { + settings.deferMathRenderingDuringStreaming = value; + }); + }) + ); + + new Setting(container) + .setName(t('settings.expandFileEditsByDefault.name')) + .setDesc(t('settings.expandFileEditsByDefault.desc')) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.expandFileEditsByDefault ?? false) + .onChange(async (value) => { + await this.plugin.mutateSettings((settings) => { + settings.expandFileEditsByDefault = value; + }); + }) + ); + + // --- Conversations --- + + new Setting(container).setName(t('settings.conversations')).setHeading(); + + new Setting(container) + .setName(t('settings.autoTitle.name')) + .setDesc(t('settings.autoTitle.desc')) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.enableAutoTitleGeneration) + .onChange(async (value) => { + await this.plugin.mutateSettings((settings) => { + settings.enableAutoTitleGeneration = value; + }); + this.display(); + }) + ); + + if (this.plugin.settings.enableAutoTitleGeneration) { + new Setting(container) + .setName(t('settings.titleModel.name')) + .setDesc(t('settings.titleModel.desc')) + .addDropdown((dropdown) => { + dropdown.addOption('', t('settings.titleModel.auto')); + + const settingsBag = this.plugin.settings as unknown as Record; + const seenValues = new Set(); + for (const providerId of ProviderRegistry.getRegisteredProviderIds()) { + const uiConfig = ProviderRegistry.getChatUIConfig(providerId); + for (const model of uiConfig.getModelOptions(settingsBag)) { + if (!seenValues.has(model.value)) { + seenValues.add(model.value); + dropdown.addOption(model.value, model.label); + } + } + } + + dropdown + .setValue(this.plugin.settings.titleGenerationModel || '') + .onChange(async (value) => { + await this.plugin.mutateSettings((settings) => { + settings.titleGenerationModel = value; + }); + }); + }); + } + + // --- Content --- + + new Setting(container).setName(t('settings.content')).setHeading(); + + new Setting(container) + .setName(t('settings.userName.name')) + .setDesc(t('settings.userName.desc')) + .addText((text) => { + text + .setPlaceholder(t('settings.userName.name')) + .setValue(this.plugin.settings.userName) + .onChange(async (value) => { + await this.plugin.mutateSettings((settings) => { + settings.userName = value; + }); + }); + text.inputEl.addEventListener('blur', () => { + void this.restartServiceForPromptChange(); + }); + }); + + new Setting(container) + .setName(t('settings.systemPrompt.name')) + .setDesc(t('settings.systemPrompt.desc')) + .addTextArea((text) => { + text + .setPlaceholder(t('settings.systemPrompt.name')) + .setValue(this.plugin.settings.systemPrompt) + .onChange(async (value) => { + await this.plugin.mutateSettings((settings) => { + settings.systemPrompt = value; + }); + }); + text.inputEl.rows = 6; + text.inputEl.cols = 50; + text.inputEl.addEventListener('blur', () => { + void this.restartServiceForPromptChange(); + }); + }); + + new Setting(container) + .setName(t('settings.excludedTags.name')) + .setDesc(t('settings.excludedTags.desc')) + .addTextArea((text) => { + text + .setPlaceholder('System\nprivate\ndraft') + .setValue(this.plugin.settings.excludedTags.join('\n')) + .onChange(async (value) => { + await this.plugin.mutateSettings((settings) => { + settings.excludedTags = value + .split(/\r?\n/) + .map((entry) => entry.trim().replace(/^#/, '')) + .filter((entry) => entry.length > 0); + }); + }); + text.inputEl.rows = 4; + text.inputEl.cols = 30; + }); + + new Setting(container) + .setName(t('settings.mediaFolder.name')) + .setDesc(t('settings.mediaFolder.desc')) + .addText((text) => { + text + .setPlaceholder('Attachments') + .setValue(this.plugin.settings.mediaFolder) + .onChange(async (value) => { + await this.plugin.mutateSettings((settings) => { + settings.mediaFolder = value.trim(); + }); + }); + text.inputEl.addClass('claudian-settings-media-input'); + text.inputEl.addEventListener('blur', () => { + void this.restartServiceForPromptChange(); + }); + }); + + // --- Input --- + + new Setting(container).setName(t('settings.input')).setHeading(); + + new Setting(container) + .setName(t('settings.requireCommandOrControlEnterToSend.name')) + .setDesc(t('settings.requireCommandOrControlEnterToSend.desc')) + .addToggle((toggle) => { + toggle + .setValue(this.plugin.settings.requireCommandOrControlEnterToSend ?? false) + .onChange(async (value) => { + await this.plugin.mutateSettings((settings) => { + settings.requireCommandOrControlEnterToSend = value; + }); + }); + }); + + new Setting(container) + .setName(t('settings.navMappings.name')) + .setDesc(t('settings.navMappings.desc')) + .addTextArea((text) => { + let pendingValue = buildNavMappingText(this.plugin.settings.keyboardNavigation); + let saveTimeout: number | null = null; + + const commitValue = async (showError: boolean): Promise => { + if (saveTimeout !== null) { + window.clearTimeout(saveTimeout); + saveTimeout = null; + } + + const result = parseNavMappings(pendingValue); + if (!result.settings) { + if (showError) { + new Notice(`${t('common.error')}: ${result.error}`); + pendingValue = buildNavMappingText(this.plugin.settings.keyboardNavigation); + text.setValue(pendingValue); + } + return; + } + + await this.plugin.mutateSettings((settings) => { + settings.keyboardNavigation.scrollUpKey = result.settings!.scrollUp; + settings.keyboardNavigation.scrollDownKey = result.settings!.scrollDown; + settings.keyboardNavigation.focusInputKey = result.settings!.focusInput; + }); + pendingValue = buildNavMappingText(this.plugin.settings.keyboardNavigation); + text.setValue(pendingValue); + }; + + const scheduleSave = (): void => { + if (saveTimeout !== null) { + window.clearTimeout(saveTimeout); + } + saveTimeout = window.setTimeout(() => { + void commitValue(false); + }, 500); + }; + + text + .setPlaceholder('Map w scrollup\nmap s scrolldown\nmap i focusinput') + .setValue(pendingValue) + .onChange((value) => { + pendingValue = value; + scheduleSave(); + }); + + text.inputEl.rows = 3; + text.inputEl.addEventListener('blur', () => { + void commitValue(true); + }); + }); + + // --- Hotkeys --- + + new Setting(container).setName(t('settings.hotkeys')).setHeading(); + + const hotkeyGrid = container.createDiv({ cls: 'claudian-hotkey-grid' }); + addHotkeySettingRow(hotkeyGrid, this.app, 'claudian:inline-edit', 'settings.inlineEditHotkey'); + addHotkeySettingRow(hotkeyGrid, this.app, 'claudian:open-view', 'settings.openChatHotkey'); + addHotkeySettingRow(hotkeyGrid, this.app, 'claudian:new-session', 'settings.newSessionHotkey'); + addHotkeySettingRow(hotkeyGrid, this.app, 'claudian:new-tab', 'settings.newTabHotkey'); + addHotkeySettingRow(hotkeyGrid, this.app, 'claudian:close-current-tab', 'settings.closeTabHotkey'); + + // --- Environment --- + + renderEnvironmentSettingsSection({ + container, + plugin: this.plugin.providerHost, + scope: 'shared', + heading: t('settings.environment'), + name: 'Shared environment', + desc: 'Provider-neutral runtime variables shared across all providers. Use this for PATH, proxy, cert, and temp variables.', + placeholder: 'PATH=/opt/homebrew/bin:/usr/local/bin\nHTTPS_PROXY=http://proxy.example.com:8080\nSSL_CERT_FILE=/path/to/cert.pem', + renderCustomContextLimits: (target) => this.renderCustomContextLimits(target), + }); + } + + private renderHiddenProviderCommandSetting( + container: HTMLElement, + providerId: ProviderId, + copy: { name: string; desc: string; placeholder: string }, + ): void { + new Setting(container) + .setName(copy.name) + .setDesc(copy.desc) + .addTextArea((text) => { + text + .setPlaceholder(copy.placeholder) + .setValue(getHiddenProviderCommands(this.plugin.settings, providerId).join('\n')) + .onChange(async (value) => { + await this.plugin.mutateSettings((settings) => { + settings.hiddenProviderCommands = { + ...settings.hiddenProviderCommands, + [providerId]: normalizeHiddenCommandList(value.split(/\r?\n/)), + }; + }); + this.plugin.getView()?.updateHiddenProviderCommands(); + }); + text.inputEl.rows = 4; + text.inputEl.cols = 30; + }); + } + + private renderCustomContextLimits(container: HTMLElement, providerId?: ProviderId): void { + container.empty(); + + const uniqueModelIds = new Set(); + const providerIds = providerId + ? [providerId] + : ProviderRegistry.getRegisteredProviderIds(); + + for (const targetProviderId of providerIds) { + const envVars = parseEnvironmentVariables( + this.plugin.getActiveEnvironmentVariables(targetProviderId), + ); + for (const modelId of ProviderRegistry.getChatUIConfig(targetProviderId).getCustomModelIds(envVars)) { + uniqueModelIds.add(modelId); + } + } + + if (uniqueModelIds.size === 0) { + return; + } + + const headerEl = container.createDiv({ cls: 'claudian-context-limits-header' }); + headerEl.createSpan({ + text: t('settings.customModelOverrides.name'), + cls: 'claudian-context-limits-label', + }); + + const descEl = container.createDiv({ cls: 'claudian-context-limits-desc' }); + descEl.setText(t('settings.customModelOverrides.desc')); + + const listEl = container.createDiv({ cls: 'claudian-context-limits-list' }); + + for (const modelId of uniqueModelIds) { + const currentValue = this.plugin.settings.customContextLimits?.[modelId]; + const currentAlias = this.plugin.settings.customModelAliases?.[modelId] ?? ''; + + const itemEl = listEl.createDiv({ cls: 'claudian-context-limits-item' }); + const nameEl = itemEl.createDiv({ cls: 'claudian-context-limits-model' }); + nameEl.setText(modelId); + + const inputWrapper = itemEl.createDiv({ cls: 'claudian-context-limits-input-wrapper' }); + const aliasInputEl = inputWrapper.createEl('input', { + type: 'text', + placeholder: t('settings.customModelAliases.placeholder'), + cls: 'claudian-context-alias-input', + value: currentAlias, + }); + aliasInputEl.setAttribute('aria-label', `Alias for ${modelId}`); + aliasInputEl.title = 'Custom label shown in the model selector. Leave empty to use the default.'; + + const inputEl = inputWrapper.createEl('input', { + type: 'text', + placeholder: '200k', + cls: 'claudian-context-limits-input', + value: currentValue ? formatContextLimit(currentValue) : '', + }); + inputEl.setAttribute('aria-label', `Context window for ${modelId}`); + + const validationEl = inputWrapper.createDiv({ cls: 'claudian-context-limit-validation claudian-hidden' }); + + const saveAlias = async (): Promise => { + const existing = this.plugin.settings.customModelAliases[modelId] ?? ''; + const trimmed = aliasInputEl.value.trim(); + if (trimmed === existing) { + aliasInputEl.value = existing; + return; + } + + await this.plugin.mutateSettings((settings) => { + settings.customModelAliases ??= {}; + if (trimmed) { + settings.customModelAliases[modelId] = trimmed; + } else { + delete settings.customModelAliases[modelId]; + } + }); + for (const view of this.plugin.getAllViews()) { + view.refreshModelSelector(); + } + }; + + const saveContextLimit = async (): Promise => { + const trimmed = inputEl.value.trim(); + + if (!trimmed) { + validationEl.toggleClass('claudian-hidden', true); + inputEl.classList.remove('claudian-input-error'); + } else { + const parsed = parseContextLimit(trimmed); + if (parsed === null) { + validationEl.setText(t('settings.customContextLimits.invalid')); + validationEl.toggleClass('claudian-hidden', false); + inputEl.classList.add('claudian-input-error'); + return; + } + + validationEl.toggleClass('claudian-hidden', true); + inputEl.classList.remove('claudian-input-error'); + } + await this.plugin.mutateSettings((settings) => { + settings.customContextLimits ??= {}; + if (!trimmed) { + delete settings.customContextLimits[modelId]; + } else { + settings.customContextLimits[modelId] = parseContextLimit(trimmed)!; + } + }); + }; + + inputEl.addEventListener('input', () => { + void saveContextLimit(); + }); + aliasInputEl.addEventListener('blur', () => { + void saveAlias(); + }); + aliasInputEl.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + aliasInputEl.blur(); + } else if (event.key === 'Escape') { + event.preventDefault(); + aliasInputEl.value = this.plugin.settings.customModelAliases?.[modelId] ?? ''; + aliasInputEl.blur(); + } + }); + } + } + + private async restartServiceForPromptChange(): Promise { + const view = this.plugin.getView(); + const tabManager = view?.getTabManager(); + if (!tabManager) return; + + try { + await tabManager.broadcastToAllTabs( + async (service) => { await service.ensureReady({ force: true }); } + ); + } catch { + // Changes will apply on the next conversation if the restart fails. + } + } +} diff --git a/src/features/settings/keyboardNavigation.ts b/src/features/settings/keyboardNavigation.ts new file mode 100644 index 0000000..b294c73 --- /dev/null +++ b/src/features/settings/keyboardNavigation.ts @@ -0,0 +1,60 @@ +import type { KeyboardNavigationSettings } from '@/core/types/settings'; + +const NAV_ACTIONS = ['scrollUp', 'scrollDown', 'focusInput'] as const; +type NavAction = (typeof NAV_ACTIONS)[number]; + +export const buildNavMappingText = (settings: KeyboardNavigationSettings): string => { + return [ + `map ${settings.scrollUpKey} scrollUp`, + `map ${settings.scrollDownKey} scrollDown`, + `map ${settings.focusInputKey} focusInput`, + ].join('\n'); +}; + +export const parseNavMappings = ( + value: string +): { settings?: Record; error?: string } => { + const parsed: Partial> = {}; + const usedKeys = new Map(); + const lines = value.split('\n'); + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) continue; + + const parts = line.split(/\s+/); + if (parts.length !== 3 || parts[0] !== 'map') { + return { error: 'Each line must follow "map "' }; + } + + const key = parts[1]; + const action = parts[2] as NavAction; + + if (!NAV_ACTIONS.includes(action)) { + return { error: `Unknown action: ${parts[2]}` }; + } + + if (key.length !== 1) { + return { error: `Key must be a single character for ${action}` }; + } + + const normalizedKey = key.toLowerCase(); + if (usedKeys.has(normalizedKey)) { + return { error: 'Navigation keys must be unique' }; + } + + if (parsed[action]) { + return { error: `Duplicate mapping for ${action}` }; + } + + usedKeys.set(normalizedKey, action); + parsed[action] = key; + } + + const missing = NAV_ACTIONS.filter((action) => !parsed[action]); + if (missing.length > 0) { + return { error: `Missing mapping for ${missing.join(', ')}` }; + } + + return { settings: parsed as Record }; +}; diff --git a/src/i18n/constants.ts b/src/i18n/constants.ts new file mode 100644 index 0000000..3967fc1 --- /dev/null +++ b/src/i18n/constants.ts @@ -0,0 +1,58 @@ +/** + * i18n Constants and Utilities + * + * Centralized constants for language management and UI display + */ + +import type { Locale } from './types'; + +/** + * Supported locales with metadata + */ +export interface LocaleInfo { + code: Locale; + name: string; // Native name + englishName: string; // English name + flag?: string; // Optional flag emoji +} + +/** + * All supported locales with display information + */ +export const SUPPORTED_LOCALES: LocaleInfo[] = [ + { code: 'en', name: 'English', englishName: 'English', flag: '🇺🇸' }, + { code: 'zh-CN', name: '简体中文', englishName: 'Simplified Chinese', flag: '🇨🇳' }, + { code: 'zh-TW', name: '繁體中文', englishName: 'Traditional Chinese', flag: '🇹🇼' }, + { code: 'ja', name: '日本語', englishName: 'Japanese', flag: '🇯🇵' }, + { code: 'ko', name: '한국어', englishName: 'Korean', flag: '🇰🇷' }, + { code: 'de', name: 'Deutsch', englishName: 'German', flag: '🇩🇪' }, + { code: 'fr', name: 'Français', englishName: 'French', flag: '🇫🇷' }, + { code: 'es', name: 'Español', englishName: 'Spanish', flag: '🇪🇸' }, + { code: 'ru', name: 'Русский', englishName: 'Russian', flag: '🇷🇺' }, + { code: 'pt', name: 'Português', englishName: 'Portuguese', flag: '🇧🇷' }, +]; + +/** + * Default locale + */ +export const DEFAULT_LOCALE: Locale = 'en'; + +/** + * Get locale info by code + */ +export function getLocaleInfo(code: Locale): LocaleInfo | undefined { + return SUPPORTED_LOCALES.find(locale => locale.code === code); +} + +/** + * Get display string for locale (with optional flag) + */ +export function getLocaleDisplayString(code: Locale, includeFlag = true): string { + const info = getLocaleInfo(code); + if (!info) return code; + + return includeFlag && info.flag + ? `${info.flag} ${info.name} (${info.englishName})` + : `${info.name} (${info.englishName})`; +} + diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts new file mode 100644 index 0000000..e2b819d --- /dev/null +++ b/src/i18n/i18n.ts @@ -0,0 +1,140 @@ +/** + * i18n - Internationalization service for Claudian + * + * Provides translation functionality for all UI strings. + * Supports 10 locales with English as the default fallback. + */ + +import * as de from './locales/de.json'; +import * as en from './locales/en.json'; +import * as es from './locales/es.json'; +import * as fr from './locales/fr.json'; +import * as ja from './locales/ja.json'; +import * as ko from './locales/ko.json'; +import * as pt from './locales/pt.json'; +import * as ru from './locales/ru.json'; +import * as zhCN from './locales/zh-CN.json'; +import * as zhTW from './locales/zh-TW.json'; +import type { Locale, TranslationKey } from './types'; + +const translations: Record = { + en, + 'zh-CN': zhCN, + 'zh-TW': zhTW, + ja, + ko, + de, + fr, + es, + ru, + pt, +}; + +const DEFAULT_LOCALE: Locale = 'en'; +let currentLocale: Locale = DEFAULT_LOCALE; + +/** + * Get a translation by key with optional parameters + */ +export function t(key: TranslationKey, params?: Record): string { + const dict = translations[currentLocale]; + + const keys = key.split('.'); + let value: unknown = dict; + + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = (value as Record)[k]; + } else { + if (currentLocale !== DEFAULT_LOCALE) { + return tFallback(key, params); + } + return key; + } + } + + if (typeof value !== 'string') { + return key; + } + + if (params) { + return value.replace(/\{(\w+)\}/g, (match: string, param: string): string => { + const replacement = params[param]; + return replacement !== undefined ? `${replacement}` : match; + }); + } + + return value; +} + +function tFallback(key: TranslationKey, params?: Record): string { + const dict = translations[DEFAULT_LOCALE]; + const keys = key.split('.'); + let value: unknown = dict; + + for (const k of keys) { + if (value && typeof value === 'object' && k in value) { + value = (value as Record)[k]; + } else { + return key; + } + } + + if (typeof value !== 'string') { + return key; + } + + if (params) { + return value.replace(/\{(\w+)\}/g, (match: string, param: string): string => { + const replacement = params[param]; + return replacement !== undefined ? `${replacement}` : match; + }); + } + + return value; +} + +/** + * Set the current locale + * @returns true if locale was set successfully, false if locale is invalid + */ +export function setLocale(locale: Locale): boolean { + if (!translations[locale]) { + return false; + } + currentLocale = locale; + return true; +} + +/** + * Get the current locale + */ +export function getLocale(): Locale { + return currentLocale; +} + +/** + * Get all available locales + */ +export function getAvailableLocales(): Locale[] { + return Object.keys(translations) as Locale[]; +} + +/** + * Get display name for a locale + */ +export function getLocaleDisplayName(locale: Locale): string { + const names: Record = { + 'en': 'English', + 'zh-CN': '简体中文', + 'zh-TW': '繁體中文', + 'ja': '日本語', + 'ko': '한국어', + 'de': 'Deutsch', + 'fr': 'Français', + 'es': 'Español', + 'ru': 'Русский', + 'pt': 'Português', + }; + return names[locale] || locale; +} diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json new file mode 100644 index 0000000..d98de27 --- /dev/null +++ b/src/i18n/locales/de.json @@ -0,0 +1,446 @@ +{ + "common": { + "save": "Speichern", + "cancel": "Abbrechen", + "delete": "Löschen", + "edit": "Bearbeiten", + "add": "Hinzufügen", + "remove": "Entfernen", + "clear": "Löschen", + "clearAll": "Alle löschen", + "loading": "Lädt", + "error": "Fehler", + "success": "Erfolg", + "warning": "Warnung", + "confirm": "Bestätigen", + "settings": "Einstellungen", + "advanced": "Erweitert", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "platform": "Plattform", + "refresh": "Aktualisieren", + "rewind": "Zurückspulen" + }, + "chat": { + "rewind": { + "confirmMessage": "Zu diesem Punkt zurückspulen? Dateiänderungen nach dieser Nachricht werden rückgängig gemacht. Das Zurückspulen betrifft keine manuell oder über Bash bearbeiteten Dateien.", + "confirmMessageConversationOnly": "Konversation zu diesem Punkt zurückspulen? Dateiänderungen bleiben erhalten.", + "confirmButton": "Zurückspulen", + "ariaLabel": "Hierher zurückspulen", + "menuConversationOnly": "Nur Konversation zurückspulen", + "menuCodeAndConversation": "Code + Konversation zurückspulen", + "notice": "Zurückgespult: {count} Datei(en) wiederhergestellt", + "noticeConversationOnly": "Konversation zurückgespult; Dateiänderungen bleiben erhalten", + "noticeSaveFailed": "Zurückgespult: {count} Datei(en) wiederhergestellt, aber Status konnte nicht gespeichert werden: {error}", + "noticeConversationOnlySaveFailed": "Konversation zurückgespult, aber Status konnte nicht gespeichert werden: {error}", + "failed": "Zurückspulen fehlgeschlagen: {error}", + "cannot": "Zurückspulen nicht möglich: {error}", + "unavailableStreaming": "Zurückspulen während des Streamings nicht möglich", + "unavailableNoUuid": "Zurückspulen nicht möglich: Nachrichtenkennungen fehlen" + }, + "fork": { + "ariaLabel": "Konversation verzweigen", + "chooseTarget": "Konversation verzweigen", + "targetNewTab": "Neuer Tab", + "targetCurrentTab": "Aktueller Tab", + "maxTabsReached": "Verzweigung nicht möglich: maximal {count} Tabs erreicht", + "notice": "In neuem Tab verzweigt", + "noticeCurrentTab": "Im aktuellen Tab verzweigt", + "failed": "Verzweigung fehlgeschlagen: {error}", + "unavailableStreaming": "Verzweigung während des Streamings nicht möglich", + "unavailableNoUuid": "Verzweigung nicht möglich: Nachrichtenkennungen fehlen", + "unavailableNoResponse": "Verzweigung nicht möglich: keine Antwort zum Verzweigen vorhanden", + "errorMessageNotFound": "Nachricht nicht gefunden", + "errorNoSession": "Keine Sitzungs-ID verfügbar", + "errorNoActiveTab": "Kein aktiver Tab", + "commandNoMessages": "Verzweigung nicht möglich: keine Nachrichten in der Konversation", + "commandNoAssistantUuid": "Verzweigung nicht möglich: keine Assistentenantwort mit Kennungen" + }, + "bangBash": { + "placeholder": "> Einen Bash-Befehl ausführen...", + "commandPanel": "Befehlspanel", + "copyAriaLabel": "Neueste Befehlsausgabe kopieren", + "clearAriaLabel": "Bash-Ausgabe löschen", + "commandLabel": "{command}", + "statusLabel": "Status des Befehls: {status}", + "collapseOutput": "Befehlsausgabe einklappen", + "expandOutput": "Befehlsausgabe ausklappen", + "running": "Wird ausgeführt...", + "copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen" + } + }, + "settings": { + "title": "Claudian Einstellungen", + "tabs": { + "general": "Allgemein", + "claude": "Claude", + "codex": "Codex" + }, + "display": "Anzeige", + "conversations": "Unterhaltungen", + "content": "Inhalte", + "input": "Eingabe", + "setup": "Einrichtung", + "models": "Modelle", + "experimental": "Experimentell", + "userName": { + "name": "Wie soll Claudian dich nennen?", + "desc": "Dein Name für personalisierte Begrüßungen (leer lassen für allgemeine Begrüßungen)" + }, + "excludedTags": { + "name": "Ausgeschlossene Tags", + "desc": "Notizen mit diesen Tags werden nicht automatisch als Kontext geladen (einer pro Zeile, ohne #)" + }, + "mediaFolder": { + "name": "Medienordner", + "desc": "Ordner mit Anhängen/Bildern. Wenn Notizen ![[image.jpg]] verwenden, sucht Claude hier. Leer lassen für Vault-Stammverzeichnis." + }, + "systemPrompt": { + "name": "Benutzerdefinierter System-Prompt", + "desc": "Zusätzliche Anweisungen, die an den Standard-System-Prompt angehängt werden" + }, + "autoTitle": { + "name": "Konversationstitel automatisch generieren", + "desc": "Generiert automatisch Konversationstitel nach der ersten Nutzernachricht." + }, + "titleModel": { + "name": "Titel-Generierungsmodell", + "desc": "Modell zur automatischen Generierung von Konversationstiteln.", + "auto": "Automatisch (Haiku)" + }, + "navMappings": { + "name": "Vim-Style Navigationszuordnungen", + "desc": "Eine Zuordnung pro Zeile. Format: \"map \" (Aktionen: scrollUp, scrollDown, focusInput)." + }, + "requireCommandOrControlEnterToSend": { + "name": "Command/Ctrl+Enter zum Senden erfordern", + "desc": "Wenn aktiviert, fügt Enter einen Zeilenumbruch ein. Command+Enter sendet unter macOS; Ctrl+Enter sendet unter Windows und Linux." + }, + "hotkeys": "Tastenkürzel", + "inlineEditHotkey": { + "name": "Inline-Bearbeitung", + "descWithKey": "Aktuelles Tastenkürzel: {hotkey}", + "descNoKey": "Kein Tastenkürzel festgelegt", + "btnChange": "Ändern", + "btnSet": "Festlegen" + }, + "openChatHotkey": { + "name": "Chat öffnen", + "descWithKey": "Aktuelles Tastenkürzel: {hotkey}", + "descNoKey": "Kein Tastenkürzel festgelegt", + "btnChange": "Ändern", + "btnSet": "Festlegen" + }, + "newSessionHotkey": { + "name": "Neue Sitzung", + "descWithKey": "Aktuelles Tastenkürzel: {hotkey}", + "descNoKey": "Kein Tastenkürzel festgelegt", + "btnChange": "Ändern", + "btnSet": "Festlegen" + }, + "newTabHotkey": { + "name": "Neuer Tab", + "descWithKey": "Aktuelles Tastenkürzel: {hotkey}", + "descNoKey": "Kein Tastenkürzel festgelegt", + "btnChange": "Ändern", + "btnSet": "Festlegen" + }, + "closeTabHotkey": { + "name": "Tab schließen", + "descWithKey": "Aktuelles Tastenkürzel: {hotkey}", + "descNoKey": "Kein Tastenkürzel festgelegt", + "btnChange": "Ändern", + "btnSet": "Festlegen" + }, + "slashCommands": { + "name": "Befehle und Fähigkeiten", + "desc": "Verwalte Vault-Befehle und -Fähigkeiten in .claude/commands/ und .claude/skills/. Ausgelöst durch /Name." + }, + "hiddenSlashCommands": { + "name": "Ausgeblendete Befehle und Fähigkeiten", + "desc": "Bestimmte Befehle und Fähigkeiten aus dem Dropdown ausblenden. Nützlich, um Claude Code-Einträge auszublenden, die für Claudian nicht relevant sind. Gib Namen ohne führenden Schrägstrich ein, einen pro Zeile.", + "placeholder": "commit\nbuild\ntest" + }, + "mcpServers": { + "name": "MCP-Server", + "desc": "Verwalte Vault-MCP-Serverkonfigurationen in .claude/mcp.json. Server mit Kontext-Speichermodus benötigen @mention zur Aktivierung." + }, + "plugins": { + "name": "Claude Code-Plugins", + "desc": "Verwalte Claude Code Plugins aus ~/.claude/plugins. Aktivierte Plugins werden pro Vault in .claude/settings.json gespeichert." + }, + "subagents": { + "name": "Sub-Agenten", + "desc": "Verwalte Vault-Sub-Agenten in .claude/agents/. Jede Markdown-Datei definiert einen benutzerdefinierten Agenten.", + "noAgents": "Keine Sub-Agenten konfiguriert. Klicke auf +, um einen zu erstellen.", + "deleteConfirm": "Sub-Agent \"{name}\" löschen?", + "saveFailed": "Sub-Agent konnte nicht gespeichert werden: {message}", + "refreshFailed": "Sub-Agenten konnten nicht aktualisiert werden: {message}", + "deleteFailed": "Sub-Agent konnte nicht gelöscht werden: {message}", + "renameCleanupFailed": "Warnung: Alte Datei für \"{name}\" konnte nicht entfernt werden", + "created": "Sub-Agent \"{name}\" erstellt", + "updated": "Sub-Agent \"{name}\" aktualisiert", + "deleted": "Sub-Agent \"{name}\" gelöscht", + "duplicateName": "Ein Agent mit dem Namen \"{name}\" existiert bereits", + "descriptionRequired": "Beschreibung ist erforderlich", + "promptRequired": "System-Prompt ist erforderlich", + "modal": { + "titleEdit": "Sub-Agent bearbeiten", + "titleAdd": "Sub-Agent hinzufügen", + "name": "Name", + "nameDesc": "Nur Kleinbuchstaben, Zahlen und Bindestriche", + "namePlaceholder": "code-reviewer", + "description": "Beschreibung", + "descriptionDesc": "Kurzbeschreibung dieses Agenten", + "descriptionPlaceholder": "Prüft Code auf Fehler und Stil", + "advancedOptions": "Erweiterte Optionen", + "model": "Modell", + "modelDesc": "Modellüberschreibung für diesen Agenten", + "tools": "Tools", + "toolsDesc": "Kommagetrennte Liste zulässiger Tools (leer = alle)", + "disallowedTools": "Nicht erlaubte Tools", + "disallowedToolsDesc": "Kommagetrennte Liste der zu verbietenden Tools", + "skills": "Fähigkeiten", + "skillsDesc": "Kommagetrennte Liste von Fähigkeiten", + "prompt": "System-Prompt", + "promptDesc": "Anweisungen für den Agenten", + "promptPlaceholder": "Du bist ein Code-Reviewer. Analysiere den angegebenen Code auf..." + } + }, + "safety": "Sicherheit", + "loadUserSettings": { + "name": "Benutzer-Claude-Einstellungen laden", + "desc": "Lädt ~/.claude/settings.json. Wenn aktiviert, können Benutzer-Claude-Code-Berechtigungsregeln den Sicherheitsmodus umgehen." + }, + "claudeSafeMode": { + "name": "Sicherheitsmodus", + "desc": "Berechtigungsmodus, der verwendet wird, wenn der Safe-Schalter aktiv ist." + }, + "codexSafeMode": { + "name": "Sicherheitsmodus", + "desc": "Sandbox-Modus, der verwendet wird, wenn der Safe-Schalter aktiv ist." + }, + "environment": "Umgebung", + "customVariables": { + "name": "Benutzerdefinierte Variablen", + "desc": "Umgebungsvariablen für Claude SDK (KEY=VALUE-Format, eine pro Zeile). Export-Präfix unterstützt." + }, + "envSnippets": { + "name": "Snippets", + "addBtn": "Snippet hinzufügen", + "noSnippets": "Keine gespeicherten Umgebungsvariablen-Snippets. Klicken Sie auf +, um Ihre aktuelle Konfiguration zu speichern.", + "nameRequired": "Bitte geben Sie einen Namen für das Snippet ein", + "modal": { + "titleEdit": "Snippet bearbeiten", + "titleSave": "Snippet speichern", + "name": "Name", + "namePlaceholder": "Ein beschreibender Name für diese Umgebungskonfiguration", + "description": "Beschreibung", + "descPlaceholder": "Optionale Beschreibung", + "envVars": "Umgebungsvariablen", + "envVarsPlaceholder": "KEY=VALUE-Format, eine pro Zeile (export-Präfix unterstützt)", + "save": "Speichern", + "update": "Aktualisieren", + "cancel": "Abbrechen" + } + }, + "customModelOverrides": { + "name": "Benutzerdefinierte Modellüberschreibungen", + "desc": "Legen Sie Aliasnamen in der Modellauswahl und Kontextfenstergrößen für benutzerdefinierte Modelle fest." + }, + "customModelAliases": { + "placeholder": "Aliasname" + }, + "customContextLimits": { + "name": "Benutzerdefinierte Kontextlimits", + "desc": "Legen Sie die Kontextfenstergrößen für Ihre benutzerdefinierten Modelle fest. Leer lassen für den Standardwert (200k Token).", + "invalid": "Ungültiges Format. Verwenden Sie: 256k, 1m oder exakte Anzahl (1000-10000000)." + }, + "customModels": { + "name": "Benutzerdefinierte Modelle", + "desc": "Füge zusätzliche Claude-Modell-IDs zur Auswahl hinzu, eine pro Zeile. Modellüberschreibungen aus der Umgebung ersetzen die Auswahl weiterhin.", + "placeholder": "claude-opus-4-6\nclaude-opus-4-5-20251101" + }, + "enableChrome": { + "name": "Chrome-Erweiterung aktivieren", + "desc": "Erlaubt Claude die Interaktion mit Chrome über die claude-in-chrome-Erweiterung. Die Erweiterung muss installiert sein. Erfordert Neustart der Sitzung." + }, + "enableBangBash": { + "name": "Bash-Modus (!) aktivieren", + "desc": "Gib ! in ein leeres Eingabefeld ein, um den Bash-Modus zu starten. Führt Befehle direkt über Node.js child_process aus. Die Ansicht muss neu geöffnet werden.", + "validation": { + "noNode": "Node.js wurde auf PATH nicht gefunden. Installiere Node.js oder prüfe deine PATH-Konfiguration." + } + }, + "maxTabs": { + "name": "Maximale Chat-Tabs", + "desc": "Maximale Anzahl gleichzeitiger Chat-Tabs (3-10). Jeder Tab verwendet eine separate Claude-Sitzung.", + "warning": "Mehr als 5 Tabs können Leistung und Speichernutzung beeinträchtigen." + }, + "enableAutoScroll": { + "name": "Automatisches Scrollen während Streaming", + "desc": "Automatisch nach unten scrollen, während Claude Antworten streamt. Deaktivieren, um oben zu bleiben und von Anfang an zu lesen." + }, + "deferMathRenderingDuringStreaming": { + "name": "Mathe-Rendering während Streaming aufschieben", + "desc": "LaTeX während des Streamings roh anzeigen und Mathematik einmal rendern, wenn der Textblock abgeschlossen ist." + }, + "expandFileEditsByDefault": { + "name": "Dateibearbeitungen standardmäßig ausklappen", + "desc": "Write- und Edit-Diffs beim ersten Erscheinen ausgeklappt anzeigen." + }, + "chatViewPlacement": { + "name": "Claudian öffnen in", + "desc": "Wählen Sie, wo das Chat-Panel beim Erstellen geöffnet wird", + "rightSidebar": "Rechte Seitenleiste", + "leftSidebar": "Linke Seitenleiste", + "mainTab": "Haupteditor-Tab" + }, + "cliPath": { + "name": "Claude CLI-Pfad", + "desc": "Benutzerdefinierter Pfad zum Claude Code CLI. Leer lassen für automatische Erkennung.", + "descWindows": "Für den nativen Installer verwenden Sie claude.exe. Für npm/pnpm/yarn oder andere Paketmanager-Installationen verwenden Sie den cli-wrapper.cjs-Pfad (nicht claude.cmd).", + "descUnix": "Fügen Sie die Ausgabe von \"which claude\" ein — funktioniert sowohl für native als auch npm/pnpm/yarn-Installationen.", + "validation": { + "notExist": "Pfad existiert nicht", + "isDirectory": "Pfad ist ein Verzeichnis, keine Datei" + } + }, + "language": { + "name": "Sprache", + "desc": "Anzeigesprache der Plugin-Oberfläche ändern" + }, + "codex": { + "enableProvider": { + "name": "Codex-Anbieter aktivieren", + "desc": "Wenn aktiviert, erscheinen Codex-Modelle in der Modellauswahl für neue Unterhaltungen. Bestehende Codex-Sitzungen bleiben erhalten." + }, + "installationMethod": { + "name": "Installationsmethode", + "desc": "Wie Claudian Codex unter Windows starten soll. Native Windows verwendet einen Windows-Programmpfad. WSL startet die Linux-CLI in einer ausgewählten Distribution.", + "nativeWindows": "Natives Windows", + "wsl": "WSL" + }, + "cliPath": { + "name": "Codex-CLI-Pfad", + "descUnix": "Benutzerdefinierter Pfad zur lokalen Codex-CLI. Leer lassen, um bekannte Codex-Installationen und dann PATH zu bevorzugen.", + "descWindows": "Benutzerdefinierter Pfad zur lokalen Codex-CLI. Leer lassen für automatische Erkennung über PATH. Verwende den nativen Windows-Programmpfad, normalerweise `codex.exe`.", + "descWsl": "Linux-seitiger Codex-Befehl oder absoluter Pfad zur Ausführung in WSL. Leer lassen für PATH-Suche in der ausgewählten Distribution.", + "validation": { + "wslWindowsPath": "Der WSL-Modus erwartet einen Linux-Befehl oder absoluten Linux-Pfad, keinen Windows-Programmpfad." + } + }, + "wslDistroOverride": { + "name": "WSL-Distribution überschreiben", + "desc": "Optionale erweiterte Überschreibung. Leer lassen, um die Distribution möglichst aus einem WSL-Workspace-Pfad abzuleiten, sonst wird die Standard-WSL-Distribution verwendet." + }, + "safeMode": { + "workspaceWrite": "Workspace schreiben", + "readOnly": "Nur lesen" + }, + "customModels": { + "name": "Benutzerdefinierte Modelle", + "desc": "Füge zusätzliche Codex-Modell-IDs zur Auswahl hinzu, eine pro Zeile. `OPENAI_MODEL` hat weiterhin Vorrang, wenn gesetzt.", + "placeholder": "gpt-5.4\ngpt-5.3-codex-spark" + }, + "reasoningSummary": { + "name": "Reasoning-Zusammenfassung", + "desc": "Zeigt eine Zusammenfassung des Reasoning-Prozesses des Modells im Denkblock.", + "auto": "Automatisch", + "concise": "Kurz", + "detailed": "Ausführlich", + "off": "Aus" + }, + "skills": { + "name": "Codex-Fähigkeiten", + "desc": "Verwalte Vault-Codex-Fähigkeiten in .codex/skills/ oder .agents/skills/. Fähigkeiten auf Home-Ebene sind hier ausgeschlossen.", + "hiddenName": "Ausgeblendete Fähigkeiten", + "hiddenDesc": "Blende bestimmte Codex-Fähigkeiten aus dem Dropdown aus. Gib Fähigkeitsnamen ohne führendes $ ein, einen pro Zeile.", + "hiddenPlaceholder": "analyze\nexplain\nfix" + }, + "subagents": { + "name": "Codex-Sub-Agenten", + "desc": "Verwalte Vault-Codex-Sub-Agenten in .codex/agents/. Jede TOML-Datei definiert einen benutzerdefinierten Agenten." + }, + "mcp": { + "descBeforeCommand": "Codex verwaltet MCP-Server über seine eigene CLI. Konfiguriere mit ", + "descAfterCommand": ", dann sind sie in Claudian verfügbar. ", + "learnMore": "Mehr erfahren" + }, + "environment": { + "name": "Codex-Umgebung", + "desc": "Nur Codex-eigene Laufzeitvariablen. Verwende dies für OPENAI_*- und CODEX_*-Einstellungen. Wenn die Codex-Autoerkennung Hilfe braucht, füge das Installationsverzeichnis dem gemeinsamen PATH hinzu statt diesem Anbieterabschnitt." + } + }, + "codexSkills": { + "modal": { + "titleEdit": "Codex-Fähigkeit bearbeiten", + "titleAdd": "Codex-Fähigkeit hinzufügen", + "directory": "Verzeichnis", + "directoryDesc": "Speicherort der Fähigkeit", + "skillName": "Name der Fähigkeit", + "skillNameDesc": "Der Name nach $ (z. B. \"analyze\" für $analyze)", + "description": "Beschreibung", + "descriptionDesc": "Optionale Beschreibung im Dropdown", + "instructions": "Anweisungen", + "instructionsDesc": "Anweisungen der Fähigkeit (SKILL.md-Inhalt)", + "instructionsPlaceholder": "Analyze the code for..." + }, + "instructionsRequired": "Anweisungen sind erforderlich", + "saveFailed": "Codex-Fähigkeit konnte nicht gespeichert werden", + "header": "Codex-Fähigkeiten", + "noSkills": "Keine Codex-Fähigkeiten im Vault. Klicke auf +, um eine zu erstellen.", + "skillBadge": "Fähigkeit", + "deleted": "Codex-Fähigkeit \"{name}\" gelöscht", + "deleteFailed": "Codex-Fähigkeit konnte nicht gelöscht werden", + "created": "Codex-Fähigkeit \"{name}\" erstellt", + "updated": "Codex-Fähigkeit \"{name}\" aktualisiert" + }, + "codexSubagents": { + "modal": { + "titleEdit": "Codex-Sub-Agent bearbeiten", + "titleAdd": "Codex-Sub-Agent hinzufügen", + "nameDesc": "Agentenname, den Codex beim Starten verwendet (Kleinbuchstaben, Bindestriche, Unterstriche)", + "descriptionDesc": "Wann Codex diesen Agenten verwenden soll", + "modelDesc": "Modellüberschreibung (leer lassen zum Vererben)", + "namePlaceholder": "code_reviewer" + }, + "reasoningEffort": { + "name": "Reasoning-Aufwand", + "desc": "Reasoning-Aufwandsstufe des Modells", + "inherit": "Vererben", + "low": "Niedrig", + "medium": "Mittel", + "high": "Hoch", + "xhigh": "Sehr hoch" + }, + "sandboxMode": { + "name": "Sandbox-Modus", + "desc": "Sandbox-Beschränkung für diesen Agenten", + "inherit": "Vererben", + "readOnly": "Nur lesen", + "dangerFullAccess": "Vollzugriff", + "workspaceWrite": "Workspace schreiben" + }, + "nicknameCandidates": { + "name": "Nickname-Kandidaten", + "desc": "Kommagetrennte Anzeige-Nicknames (z. B. atlas, delta, echo)" + }, + "developerInstructions": { + "name": "Entwickleranweisungen", + "desc": "Kernanweisungen, die das Verhalten des Agenten definieren", + "placeholder": "Review code like an owner.\nPrioritize correctness, security, and missing test coverage.", + "required": "Entwickleranweisungen sind erforderlich" + }, + "validation": { + "nameRequired": "Sub-Agent-Name ist erforderlich", + "nameTooLong": "Sub-Agent-Name darf höchstens {count} Zeichen lang sein", + "nameInvalid": "Sub-Agent-Name darf nur Kleinbuchstaben, Zahlen, Bindestriche und Unterstriche enthalten", + "nicknameInvalid": "Nickname-Kandidaten dürfen nur ASCII-Buchstaben, Zahlen, Leerzeichen, Bindestriche und Unterstriche enthalten", + "nicknameDuplicate": "Nickname-Kandidaten müssen eindeutig sein" + }, + "header": "Codex-Sub-Agenten", + "noAgents": "Keine Codex-Sub-Agenten im Vault. Klicke auf +, um einen zu erstellen." + } + } +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json new file mode 100644 index 0000000..5e3d5a3 --- /dev/null +++ b/src/i18n/locales/en.json @@ -0,0 +1,446 @@ +{ + "common": { + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "add": "Add", + "remove": "Remove", + "clear": "Clear", + "clearAll": "Clear all", + "loading": "Loading", + "error": "Error", + "success": "Success", + "warning": "Warning", + "confirm": "Confirm", + "settings": "Settings", + "advanced": "Advanced", + "enabled": "Enabled", + "disabled": "Disabled", + "platform": "Platform", + "refresh": "Refresh", + "rewind": "Rewind" + }, + "chat": { + "rewind": { + "confirmMessage": "Rewind to this point? File changes after this message will be reverted. Rewinding does not affect files edited manually or via bash.", + "confirmMessageConversationOnly": "Rewind conversation to this point? File changes will be kept.", + "confirmButton": "Rewind", + "ariaLabel": "Rewind to here", + "menuConversationOnly": "Rewind conversation only", + "menuCodeAndConversation": "Rewind code + conversation", + "notice": "Rewound: {count} file(s) reverted", + "noticeConversationOnly": "Rewound conversation; file changes kept", + "noticeSaveFailed": "Rewound: {count} file(s) reverted, but failed to save state: {error}", + "noticeConversationOnlySaveFailed": "Rewound conversation, but failed to save state: {error}", + "failed": "Rewind failed: {error}", + "cannot": "Cannot rewind: {error}", + "unavailableStreaming": "Cannot rewind while streaming", + "unavailableNoUuid": "Cannot rewind: missing message identifiers" + }, + "fork": { + "ariaLabel": "Fork conversation", + "chooseTarget": "Fork conversation", + "targetNewTab": "New tab", + "targetCurrentTab": "Current tab", + "maxTabsReached": "Cannot fork: maximum {count} tabs reached", + "notice": "Forked to new tab", + "noticeCurrentTab": "Forked in current tab", + "failed": "Fork failed: {error}", + "unavailableStreaming": "Cannot fork while streaming", + "unavailableNoUuid": "Cannot fork: missing message identifiers", + "unavailableNoResponse": "Cannot fork: no response to fork from", + "errorMessageNotFound": "Message not found", + "errorNoSession": "No session ID available", + "errorNoActiveTab": "No active tab", + "commandNoMessages": "Cannot fork: no messages in conversation", + "commandNoAssistantUuid": "Cannot fork: no assistant response with identifiers" + }, + "bangBash": { + "placeholder": "> Run a bash command...", + "commandPanel": "Command panel", + "copyAriaLabel": "Copy latest command output", + "clearAriaLabel": "Clear bash output", + "commandLabel": "{command}", + "statusLabel": "Status: {status}", + "collapseOutput": "Collapse command output", + "expandOutput": "Expand command output", + "running": "Running...", + "copyFailed": "Failed to copy to clipboard" + } + }, + "settings": { + "title": "Claudian Settings", + "tabs": { + "general": "General", + "claude": "Claude", + "codex": "Codex" + }, + "display": "Display", + "conversations": "Conversations", + "content": "Content", + "input": "Input", + "setup": "Setup", + "models": "Models", + "experimental": "Experimental", + "userName": { + "name": "What should Claudian call you?", + "desc": "Your name for personalized greetings (leave empty for generic greetings)" + }, + "excludedTags": { + "name": "Excluded tags", + "desc": "Notes with these tags will not auto-load as context (one per line, without #)" + }, + "mediaFolder": { + "name": "Media folder", + "desc": "Folder containing attachments/images. When notes use ![[image.jpg]], Claude will look here. Leave empty for vault root." + }, + "systemPrompt": { + "name": "Custom system prompt", + "desc": "Additional instructions appended to the default system prompt" + }, + "autoTitle": { + "name": "Auto-generate conversation titles", + "desc": "Automatically generate conversation titles after the first user message is sent." + }, + "titleModel": { + "name": "Title generation model", + "desc": "Model used for auto-generating conversation titles.", + "auto": "Auto (Haiku)" + }, + "navMappings": { + "name": "Vim-style navigation mappings", + "desc": "One mapping per line. Format: \"map \" (actions: scrollUp, scrollDown, focusInput)." + }, + "requireCommandOrControlEnterToSend": { + "name": "Require Command/Ctrl+Enter to send", + "desc": "When enabled, Enter inserts a newline. Command+Enter sends on macOS; Ctrl+Enter sends on Windows and Linux." + }, + "hotkeys": "Hotkeys", + "inlineEditHotkey": { + "name": "Inline Edit", + "descWithKey": "Current hotkey: {hotkey}", + "descNoKey": "No hotkey set", + "btnChange": "Change", + "btnSet": "Set hotkey" + }, + "openChatHotkey": { + "name": "Open Chat", + "descWithKey": "Current hotkey: {hotkey}", + "descNoKey": "No hotkey set", + "btnChange": "Change", + "btnSet": "Set hotkey" + }, + "newSessionHotkey": { + "name": "New Session", + "descWithKey": "Current hotkey: {hotkey}", + "descNoKey": "No hotkey set", + "btnChange": "Change", + "btnSet": "Set hotkey" + }, + "newTabHotkey": { + "name": "New Tab", + "descWithKey": "Current hotkey: {hotkey}", + "descNoKey": "No hotkey set", + "btnChange": "Change", + "btnSet": "Set hotkey" + }, + "closeTabHotkey": { + "name": "Close Tab", + "descWithKey": "Current hotkey: {hotkey}", + "descNoKey": "No hotkey set", + "btnChange": "Change", + "btnSet": "Set hotkey" + }, + "slashCommands": { + "name": "Commands and Skills", + "desc": "Manage vault-level commands and skills stored in .claude/commands/ and .claude/skills/. Triggered by /name." + }, + "hiddenSlashCommands": { + "name": "Hidden Commands and Skills", + "desc": "Hide specific commands and skills from the dropdown. Useful for hiding Claude Code entries that are not relevant to Claudian. Enter names without the leading slash, one per line.", + "placeholder": "commit\nbuild\ntest" + }, + "mcpServers": { + "name": "MCP Servers", + "desc": "Manage vault-level MCP server configurations stored in .claude/mcp.json. Servers with context-saving mode require @mention to activate." + }, + "plugins": { + "name": "Claude Code Plugins", + "desc": "Manage Claude Code plugins discovered from ~/.claude/plugins. Enabled plugins are stored per vault in .claude/settings.json." + }, + "subagents": { + "name": "Subagents", + "desc": "Manage vault-level subagents stored in .claude/agents/. Each Markdown file defines one custom agent.", + "noAgents": "No subagents configured. Click + to create one.", + "deleteConfirm": "Delete subagent \"{name}\"?", + "saveFailed": "Failed to save subagent: {message}", + "refreshFailed": "Failed to refresh subagents: {message}", + "deleteFailed": "Failed to delete subagent: {message}", + "renameCleanupFailed": "Warning: could not remove old file for \"{name}\"", + "created": "Subagent \"{name}\" created", + "updated": "Subagent \"{name}\" updated", + "deleted": "Subagent \"{name}\" deleted", + "duplicateName": "An agent named \"{name}\" already exists", + "descriptionRequired": "Description is required", + "promptRequired": "System prompt is required", + "modal": { + "titleEdit": "Edit Subagent", + "titleAdd": "Add Subagent", + "name": "Name", + "nameDesc": "Lowercase letters, numbers, and hyphens only", + "namePlaceholder": "code-reviewer", + "description": "Description", + "descriptionDesc": "Brief description of this agent", + "descriptionPlaceholder": "Reviews code for bugs and style", + "advancedOptions": "Advanced options", + "model": "Model", + "modelDesc": "Model override for this agent", + "tools": "Tools", + "toolsDesc": "Comma-separated list of allowed tools (empty = all)", + "disallowedTools": "Disallowed tools", + "disallowedToolsDesc": "Comma-separated list of tools to disallow", + "skills": "Skills", + "skillsDesc": "Comma-separated list of skills", + "prompt": "System prompt", + "promptDesc": "Instructions for the agent", + "promptPlaceholder": "You are a code reviewer. Analyze the given code for..." + } + }, + "safety": "Safety", + "loadUserSettings": { + "name": "Load user Claude settings", + "desc": "Load ~/.claude/settings.json. When enabled, user's Claude Code permission rules may bypass Safe mode." + }, + "claudeSafeMode": { + "name": "Safe mode", + "desc": "Permission mode used when the Safe toggle is active." + }, + "codexSafeMode": { + "name": "Safe mode", + "desc": "Sandbox mode used when the Safe toggle is active." + }, + "environment": "Environment", + "customVariables": { + "name": "Custom variables", + "desc": "Environment variables for Claude SDK (KEY=VALUE format, one per line). Shell export prefix supported." + }, + "envSnippets": { + "name": "Snippets", + "addBtn": "Add snippet", + "noSnippets": "No saved environment snippets yet. Click + to save your current environment configuration.", + "nameRequired": "Please enter a name for the snippet", + "modal": { + "titleEdit": "Edit snippet", + "titleSave": "Save snippet", + "name": "Name", + "namePlaceholder": "A descriptive name for this environment configuration", + "description": "Description", + "descPlaceholder": "Optional description", + "envVars": "Environment variables", + "envVarsPlaceholder": "KEY=VALUE format, one per line (export prefix supported)", + "save": "Save", + "update": "Update", + "cancel": "Cancel" + } + }, + "customModelOverrides": { + "name": "Custom model overrides", + "desc": "Set model selector aliases and context window sizes for custom models." + }, + "customModelAliases": { + "placeholder": "Alias" + }, + "customContextLimits": { + "name": "Custom Context Limits", + "desc": "Set context window sizes for your custom models. Leave empty to use the default (200k tokens).", + "invalid": "Invalid format. Use: 256k, 1m, or exact count (1000-10000000)." + }, + "customModels": { + "name": "Custom models", + "desc": "Append additional Claude model IDs to the picker, one per line. Environment model overrides still replace the picker.", + "placeholder": "claude-opus-4-6\nclaude-opus-4-5-20251101" + }, + "enableChrome": { + "name": "Enable Chrome extension", + "desc": "Allow Claude to interact with Chrome via the claude-in-chrome extension. Requires the extension to be installed. Requires session restart." + }, + "enableBangBash": { + "name": "Enable bash mode (!)", + "desc": "Type ! on empty input to enter bash mode. Runs commands directly via Node.js child_process. Requires view reopen.", + "validation": { + "noNode": "Node.js not found on PATH. Install Node.js or check your PATH configuration." + } + }, + "maxTabs": { + "name": "Maximum chat tabs", + "desc": "Maximum number of concurrent chat tabs (3-10). Each tab uses a separate Claude session.", + "warning": "More than 5 tabs may impact performance and memory usage." + }, + "enableAutoScroll": { + "name": "Auto-scroll during streaming", + "desc": "Automatically scroll to the bottom as Claude streams responses. Disable to stay at the top and read from the beginning." + }, + "deferMathRenderingDuringStreaming": { + "name": "Defer math rendering during streaming", + "desc": "Show raw LaTeX while responses stream, then render math once when each text block completes." + }, + "expandFileEditsByDefault": { + "name": "Expand file edits by default", + "desc": "Show Write and Edit diffs expanded when they first appear." + }, + "chatViewPlacement": { + "name": "Open Claudian in", + "desc": "Choose where the chat panel opens when it is created", + "rightSidebar": "Right sidebar", + "leftSidebar": "Left sidebar", + "mainTab": "Main editor tab" + }, + "cliPath": { + "name": "Claude CLI path", + "desc": "Custom path to Claude Code CLI. Leave empty for auto-detection.", + "descWindows": "For the native installer, use claude.exe. For npm/pnpm/yarn or other package manager installs, use the cli-wrapper.cjs path (not claude.cmd).", + "descUnix": "Paste the output of \"which claude\" — works for both native and npm/pnpm/yarn installs.", + "validation": { + "notExist": "Path does not exist", + "isDirectory": "Path is a directory, not a file" + } + }, + "language": { + "name": "Language", + "desc": "Change the display language of the plugin interface" + }, + "codex": { + "enableProvider": { + "name": "Enable Codex provider", + "desc": "When enabled, Codex models appear in the model selector for new conversations. Existing Codex sessions are preserved." + }, + "installationMethod": { + "name": "Installation method", + "desc": "How Claudian should launch Codex on Windows. Native Windows uses a Windows executable path. WSL launches the Linux CLI inside a selected distro.", + "nativeWindows": "Native Windows", + "wsl": "WSL" + }, + "cliPath": { + "name": "Codex CLI path", + "descUnix": "Custom path to the local Codex CLI. Leave empty to prefer known Codex installs, then PATH.", + "descWindows": "Custom path to the local Codex CLI. Leave empty to auto-detect from PATH. Use the native Windows executable path, usually `codex.exe`.", + "descWsl": "Linux-side Codex command or absolute path to run inside WSL. Leave empty for PATH lookup inside the selected distro.", + "validation": { + "wslWindowsPath": "WSL mode expects a Linux command or Linux absolute path, not a Windows executable path." + } + }, + "wslDistroOverride": { + "name": "WSL distro override", + "desc": "Optional advanced override. Leave empty to infer the distro from a WSL workspace path when possible, otherwise use the default WSL distro." + }, + "safeMode": { + "workspaceWrite": "Workspace write", + "readOnly": "Read only" + }, + "customModels": { + "name": "Custom models", + "desc": "Append additional Codex model IDs to the picker, one per line. `OPENAI_MODEL` still takes precedence when set.", + "placeholder": "gpt-5.4\ngpt-5.3-codex-spark" + }, + "reasoningSummary": { + "name": "Reasoning summary", + "desc": "Show a summary of the model's reasoning process in the thinking block.", + "auto": "Auto", + "concise": "Concise", + "detailed": "Detailed", + "off": "Off" + }, + "skills": { + "name": "Codex skills", + "desc": "Manage vault-level Codex skills stored in .codex/skills/ or .agents/skills/. Home-level skills are excluded here.", + "hiddenName": "Hidden Skills", + "hiddenDesc": "Hide specific Codex skills from the dropdown. Enter skill names without the leading $, one per line.", + "hiddenPlaceholder": "analyze\nexplain\nfix" + }, + "subagents": { + "name": "Codex subagents", + "desc": "Manage vault-level Codex subagents stored in .codex/agents/. Each TOML file defines one custom agent." + }, + "mcp": { + "descBeforeCommand": "Codex manages MCP servers via its own CLI. Configure with ", + "descAfterCommand": " and they will be available in Claudian. ", + "learnMore": "Learn more" + }, + "environment": { + "name": "Codex environment", + "desc": "Codex-owned runtime variables only. Use this for OPENAI_* and CODEX_* settings. If Codex auto-detection needs help, add its install directory to shared PATH instead of this provider section." + } + }, + "codexSkills": { + "modal": { + "titleEdit": "Edit Codex Skill", + "titleAdd": "Add Codex Skill", + "directory": "Directory", + "directoryDesc": "Where to store the skill", + "skillName": "Skill name", + "skillNameDesc": "The name used after $ (e.g., \"analyze\" for $analyze)", + "description": "Description", + "descriptionDesc": "Optional description shown in dropdown", + "instructions": "Instructions", + "instructionsDesc": "The skill instructions (SKILL.md content)", + "instructionsPlaceholder": "Analyze the code for..." + }, + "instructionsRequired": "Instructions are required", + "saveFailed": "Failed to save Codex skill", + "header": "Codex Skills", + "noSkills": "No Codex skills in vault. Click + to create one.", + "skillBadge": "skill", + "deleted": "Codex skill \"{name}\" deleted", + "deleteFailed": "Failed to delete Codex skill", + "created": "Codex skill \"{name}\" created", + "updated": "Codex skill \"{name}\" updated" + }, + "codexSubagents": { + "modal": { + "titleEdit": "Edit Codex Subagent", + "titleAdd": "Add Codex Subagent", + "nameDesc": "Agent name Codex uses when spawning (lowercase, hyphens, underscores)", + "descriptionDesc": "When Codex should use this agent", + "modelDesc": "Model override (leave empty to inherit)", + "namePlaceholder": "code_reviewer" + }, + "reasoningEffort": { + "name": "Reasoning effort", + "desc": "Model reasoning effort level", + "inherit": "Inherit", + "low": "Low", + "medium": "Medium", + "high": "High", + "xhigh": "Extra High" + }, + "sandboxMode": { + "name": "Sandbox mode", + "desc": "Sandbox restriction for this agent", + "inherit": "Inherit", + "readOnly": "Read only", + "dangerFullAccess": "Danger full access", + "workspaceWrite": "Workspace write" + }, + "nicknameCandidates": { + "name": "Nickname candidates", + "desc": "Comma-separated display nicknames (e.g., atlas, delta, echo)" + }, + "developerInstructions": { + "name": "Developer instructions", + "desc": "Core instructions that define the agent's behavior", + "placeholder": "Review code like an owner.\nPrioritize correctness, security, and missing test coverage.", + "required": "Developer instructions are required" + }, + "validation": { + "nameRequired": "Subagent name is required", + "nameTooLong": "Subagent name must be {count} characters or fewer", + "nameInvalid": "Subagent name can only contain lowercase letters, numbers, hyphens, and underscores", + "nicknameInvalid": "Nickname candidates can only contain ASCII letters, numbers, spaces, hyphens, and underscores", + "nicknameDuplicate": "Nickname candidates must be unique" + }, + "header": "Codex Subagents", + "noAgents": "No Codex subagents in vault. Click + to create one." + } + } +} diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json new file mode 100644 index 0000000..fe7a843 --- /dev/null +++ b/src/i18n/locales/es.json @@ -0,0 +1,446 @@ +{ + "common": { + "save": "Guardar", + "cancel": "Cancelar", + "delete": "Eliminar", + "edit": "Editar", + "add": "Agregar", + "remove": "Eliminar", + "clear": "Limpiar", + "clearAll": "Limpiar todo", + "loading": "Cargando", + "error": "Error", + "success": "Éxito", + "warning": "Advertencia", + "confirm": "Confirmar", + "settings": "Configuración", + "advanced": "Avanzado", + "enabled": "Habilitado", + "disabled": "Deshabilitado", + "platform": "Plataforma", + "refresh": "Actualizar", + "rewind": "Rebobinar" + }, + "chat": { + "rewind": { + "confirmMessage": "¿Rebobinar a este punto? Los cambios de archivos después de este mensaje serán revertidos. El rebobinado no afecta archivos editados manualmente o mediante bash.", + "confirmMessageConversationOnly": "¿Rebobinar la conversación hasta este punto? Se conservarán los cambios de archivos.", + "confirmButton": "Rebobinar", + "ariaLabel": "Rebobinar hasta aquí", + "menuConversationOnly": "Rebobinar solo conversación", + "menuCodeAndConversation": "Rebobinar código + conversación", + "notice": "Rebobinado: {count} archivo(s) revertido(s)", + "noticeConversationOnly": "Conversación rebobinada; cambios de archivos conservados", + "noticeSaveFailed": "Rebobinado: {count} archivo(s) revertido(s), pero no se pudo guardar el estado: {error}", + "noticeConversationOnlySaveFailed": "Conversación rebobinada, pero no se pudo guardar el estado: {error}", + "failed": "Error al rebobinar: {error}", + "cannot": "No se puede rebobinar: {error}", + "unavailableStreaming": "No se puede rebobinar durante la transmisión", + "unavailableNoUuid": "No se puede rebobinar: faltan identificadores de mensaje" + }, + "fork": { + "ariaLabel": "Bifurcar conversación", + "chooseTarget": "Bifurcar conversación", + "targetNewTab": "Nueva pestaña", + "targetCurrentTab": "Pestaña actual", + "maxTabsReached": "No se puede bifurcar: máximo de {count} pestañas alcanzado", + "notice": "Bifurcado a nueva pestaña", + "noticeCurrentTab": "Bifurcado en pestaña actual", + "failed": "Error al bifurcar: {error}", + "unavailableStreaming": "No se puede bifurcar durante la transmisión", + "unavailableNoUuid": "No se puede bifurcar: faltan identificadores de mensaje", + "unavailableNoResponse": "No se puede bifurcar: no hay respuesta para bifurcar", + "errorMessageNotFound": "Mensaje no encontrado", + "errorNoSession": "No hay ningún ID de sesión disponible", + "errorNoActiveTab": "No hay ninguna pestaña activa", + "commandNoMessages": "No se puede bifurcar: no hay mensajes en la conversación", + "commandNoAssistantUuid": "No se puede bifurcar: no hay respuesta del asistente con identificadores" + }, + "bangBash": { + "placeholder": "> Ejecuta un comando bash...", + "commandPanel": "Panel de comandos", + "copyAriaLabel": "Copiar la salida del comando más reciente", + "clearAriaLabel": "Limpiar la salida de bash", + "commandLabel": "{command}", + "statusLabel": "Estado: {status}", + "collapseOutput": "Contraer la salida del comando", + "expandOutput": "Expandir la salida del comando", + "running": "Ejecutando...", + "copyFailed": "No se pudo copiar al portapapeles" + } + }, + "settings": { + "title": "Configuración de Claudian", + "tabs": { + "general": "General", + "claude": "Claude", + "codex": "Codex" + }, + "display": "Pantalla", + "conversations": "Conversaciones", + "content": "Contenido", + "input": "Entrada", + "setup": "Configuración", + "models": "Modelos", + "experimental": "Experimental", + "userName": { + "name": "¿Cómo debería Claudian llamarte?", + "desc": "Tu nombre para saludos personalizados (dejar vacío para saludos genéricos)" + }, + "excludedTags": { + "name": "Etiquetas excluidas", + "desc": "Las notas con estas etiquetas no se cargarán automáticamente como contexto (una por línea, sin #)" + }, + "mediaFolder": { + "name": "Carpeta de medios", + "desc": "Carpeta que contiene archivos adjuntos/imagenes. Cuando las notas usan ![[image.jpg]], Claude buscará aquí. Dejar vacío para la raíz del depósito." + }, + "systemPrompt": { + "name": "Prompt de sistema personalizado", + "desc": "Instrucciones adicionales añadidas al prompt de sistema por defecto" + }, + "autoTitle": { + "name": "Generar automáticamente títulos de conversación", + "desc": "Genera automáticamente títulos de conversación después del primer mensaje del usuario." + }, + "titleModel": { + "name": "Modelo de generación de títulos", + "desc": "Modelo utilizado para generar automáticamente títulos de conversación.", + "auto": "Automático (Haiku)" + }, + "navMappings": { + "name": "Mapeos de navegación estilo Vim", + "desc": "Un mapeo por línea. Formato: \"map \" (acciones: scrollUp, scrollDown, focusInput)." + }, + "requireCommandOrControlEnterToSend": { + "name": "Requerir Command/Ctrl+Enter para enviar", + "desc": "Cuando está activado, Enter inserta un salto de línea. Command+Enter envía en macOS; Ctrl+Enter envía en Windows y Linux." + }, + "hotkeys": "Atajos de teclado", + "inlineEditHotkey": { + "name": "Edición en línea", + "descWithKey": "Atajo actual: {hotkey}", + "descNoKey": "Sin atajo configurado", + "btnChange": "Cambiar", + "btnSet": "Configurar" + }, + "openChatHotkey": { + "name": "Abrir chat", + "descWithKey": "Atajo actual: {hotkey}", + "descNoKey": "Sin atajo configurado", + "btnChange": "Cambiar", + "btnSet": "Configurar" + }, + "newSessionHotkey": { + "name": "Nueva sesión", + "descWithKey": "Atajo actual: {hotkey}", + "descNoKey": "Sin atajo configurado", + "btnChange": "Cambiar", + "btnSet": "Configurar" + }, + "newTabHotkey": { + "name": "Nueva pestaña", + "descWithKey": "Atajo actual: {hotkey}", + "descNoKey": "Sin atajo configurado", + "btnChange": "Cambiar", + "btnSet": "Configurar" + }, + "closeTabHotkey": { + "name": "Cerrar pestaña", + "descWithKey": "Atajo actual: {hotkey}", + "descNoKey": "Sin atajo configurado", + "btnChange": "Cambiar", + "btnSet": "Configurar" + }, + "slashCommands": { + "name": "Comandos y habilidades", + "desc": "Administra comandos y habilidades a nivel de vault almacenados en .claude/commands/ y .claude/skills/. Activados por /nombre." + }, + "hiddenSlashCommands": { + "name": "Comandos y habilidades ocultos", + "desc": "Oculta comandos y habilidades específicos del menú desplegable. Útil para ocultar entradas de Claude Code que no son relevantes para Claudian. Ingresa los nombres sin la barra inicial, uno por línea.", + "placeholder": "commit\nbuild\ntest" + }, + "mcpServers": { + "name": "Servidores MCP", + "desc": "Administra configuraciones de servidores MCP a nivel de vault almacenadas en .claude/mcp.json. Los servidores con modo de guardado de contexto requieren @mention para activarse." + }, + "plugins": { + "name": "Plugins de Claude Code", + "desc": "Administra plugins de Claude Code descubiertos desde ~/.claude/plugins. Los plugins habilitados se almacenan por vault en .claude/settings.json." + }, + "subagents": { + "name": "Subagentes", + "desc": "Administra subagentes a nivel de vault almacenados en .claude/agents/. Cada archivo Markdown define un agente personalizado.", + "noAgents": "No hay subagentes configurados. Haz clic en + para crear uno.", + "deleteConfirm": "¿Eliminar el subagente \"{name}\"?", + "saveFailed": "No se pudo guardar el subagente: {message}", + "refreshFailed": "No se pudieron actualizar los subagentes: {message}", + "deleteFailed": "No se pudo eliminar el subagente: {message}", + "renameCleanupFailed": "Advertencia: no se pudo eliminar el archivo anterior de \"{name}\"", + "created": "Se creó el subagente \"{name}\"", + "updated": "Se actualizó el subagente \"{name}\"", + "deleted": "Se eliminó el subagente \"{name}\"", + "duplicateName": "Ya existe un agente con el nombre \"{name}\"", + "descriptionRequired": "La descripción es obligatoria", + "promptRequired": "El prompt del sistema es obligatorio", + "modal": { + "titleEdit": "Editar subagente", + "titleAdd": "Agregar subagente", + "name": "Nombre", + "nameDesc": "Solo letras minúsculas, números y guiones", + "namePlaceholder": "code-reviewer", + "description": "Descripción", + "descriptionDesc": "Descripción breve de este agente", + "descriptionPlaceholder": "Revisa código en busca de errores y estilo", + "advancedOptions": "Opciones avanzadas", + "model": "Modelo", + "modelDesc": "Modelo alternativo para este agente", + "tools": "Herramientas", + "toolsDesc": "Lista separada por comas de las herramientas permitidas (vacío = todas)", + "disallowedTools": "Herramientas no permitidas", + "disallowedToolsDesc": "Lista separada por comas de herramientas no permitidas", + "skills": "Habilidades", + "skillsDesc": "Lista separada por comas de habilidades", + "prompt": "Prompt del sistema", + "promptDesc": "Instrucciones para el agente", + "promptPlaceholder": "Eres un revisor de código. Analiza el código proporcionado para..." + } + }, + "safety": "Seguridad", + "loadUserSettings": { + "name": "Cargar configuración de usuario Claude", + "desc": "Carga ~/.claude/settings.json. Cuando está habilitado, las reglas de permisos del usuario pueden eludir el modo seguro." + }, + "claudeSafeMode": { + "name": "Modo seguro", + "desc": "Modo de permisos usado cuando el interruptor Seguro está activo." + }, + "codexSafeMode": { + "name": "Modo seguro", + "desc": "Modo de sandbox usado cuando el interruptor Seguro está activo." + }, + "environment": "Entorno", + "customVariables": { + "name": "Variables personalizadas", + "desc": "Variables de entorno para Claude SDK (formato KEY=VALUE, una por línea). Prefijo export soportado." + }, + "envSnippets": { + "name": "Fragmentos", + "addBtn": "Añadir fragmento", + "noSnippets": "No hay fragmentos de entorno guardados. Haga clic en + para guardar su configuración actual.", + "nameRequired": "Por favor ingrese un nombre para el fragmento", + "modal": { + "titleEdit": "Editar fragmento", + "titleSave": "Guardar fragmento", + "name": "Nombre", + "namePlaceholder": "Un nombre descriptivo para esta configuración", + "description": "Descripción", + "descPlaceholder": "Descripción opcional", + "envVars": "Variables de entorno", + "envVarsPlaceholder": "Formato KEY=VALUE, una por línea (prefijo export soportado)", + "save": "Guardar", + "update": "Actualizar", + "cancel": "Cancelar" + } + }, + "customModelOverrides": { + "name": "Anulaciones de modelos personalizados", + "desc": "Configure alias en el selector de modelos y tamaños de ventana de contexto para modelos personalizados." + }, + "customModelAliases": { + "placeholder": "Alias" + }, + "customContextLimits": { + "name": "Límites de contexto personalizados", + "desc": "Establezca tamaños de ventana de contexto para sus modelos personalizados. Deje vacío para usar el valor predeterminado (200k tokens).", + "invalid": "Formato inválido. Use: 256k, 1m o número exacto (1000-10000000)." + }, + "customModels": { + "name": "Modelos personalizados", + "desc": "Añade IDs de modelos de Claude al selector, uno por línea. Las sobrescrituras de modelo desde el entorno siguen reemplazando el selector.", + "placeholder": "claude-opus-4-6\nclaude-opus-4-5-20251101" + }, + "enableChrome": { + "name": "Habilitar extensión de Chrome", + "desc": "Permitir que Claude interactúe con Chrome a través de la extensión claude-in-chrome. Requiere que la extensión esté instalada. Requiere reinicio de sesión." + }, + "enableBangBash": { + "name": "Habilitar modo bash (!)", + "desc": "Escribe ! en una entrada vacía para entrar en modo bash. Ejecuta comandos directamente mediante Node.js child_process. Requiere volver a abrir la vista.", + "validation": { + "noNode": "Node.js no se encontró en PATH. Instala Node.js o revisa tu configuración de PATH." + } + }, + "maxTabs": { + "name": "Máximo de pestañas de chat", + "desc": "Número máximo de pestañas de chat simultáneas (3-10). Cada pestaña usa una sesión de Claude separada.", + "warning": "Más de 5 pestañas puede afectar el rendimiento y el uso de memoria." + }, + "enableAutoScroll": { + "name": "Desplazamiento automático durante streaming", + "desc": "Desplazarse automáticamente hacia abajo mientras Claude transmite respuestas. Desactivar para quedarse arriba y leer desde el principio." + }, + "deferMathRenderingDuringStreaming": { + "name": "Diferir renderizado matemático durante streaming", + "desc": "Mostrar LaTeX sin procesar mientras se transmite la respuesta y renderizar las fórmulas una vez al completar cada bloque de texto." + }, + "expandFileEditsByDefault": { + "name": "Expandir ediciones de archivo por defecto", + "desc": "Mostrar los diffs de Write y Edit expandidos cuando aparecen por primera vez." + }, + "chatViewPlacement": { + "name": "Abrir Claudian en", + "desc": "Elige dónde se abre el panel de chat cuando se crea", + "rightSidebar": "Barra lateral derecha", + "leftSidebar": "Barra lateral izquierda", + "mainTab": "Pestaña del editor principal" + }, + "cliPath": { + "name": "Ruta CLI Claude", + "desc": "Ruta personalizada a Claude Code CLI. Dejar vacío para detección automática.", + "descWindows": "Para el instalador nativo, use claude.exe. Para instalaciones con npm/pnpm/yarn u otros gestores de paquetes, use la ruta cli-wrapper.cjs (no claude.cmd).", + "descUnix": "Pegue la salida de \"which claude\" — funciona tanto para instalaciones nativas como npm/pnpm/yarn.", + "validation": { + "notExist": "La ruta no existe", + "isDirectory": "La ruta es un directorio, no un archivo" + } + }, + "language": { + "name": "Idioma", + "desc": "Cambiar el idioma de visualización de la interfaz del plugin" + }, + "codex": { + "enableProvider": { + "name": "Activar proveedor Codex", + "desc": "Cuando está activado, los modelos Codex aparecen en el selector de modelos para nuevas conversaciones. Las sesiones Codex existentes se conservan." + }, + "installationMethod": { + "name": "Método de instalación", + "desc": "Cómo debe iniciar Claudian Codex en Windows. Windows nativo usa una ruta de ejecutable de Windows. WSL inicia la CLI de Linux dentro de una distribución seleccionada.", + "nativeWindows": "Windows nativo", + "wsl": "WSL" + }, + "cliPath": { + "name": "Ruta de la CLI de Codex", + "descUnix": "Ruta personalizada a la CLI local de Codex. Déjala vacía para preferir instalaciones conocidas de Codex y luego PATH.", + "descWindows": "Ruta personalizada a la CLI local de Codex. Déjala vacía para autodetectar desde PATH. Usa la ruta del ejecutable nativo de Windows, normalmente `codex.exe`.", + "descWsl": "Comando de Codex del lado Linux o ruta absoluta para ejecutar dentro de WSL. Déjala vacía para buscar en PATH dentro de la distribución seleccionada.", + "validation": { + "wslWindowsPath": "El modo WSL espera un comando Linux o una ruta absoluta de Linux, no una ruta de ejecutable de Windows." + } + }, + "wslDistroOverride": { + "name": "Sobrescritura de distribución WSL", + "desc": "Sobrescritura avanzada opcional. Déjala vacía para inferir la distribución desde una ruta de espacio de trabajo WSL cuando sea posible; si no, se usa la distribución WSL predeterminada." + }, + "safeMode": { + "workspaceWrite": "Escritura en espacio de trabajo", + "readOnly": "Solo lectura" + }, + "customModels": { + "name": "Modelos personalizados", + "desc": "Añade IDs de modelos Codex al selector, uno por línea. `OPENAI_MODEL` sigue teniendo prioridad cuando está definido.", + "placeholder": "gpt-5.4\ngpt-5.3-codex-spark" + }, + "reasoningSummary": { + "name": "Resumen de razonamiento", + "desc": "Muestra un resumen del proceso de razonamiento del modelo en el bloque de pensamiento.", + "auto": "Auto", + "concise": "Conciso", + "detailed": "Detallado", + "off": "Desactivado" + }, + "skills": { + "name": "Habilidades de Codex", + "desc": "Gestiona habilidades Codex de nivel vault guardadas en .codex/skills/ o .agents/skills/. Las habilidades de nivel home se excluyen aquí.", + "hiddenName": "Habilidades ocultas", + "hiddenDesc": "Oculta habilidades Codex específicas del desplegable. Introduce nombres de habilidad sin el $ inicial, uno por línea.", + "hiddenPlaceholder": "analyze\nexplain\nfix" + }, + "subagents": { + "name": "Subagentes de Codex", + "desc": "Gestiona subagentes Codex de nivel vault guardados en .codex/agents/. Cada archivo TOML define un agente personalizado." + }, + "mcp": { + "descBeforeCommand": "Codex gestiona servidores MCP mediante su propia CLI. Configúralos con ", + "descAfterCommand": " y estarán disponibles en Claudian. ", + "learnMore": "Más información" + }, + "environment": { + "name": "Entorno de Codex", + "desc": "Solo variables de ejecución propiedad de Codex. Úsalo para ajustes OPENAI_* y CODEX_*. Si la autodetección de Codex necesita ayuda, añade su directorio de instalación al PATH compartido en lugar de esta sección del proveedor." + } + }, + "codexSkills": { + "modal": { + "titleEdit": "Editar habilidad de Codex", + "titleAdd": "Añadir habilidad de Codex", + "directory": "Directorio", + "directoryDesc": "Dónde guardar la habilidad", + "skillName": "Nombre de habilidad", + "skillNameDesc": "El nombre usado después de $ (por ejemplo, \"analyze\" para $analyze)", + "description": "Descripción", + "descriptionDesc": "Descripción opcional mostrada en el desplegable", + "instructions": "Instrucciones", + "instructionsDesc": "Instrucciones de la habilidad (contenido de SKILL.md)", + "instructionsPlaceholder": "Analyze the code for..." + }, + "instructionsRequired": "Las instrucciones son obligatorias", + "saveFailed": "No se pudo guardar la habilidad de Codex", + "header": "Habilidades de Codex", + "noSkills": "No hay habilidades Codex en el vault. Haz clic en + para crear una.", + "skillBadge": "habilidad", + "deleted": "Habilidad de Codex \"{name}\" eliminada", + "deleteFailed": "No se pudo eliminar la habilidad de Codex", + "created": "Habilidad de Codex \"{name}\" creada", + "updated": "Habilidad de Codex \"{name}\" actualizada" + }, + "codexSubagents": { + "modal": { + "titleEdit": "Editar subagente de Codex", + "titleAdd": "Añadir subagente de Codex", + "nameDesc": "Nombre de agente que Codex usa al generarlo (minúsculas, guiones y guiones bajos)", + "descriptionDesc": "Cuándo debería usar Codex este agente", + "modelDesc": "Sobrescritura de modelo (vacío = heredar)", + "namePlaceholder": "code_reviewer" + }, + "reasoningEffort": { + "name": "Esfuerzo de razonamiento", + "desc": "Nivel de esfuerzo de razonamiento del modelo", + "inherit": "Heredar", + "low": "Bajo", + "medium": "Medio", + "high": "Alto", + "xhigh": "Muy alto" + }, + "sandboxMode": { + "name": "Modo sandbox", + "desc": "Restricción de sandbox para este agente", + "inherit": "Heredar", + "readOnly": "Solo lectura", + "dangerFullAccess": "Acceso total", + "workspaceWrite": "Escritura en espacio de trabajo" + }, + "nicknameCandidates": { + "name": "Candidatos de apodo", + "desc": "Apodos visibles separados por comas (p. ej., atlas, delta, echo)" + }, + "developerInstructions": { + "name": "Instrucciones de desarrollador", + "desc": "Instrucciones centrales que definen el comportamiento del agente", + "placeholder": "Review code like an owner.\nPrioritize correctness, security, and missing test coverage.", + "required": "Las instrucciones de desarrollador son obligatorias" + }, + "validation": { + "nameRequired": "El nombre del subagente es obligatorio", + "nameTooLong": "El nombre del subagente debe tener {count} caracteres o menos", + "nameInvalid": "El nombre del subagente solo puede contener letras minúsculas, números, guiones y guiones bajos", + "nicknameInvalid": "Los candidatos de apodo solo pueden contener letras ASCII, números, espacios, guiones y guiones bajos", + "nicknameDuplicate": "Los candidatos de apodo deben ser únicos" + }, + "header": "Subagentes de Codex", + "noAgents": "No hay subagentes Codex en el vault. Haz clic en + para crear uno." + } + } +} diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json new file mode 100644 index 0000000..00a3823 --- /dev/null +++ b/src/i18n/locales/fr.json @@ -0,0 +1,446 @@ +{ + "common": { + "save": "Enregistrer", + "cancel": "Annuler", + "delete": "Supprimer", + "edit": "Modifier", + "add": "Ajouter", + "remove": "Supprimer", + "clear": "Effacer", + "clearAll": "Tout effacer", + "loading": "Chargement", + "error": "Erreur", + "success": "Succès", + "warning": "Avertissement", + "confirm": "Confirmer", + "settings": "Paramètres", + "advanced": "Avancé", + "enabled": "Activé", + "disabled": "Désactivé", + "platform": "Plateforme", + "refresh": "Actualiser", + "rewind": "Rembobiner" + }, + "chat": { + "rewind": { + "confirmMessage": "Rembobiner jusqu'à ce point ? Les modifications de fichiers après ce message seront annulées. Le rembobinage n'affecte pas les fichiers modifiés manuellement ou via bash.", + "confirmMessageConversationOnly": "Rembobiner la conversation jusqu'à ce point ? Les modifications de fichiers seront conservées.", + "confirmButton": "Rembobiner", + "ariaLabel": "Rembobiner jusqu'ici", + "menuConversationOnly": "Rembobiner la conversation seule", + "menuCodeAndConversation": "Rembobiner code + conversation", + "notice": "Rembobiné : {count} fichier(s) restauré(s)", + "noticeConversationOnly": "Conversation rembobinée ; modifications de fichiers conservées", + "noticeSaveFailed": "Rembobiné : {count} fichier(s) restauré(s), mais impossible d'enregistrer l'état : {error}", + "noticeConversationOnlySaveFailed": "Conversation rembobinée, mais impossible d'enregistrer l'état : {error}", + "failed": "Échec du rembobinage : {error}", + "cannot": "Impossible de rembobiner : {error}", + "unavailableStreaming": "Impossible de rembobiner pendant le streaming", + "unavailableNoUuid": "Impossible de rembobiner : identifiants de message manquants" + }, + "fork": { + "ariaLabel": "Bifurquer la conversation", + "chooseTarget": "Bifurquer la conversation", + "targetNewTab": "Nouvel onglet", + "targetCurrentTab": "Onglet actuel", + "maxTabsReached": "Impossible de bifurquer : maximum de {count} onglets atteint", + "notice": "Bifurqué dans un nouvel onglet", + "noticeCurrentTab": "Bifurqué dans l'onglet actuel", + "failed": "Échec de la bifurcation : {error}", + "unavailableStreaming": "Impossible de bifurquer pendant le streaming", + "unavailableNoUuid": "Impossible de bifurquer : identifiants de message manquants", + "unavailableNoResponse": "Impossible de bifurquer : aucune réponse pour bifurquer", + "errorMessageNotFound": "Message introuvable", + "errorNoSession": "Aucun ID de session disponible", + "errorNoActiveTab": "Aucun onglet actif", + "commandNoMessages": "Impossible de bifurquer : aucun message dans la conversation", + "commandNoAssistantUuid": "Impossible de bifurquer : aucune réponse de l’assistant avec des identifiants" + }, + "bangBash": { + "placeholder": "> Exécuter une commande bash...", + "commandPanel": "Panneau de commandes", + "copyAriaLabel": "Copier la sortie de la dernière commande", + "clearAriaLabel": "Effacer la sortie bash", + "commandLabel": "{command}", + "statusLabel": "Statut : {status}", + "collapseOutput": "Réduire la sortie de la commande", + "expandOutput": "Développer la sortie de la commande", + "running": "Exécution...", + "copyFailed": "Échec de la copie dans le presse-papiers" + } + }, + "settings": { + "title": "Paramètres Claudian", + "tabs": { + "general": "Général", + "claude": "Claude", + "codex": "Codex" + }, + "display": "Affichage", + "conversations": "Conversations", + "content": "Contenu", + "input": "Saisie", + "setup": "Configuration", + "models": "Modèles", + "experimental": "Expérimental", + "userName": { + "name": "Comment Claudian doit-il vous appeler ?", + "desc": "Votre nom pour les salutations personnalisées (laisser vide pour les salutations génériques)" + }, + "excludedTags": { + "name": "Tags exclus", + "desc": "Les notes avec ces tags ne seront pas chargées automatiquement comme contexte (un par ligne, sans #)" + }, + "mediaFolder": { + "name": "Dossier des médias", + "desc": "Dossier contenant les pièces jointes/images. Lorsque les notes utilisent ![[image.jpg]], Claude cherchera ici. Laisser vide pour la racine du coffre." + }, + "systemPrompt": { + "name": "Prompt système personnalisé", + "desc": "Instructions supplémentaires ajoutées au prompt système par défaut" + }, + "autoTitle": { + "name": "Générer automatiquement les titres de conversation", + "desc": "Génère automatiquement les titres de conversation après le premier message de l'utilisateur." + }, + "titleModel": { + "name": "Modèle de génération de titre", + "desc": "Modèle utilisé pour générer automatiquement les titres de conversation.", + "auto": "Automatique (Haiku)" + }, + "navMappings": { + "name": "Mappages de navigation style Vim", + "desc": "Un mappage par ligne. Format : \"map \" (actions : scrollUp, scrollDown, focusInput)." + }, + "requireCommandOrControlEnterToSend": { + "name": "Exiger Command/Ctrl+Entrée pour envoyer", + "desc": "Lorsque cette option est activée, Entrée insère une nouvelle ligne. Command+Entrée envoie sur macOS ; Ctrl+Entrée envoie sur Windows et Linux." + }, + "hotkeys": "Raccourcis clavier", + "inlineEditHotkey": { + "name": "Édition en ligne", + "descWithKey": "Raccourci actuel : {hotkey}", + "descNoKey": "Aucun raccourci défini", + "btnChange": "Modifier", + "btnSet": "Définir" + }, + "openChatHotkey": { + "name": "Ouvrir le chat", + "descWithKey": "Raccourci actuel : {hotkey}", + "descNoKey": "Aucun raccourci défini", + "btnChange": "Modifier", + "btnSet": "Définir" + }, + "newSessionHotkey": { + "name": "Nouvelle session", + "descWithKey": "Raccourci actuel : {hotkey}", + "descNoKey": "Aucun raccourci défini", + "btnChange": "Modifier", + "btnSet": "Définir" + }, + "newTabHotkey": { + "name": "Nouvel onglet", + "descWithKey": "Raccourci actuel : {hotkey}", + "descNoKey": "Aucun raccourci défini", + "btnChange": "Modifier", + "btnSet": "Définir" + }, + "closeTabHotkey": { + "name": "Fermer l'onglet", + "descWithKey": "Raccourci actuel : {hotkey}", + "descNoKey": "Aucun raccourci défini", + "btnChange": "Modifier", + "btnSet": "Définir" + }, + "slashCommands": { + "name": "Commandes et compétences", + "desc": "Gérez les commandes et compétences au niveau du vault stockées dans .claude/commands/ et .claude/skills/. Déclenchées par /nom." + }, + "hiddenSlashCommands": { + "name": "Commandes et compétences masquées", + "desc": "Masquez des commandes et compétences spécifiques du menu déroulant. Utile pour masquer les entrées Claude Code qui ne sont pas pertinentes pour Claudian. Saisissez les noms sans le slash initial, un par ligne.", + "placeholder": "commit\nbuild\ntest" + }, + "mcpServers": { + "name": "Serveurs MCP", + "desc": "Gérez les configurations de serveurs MCP au niveau du vault stockées dans .claude/mcp.json. Les serveurs avec mode de sauvegarde de contexte nécessitent une @mention pour s'activer." + }, + "plugins": { + "name": "Plugins Claude Code", + "desc": "Gérez les plugins Claude Code découverts dans ~/.claude/plugins. Les plugins activés sont stockés par vault dans .claude/settings.json." + }, + "subagents": { + "name": "Sous-agents", + "desc": "Gérez les sous-agents au niveau du vault stockés dans .claude/agents/. Chaque fichier Markdown définit un agent personnalisé.", + "noAgents": "Aucun sous-agent configuré. Cliquez sur + pour en créer un.", + "deleteConfirm": "Supprimer le sous-agent \"{name}\" ?", + "saveFailed": "Échec de l’enregistrement du sous-agent : {message}", + "refreshFailed": "Échec de l’actualisation des subagents : {message}", + "deleteFailed": "Échec de la suppression du sous-agent : {message}", + "renameCleanupFailed": "Avertissement : impossible de supprimer l’ancien fichier pour \"{name}\"", + "created": "Sous-agent \"{name}\" créé", + "updated": "Sous-agent \"{name}\" mis à jour", + "deleted": "Sous-agent \"{name}\" supprimé", + "duplicateName": "Un agent nommé \"{name}\" existe déjà", + "descriptionRequired": "La description est obligatoire", + "promptRequired": "Le prompt système est obligatoire", + "modal": { + "titleEdit": "Modifier le sous-agent", + "titleAdd": "Ajouter un sous-agent", + "name": "Nom", + "nameDesc": "Lettres minuscules, chiffres et tirets uniquement", + "namePlaceholder": "code-reviewer", + "description": "Description", + "descriptionDesc": "Brève description de cet agent", + "descriptionPlaceholder": "Examine le code pour détecter les bugs et les problèmes de style", + "advancedOptions": "Options avancées", + "model": "Modèle", + "modelDesc": "Modèle à utiliser pour cet agent", + "tools": "Outils", + "toolsDesc": "Liste des outils autorisés, séparés par des virgules (vide = tous)", + "disallowedTools": "Outils non autorisés", + "disallowedToolsDesc": "Liste des outils à interdire, séparés par des virgules", + "skills": "Compétences", + "skillsDesc": "Liste des compétences, séparées par des virgules", + "prompt": "Prompt système", + "promptDesc": "Instructions pour l’agent", + "promptPlaceholder": "Vous êtes un relecteur de code. Analysez le code fourni pour..." + } + }, + "safety": "Sécurité", + "loadUserSettings": { + "name": "Charger les paramètres utilisateur Claude", + "desc": "Charge ~/.claude/settings.json. Lorsqu'activé, les règles de permission de l'utilisateur peuvent contourner le mode sécurisé." + }, + "claudeSafeMode": { + "name": "Mode sécurisé", + "desc": "Mode d'autorisation utilisé lorsque le bouton Safe est actif." + }, + "codexSafeMode": { + "name": "Mode sécurisé", + "desc": "Mode sandbox utilisé lorsque le bouton Safe est actif." + }, + "environment": "Environnement", + "customVariables": { + "name": "Variables personnalisées", + "desc": "Variables d'environnement pour Claude SDK (format KEY=VALUE, une par ligne). Préfixe export supporté." + }, + "envSnippets": { + "name": "Extraits", + "addBtn": "Ajouter un extrait", + "noSnippets": "Aucun extrait d'environnement enregistré. Cliquez sur + pour sauvegarder votre configuration actuelle.", + "nameRequired": "Veuillez entrer un nom pour l'extrait", + "modal": { + "titleEdit": "Modifier l'extrait", + "titleSave": "Sauvegarder l'extrait", + "name": "Nom", + "namePlaceholder": "Un nom descriptif pour cette configuration", + "description": "Description", + "descPlaceholder": "Description optionnelle", + "envVars": "Variables d'environnement", + "envVarsPlaceholder": "Format KEY=VALUE, une par ligne (préfixe export supporté)", + "save": "Enregistrer", + "update": "Mettre à jour", + "cancel": "Annuler" + } + }, + "customModelOverrides": { + "name": "Remplacements de modèles personnalisés", + "desc": "Définissez les alias dans le sélecteur de modèle et les tailles de fenêtre de contexte pour les modèles personnalisés." + }, + "customModelAliases": { + "placeholder": "Alias" + }, + "customContextLimits": { + "name": "Limites de contexte personnalisées", + "desc": "Définissez les tailles de fenêtre de contexte pour vos modèles personnalisés. Laissez vide pour utiliser la valeur par défaut (200k tokens).", + "invalid": "Format invalide. Utilisez : 256k, 1m ou nombre exact (1000-10000000)." + }, + "customModels": { + "name": "Modèles personnalisés", + "desc": "Ajoutez des IDs de modèles Claude au sélecteur, un par ligne. Les remplacements de modèle par variables d'environnement remplacent toujours le sélecteur.", + "placeholder": "claude-opus-4-6\nclaude-opus-4-5-20251101" + }, + "enableChrome": { + "name": "Activer l'extension Chrome", + "desc": "Permettre à Claude d'interagir avec Chrome via l'extension claude-in-chrome. L'extension doit être installée. Nécessite un redémarrage de session." + }, + "enableBangBash": { + "name": "Activer le mode bash (!)", + "desc": "Saisissez ! dans un champ vide pour passer en mode bash. Exécute les commandes directement via le child_process de Node.js. Nécessite de rouvrir la vue.", + "validation": { + "noNode": "Node.js introuvable dans PATH. Installez Node.js ou vérifiez votre configuration PATH." + } + }, + "maxTabs": { + "name": "Maximum d'onglets de chat", + "desc": "Nombre maximum d'onglets de chat simultanés (3-10). Chaque onglet utilise une session Claude séparée.", + "warning": "Plus de 5 onglets peut affecter les performances et l'utilisation de la mémoire." + }, + "enableAutoScroll": { + "name": "Défilement automatique pendant le streaming", + "desc": "Défiler automatiquement vers le bas pendant que Claude diffuse les réponses. Désactiver pour rester en haut et lire depuis le début." + }, + "deferMathRenderingDuringStreaming": { + "name": "Différer le rendu mathématique pendant le streaming", + "desc": "Afficher le LaTeX brut pendant la diffusion, puis rendre les formules une fois chaque bloc de texte terminé." + }, + "expandFileEditsByDefault": { + "name": "Développer les modifications de fichiers par défaut", + "desc": "Afficher les diffs Write et Edit développés lors de leur première apparition." + }, + "chatViewPlacement": { + "name": "Ouvrir Claudian dans", + "desc": "Choisissez où le panneau de chat s'ouvre lors de sa création", + "rightSidebar": "Barre latérale droite", + "leftSidebar": "Barre latérale gauche", + "mainTab": "Onglet principal de l'éditeur" + }, + "cliPath": { + "name": "Chemin CLI Claude", + "desc": "Chemin personnalisé vers Claude Code CLI. Laisser vide pour la détection automatique.", + "descWindows": "Pour l'installateur natif, utilisez claude.exe. Pour les installations npm/pnpm/yarn ou autres gestionnaires de paquets, utilisez le chemin cli-wrapper.cjs (pas claude.cmd).", + "descUnix": "Collez la sortie de \"which claude\" — fonctionne pour les installations natives et npm/pnpm/yarn.", + "validation": { + "notExist": "Le chemin n'existe pas", + "isDirectory": "Le chemin est un répertoire, pas un fichier" + } + }, + "language": { + "name": "Langue", + "desc": "Changer la langue d'affichage de l'interface du plugin" + }, + "codex": { + "enableProvider": { + "name": "Activer le fournisseur Codex", + "desc": "Lorsque cette option est activée, les modèles Codex apparaissent dans le sélecteur de modèles des nouvelles conversations. Les sessions Codex existantes sont conservées." + }, + "installationMethod": { + "name": "Méthode d’installation", + "desc": "Comment Claudian doit lancer Codex sous Windows. Windows natif utilise un chemin d’exécutable Windows. WSL lance la CLI Linux dans une distribution choisie.", + "nativeWindows": "Windows natif", + "wsl": "WSL" + }, + "cliPath": { + "name": "Chemin de la CLI Codex", + "descUnix": "Chemin personnalisé vers la CLI Codex locale. Laissez vide pour préférer les installations Codex connues, puis PATH.", + "descWindows": "Chemin personnalisé vers la CLI Codex locale. Laissez vide pour détecter via PATH. Utilisez le chemin de l’exécutable Windows natif, généralement `codex.exe`.", + "descWsl": "Commande Codex côté Linux ou chemin absolu à exécuter dans WSL. Laissez vide pour rechercher dans PATH dans la distribution choisie.", + "validation": { + "wslWindowsPath": "Le mode WSL attend une commande Linux ou un chemin absolu Linux, pas un chemin d’exécutable Windows." + } + }, + "wslDistroOverride": { + "name": "Remplacement de distribution WSL", + "desc": "Remplacement avancé facultatif. Laissez vide pour déduire la distribution depuis un chemin d’espace de travail WSL si possible, sinon utiliser la distribution WSL par défaut." + }, + "safeMode": { + "workspaceWrite": "Écriture dans l’espace de travail", + "readOnly": "Lecture seule" + }, + "customModels": { + "name": "Modèles personnalisés", + "desc": "Ajoutez des IDs de modèles Codex au sélecteur, un par ligne. `OPENAI_MODEL` reste prioritaire lorsqu’il est défini.", + "placeholder": "gpt-5.4\ngpt-5.3-codex-spark" + }, + "reasoningSummary": { + "name": "Résumé du raisonnement", + "desc": "Affiche un résumé du processus de raisonnement du modèle dans le bloc de réflexion.", + "auto": "Auto", + "concise": "Concise", + "detailed": "Détaillée", + "off": "Désactivé" + }, + "skills": { + "name": "Compétences Codex", + "desc": "Gérez les compétences Codex de niveau vault stockées dans .codex/skills/ ou .agents/skills/. Les compétences de niveau home sont exclues ici.", + "hiddenName": "Compétences masquées", + "hiddenDesc": "Masquez certaines compétences Codex dans le menu déroulant. Entrez les noms sans le $ initial, un par ligne.", + "hiddenPlaceholder": "analyze\nexplain\nfix" + }, + "subagents": { + "name": "Sous-agents Codex", + "desc": "Gérez les sous-agents Codex de niveau vault stockés dans .codex/agents/. Chaque fichier TOML définit un agent personnalisé." + }, + "mcp": { + "descBeforeCommand": "Codex gère les serveurs MCP via sa propre CLI. Configurez avec ", + "descAfterCommand": " et ils seront disponibles dans Claudian. ", + "learnMore": "En savoir plus" + }, + "environment": { + "name": "Environnement Codex", + "desc": "Variables d’exécution appartenant uniquement à Codex. Utilisez ceci pour les réglages OPENAI_* et CODEX_*. Si l’auto-détection de Codex a besoin d’aide, ajoutez son répertoire d’installation au PATH partagé plutôt qu’à cette section fournisseur." + } + }, + "codexSkills": { + "modal": { + "titleEdit": "Modifier la compétence Codex", + "titleAdd": "Ajouter une compétence Codex", + "directory": "Répertoire", + "directoryDesc": "Où stocker la compétence", + "skillName": "Nom de la compétence", + "skillNameDesc": "Le nom utilisé après $ (par ex. \"analyze\" pour $analyze)", + "description": "Description", + "descriptionDesc": "Description facultative affichée dans le menu déroulant", + "instructions": "Instructions", + "instructionsDesc": "Instructions de la compétence (contenu de SKILL.md)", + "instructionsPlaceholder": "Analyze the code for..." + }, + "instructionsRequired": "Les instructions sont obligatoires", + "saveFailed": "Échec de l’enregistrement de la compétence Codex", + "header": "Compétences Codex", + "noSkills": "Aucune compétence Codex dans le vault. Cliquez sur + pour en créer une.", + "skillBadge": "compétence", + "deleted": "Compétence Codex \"{name}\" supprimée", + "deleteFailed": "Échec de la suppression de la compétence Codex", + "created": "Compétence Codex \"{name}\" créée", + "updated": "Compétence Codex \"{name}\" mise à jour" + }, + "codexSubagents": { + "modal": { + "titleEdit": "Modifier le sous-agent Codex", + "titleAdd": "Ajouter un sous-agent Codex", + "nameDesc": "Nom d’agent utilisé par Codex au lancement (minuscules, traits d’union, underscores)", + "descriptionDesc": "Quand Codex doit utiliser cet agent", + "modelDesc": "Remplacement de modèle (vide = hériter)", + "namePlaceholder": "code_reviewer" + }, + "reasoningEffort": { + "name": "Effort de raisonnement", + "desc": "Niveau d’effort de raisonnement du modèle", + "inherit": "Hériter", + "low": "Faible", + "medium": "Moyen", + "high": "Élevé", + "xhigh": "Très élevé" + }, + "sandboxMode": { + "name": "Mode sandbox", + "desc": "Restriction sandbox pour cet agent", + "inherit": "Hériter", + "readOnly": "Lecture seule", + "dangerFullAccess": "Accès complet", + "workspaceWrite": "Écriture dans l’espace de travail" + }, + "nicknameCandidates": { + "name": "Surnoms candidats", + "desc": "Surnoms d’affichage séparés par des virgules (ex. atlas, delta, echo)" + }, + "developerInstructions": { + "name": "Instructions développeur", + "desc": "Instructions centrales qui définissent le comportement de l’agent", + "placeholder": "Review code like an owner.\nPrioritize correctness, security, and missing test coverage.", + "required": "Les instructions développeur sont obligatoires" + }, + "validation": { + "nameRequired": "Le nom du sous-agent est obligatoire", + "nameTooLong": "Le nom du sous-agent doit contenir {count} caractères ou moins", + "nameInvalid": "Le nom du sous-agent ne peut contenir que des minuscules, chiffres, traits d’union et underscores", + "nicknameInvalid": "Les surnoms candidats ne peuvent contenir que des lettres ASCII, chiffres, espaces, traits d’union et underscores", + "nicknameDuplicate": "Les surnoms candidats doivent être uniques" + }, + "header": "Sous-agents Codex", + "noAgents": "Aucun sous-agent Codex dans le vault. Cliquez sur + pour en créer un." + } + } +} diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json new file mode 100644 index 0000000..e90b786 --- /dev/null +++ b/src/i18n/locales/ja.json @@ -0,0 +1,446 @@ +{ + "common": { + "save": "保存", + "cancel": "キャンセル", + "delete": "削除", + "edit": "編集", + "add": "追加", + "remove": "削除", + "clear": "クリア", + "clearAll": "すべてクリア", + "loading": "読み込み中", + "error": "エラー", + "success": "成功", + "warning": "警告", + "confirm": "確認", + "settings": "設定", + "advanced": "詳細", + "enabled": "有効", + "disabled": "無効", + "platform": "プラットフォーム", + "refresh": "更新", + "rewind": "巻き戻し" + }, + "chat": { + "rewind": { + "confirmMessage": "この時点に巻き戻しますか?このメッセージ以降のファイル変更が元に戻されます。手動またはbashで編集されたファイルには影響しません。", + "confirmMessageConversationOnly": "会話をこの時点に巻き戻しますか?ファイル変更は保持されます。", + "confirmButton": "巻き戻す", + "ariaLabel": "ここに巻き戻す", + "menuConversationOnly": "会話のみ巻き戻す", + "menuCodeAndConversation": "コード + 会話を巻き戻す", + "notice": "巻き戻し完了:{count} 個のファイルを復元", + "noticeConversationOnly": "会話を巻き戻しました。ファイル変更は保持されました", + "noticeSaveFailed": "巻き戻し完了:{count} 個のファイルを復元しましたが、状態を保存できませんでした:{error}", + "noticeConversationOnlySaveFailed": "会話を巻き戻しましたが、状態を保存できませんでした:{error}", + "failed": "巻き戻しに失敗:{error}", + "cannot": "巻き戻しできません:{error}", + "unavailableStreaming": "ストリーミング中は巻き戻しできません", + "unavailableNoUuid": "巻き戻しできません:メッセージ識別子がありません" + }, + "fork": { + "ariaLabel": "会話を分岐", + "chooseTarget": "会話を分岐", + "targetNewTab": "新しいタブ", + "targetCurrentTab": "現在のタブ", + "maxTabsReached": "分岐できません:最大 {count} タブに達しました", + "notice": "新しいタブに分岐しました", + "noticeCurrentTab": "現在のタブで分岐しました", + "failed": "分岐に失敗:{error}", + "unavailableStreaming": "ストリーミング中は分岐できません", + "unavailableNoUuid": "分岐できません:メッセージ識別子がありません", + "unavailableNoResponse": "分岐できません:分岐元の応答がありません", + "errorMessageNotFound": "メッセージが見つかりません", + "errorNoSession": "セッション ID がありません", + "errorNoActiveTab": "アクティブなタブがありません", + "commandNoMessages": "フォークできません: 会話にメッセージがありません", + "commandNoAssistantUuid": "フォークできません: 識別子付きのアシスタント応答がありません" + }, + "bangBash": { + "placeholder": "> bash コマンドを実行...", + "commandPanel": "コマンドパネル", + "copyAriaLabel": "最新のコマンド出力をコピー", + "clearAriaLabel": "bash 出力をクリア", + "commandLabel": "{command}", + "statusLabel": "状態: {status}", + "collapseOutput": "コマンド出力を折りたたむ", + "expandOutput": "コマンド出力を展開", + "running": "実行中...", + "copyFailed": "クリップボードへのコピーに失敗しました" + } + }, + "settings": { + "title": "Claudian 設定", + "tabs": { + "general": "一般", + "claude": "Claude", + "codex": "Codex" + }, + "display": "表示", + "conversations": "会話", + "content": "コンテンツ", + "input": "入力", + "setup": "セットアップ", + "models": "モデル", + "experimental": "実験的機能", + "userName": { + "name": "Claudian はどのように呼びますか?", + "desc": "パーソナライズされた挨拶に使用する名前(空欄で一般の挨拶)" + }, + "excludedTags": { + "name": "除外タグ", + "desc": "これらのタグを含むノートは自動的にコンテキストとして読み込まれません(1行に1つ、#なし)" + }, + "mediaFolder": { + "name": "メディアフォルダ", + "desc": "添付ファイル/画像を格納するフォルダ。ノートが ![[image.jpg]] を使用する場合、Claude はここで探します。空欄でリポジトリのルートを使用。" + }, + "systemPrompt": { + "name": "カスタムシステムプロンプト", + "desc": "デフォルトのシステムプロンプトに追加される追加指示" + }, + "autoTitle": { + "name": "会話タイトルを自動生成", + "desc": "最初のユーザーメッセージ送信後に会話タイトルを自動的に生成します。" + }, + "titleModel": { + "name": "タイトル生成モデル", + "desc": "会話タイトルを自動生成するために使用されるモデル。", + "auto": "自動 (Haiku)" + }, + "navMappings": { + "name": "Vimスタイルナビゲーションマッピング", + "desc": "1行に1つのマッピング。形式:\"map <キー> <アクション>\"(アクション:scrollUp, scrollDown, focusInput)。" + }, + "requireCommandOrControlEnterToSend": { + "name": "送信に Command/Ctrl+Enter を必須にする", + "desc": "有効にすると、Enter は改行を挿入します。macOS では Command+Enter、Windows と Linux では Ctrl+Enter で送信します。" + }, + "hotkeys": "ホットキー", + "inlineEditHotkey": { + "name": "インライン編集", + "descWithKey": "現在のホットキー: {hotkey}", + "descNoKey": "ホットキー未設定", + "btnChange": "変更", + "btnSet": "設定" + }, + "openChatHotkey": { + "name": "チャットを開く", + "descWithKey": "現在のホットキー: {hotkey}", + "descNoKey": "ホットキー未設定", + "btnChange": "変更", + "btnSet": "設定" + }, + "newSessionHotkey": { + "name": "新規セッション", + "descWithKey": "現在のホットキー: {hotkey}", + "descNoKey": "ホットキー未設定", + "btnChange": "変更", + "btnSet": "設定" + }, + "newTabHotkey": { + "name": "新規タブ", + "descWithKey": "現在のホットキー: {hotkey}", + "descNoKey": "ホットキー未設定", + "btnChange": "変更", + "btnSet": "設定" + }, + "closeTabHotkey": { + "name": "タブを閉じる", + "descWithKey": "現在のホットキー: {hotkey}", + "descNoKey": "ホットキー未設定", + "btnChange": "変更", + "btnSet": "設定" + }, + "slashCommands": { + "name": "コマンドとスキル", + "desc": ".claude/commands/ と .claude/skills/ に保存された Vault レベルのコマンドとスキルを管理します。/名前 でトリガーされます。" + }, + "hiddenSlashCommands": { + "name": "非表示のコマンドとスキル", + "desc": "ドロップダウンから特定のコマンドとスキルを非表示にします。Claudian に関係のない Claude Code の項目を隠すのに便利です。先頭のスラッシュなしで名前を1行に1つ入力してください。", + "placeholder": "commit\nbuild\ntest" + }, + "mcpServers": { + "name": "MCP サーバー", + "desc": ".claude/mcp.json に保存された Vault レベルの MCP サーバー設定を管理します。コンテキスト保存モードのサーバーは @mention でアクティブにする必要があります。" + }, + "plugins": { + "name": "Claude Code プラグイン", + "desc": "~/.claude/plugins から検出された Claude Code プラグインを管理します。有効化されたプラグインは Vault ごとに .claude/settings.json に保存されます。" + }, + "subagents": { + "name": "サブエージェント", + "desc": ".claude/agents/ に保存された Vault レベルのサブエージェントを管理します。各 Markdown ファイルがカスタムエージェントを1つ定義します。", + "noAgents": "サブエージェントが設定されていません。+ をクリックして作成してください。", + "deleteConfirm": "サブエージェント「{name}」を削除しますか?", + "saveFailed": "サブエージェントの保存に失敗しました: {message}", + "refreshFailed": "サブエージェントを更新できませんでした: {message}", + "deleteFailed": "サブエージェントの削除に失敗しました: {message}", + "renameCleanupFailed": "警告: 「{name}」の古いファイルを削除できませんでした", + "created": "サブエージェント「{name}」を作成しました", + "updated": "サブエージェント「{name}」を更新しました", + "deleted": "サブエージェント「{name}」を削除しました", + "duplicateName": "「{name}」という名前のエージェントは既に存在します", + "descriptionRequired": "説明は必須です", + "promptRequired": "システムプロンプトは必須です", + "modal": { + "titleEdit": "サブエージェントを編集", + "titleAdd": "サブエージェントを追加", + "name": "名前", + "nameDesc": "小文字、数字、ハイフンのみ使用できます", + "namePlaceholder": "code-reviewer", + "description": "説明", + "descriptionDesc": "このエージェントの簡単な説明", + "descriptionPlaceholder": "コードのバグやスタイルをレビューします", + "advancedOptions": "詳細オプション", + "model": "モデル", + "modelDesc": "このエージェントのモデル上書き", + "tools": "ツール", + "toolsDesc": "許可するツールのカンマ区切りリスト(空欄 = すべて)", + "disallowedTools": "禁止ツール", + "disallowedToolsDesc": "禁止するツールのカンマ区切りリスト", + "skills": "スキル", + "skillsDesc": "スキルのカンマ区切りリスト", + "prompt": "システムプロンプト", + "promptDesc": "エージェントへの指示", + "promptPlaceholder": "あなたはコードレビュアーです。与えられたコードを分析して..." + } + }, + "safety": "セキュリティ", + "loadUserSettings": { + "name": "ユーザーClaude設定を読み込む", + "desc": "~/.claude/settings.json を読み込みます。有効にすると、ユーザーの Claude Code 許可ルールがセキュリティモードをバイパスする可能性があります。" + }, + "claudeSafeMode": { + "name": "セーフモード", + "desc": "Safe トグルが有効なときに使用する権限モードです。" + }, + "codexSafeMode": { + "name": "セーフモード", + "desc": "Safe トグルが有効なときに使用するサンドボックスモードです。" + }, + "environment": "環境", + "customVariables": { + "name": "カスタム変数", + "desc": "Claude SDKの環境変数(KEY=VALUE形式、1行に1つ)。exportプレフィックス対応。" + }, + "envSnippets": { + "name": "スニペット", + "addBtn": "スニペットを追加", + "noSnippets": "保存された環境変数スニペットはありません。+をクリックして現在の設定を保存してください。", + "nameRequired": "スニペットの名前を入力してください", + "modal": { + "titleEdit": "スニペットを編集", + "titleSave": "スニペットを保存", + "name": "名前", + "namePlaceholder": "この設定のわかりやすい名前", + "description": "説明", + "descPlaceholder": "任意の説明", + "envVars": "環境変数", + "envVarsPlaceholder": "KEY=VALUE形式、1行に1つ(exportプレフィックス対応)", + "save": "保存", + "update": "更新", + "cancel": "キャンセル" + } + }, + "customModelOverrides": { + "name": "カスタムモデルの上書き", + "desc": "カスタムモデルのモデルセレクター表示名とコンテキストウィンドウサイズを設定します。" + }, + "customModelAliases": { + "placeholder": "エイリアス" + }, + "customContextLimits": { + "name": "カスタムコンテキスト制限", + "desc": "カスタムモデルのコンテキストウィンドウサイズを設定します。デフォルト(200kトークン)を使用する場合は空欄のままにしてください。", + "invalid": "無効な形式です。使用:256k、1m、または正確な数値(1000-10000000)。" + }, + "customModels": { + "name": "カスタムモデル", + "desc": "追加の Claude モデル ID を1行に1つずつピッカーに追加します。環境変数によるモデル上書きは引き続きピッカーを置き換えます。", + "placeholder": "claude-opus-4-6\nclaude-opus-4-5-20251101" + }, + "enableChrome": { + "name": "Chrome拡張機能を有効化", + "desc": "claude-in-chrome拡張機能を通じてClaudeがChromeと連携できるようにします。拡張機能のインストールが必要です。セッションの再起動が必要です。" + }, + "enableBangBash": { + "name": "bash モード (!) を有効化", + "desc": "入力欄が空の状態で ! を入力すると bash モードに入ります。Node.js の child_process 経由でコマンドを直接実行します。ビューの再オープンが必要です。", + "validation": { + "noNode": "PATH に Node.js が見つかりません。Node.js をインストールするか、PATH 設定を確認してください。" + } + }, + "maxTabs": { + "name": "最大チャットタブ数", + "desc": "同時に開ける最大チャットタブ数(3-10)。各タブは個別の Claude セッションを使用します。", + "warning": "5 タブを超えるとパフォーマンスやメモリ使用量に影響する可能性があります。" + }, + "enableAutoScroll": { + "name": "ストリーミング中の自動スクロール", + "desc": "Claudeが応答をストリーミングしている間、自動的に下にスクロールします。無効にすると上部に留まり、最初から読むことができます。" + }, + "deferMathRenderingDuringStreaming": { + "name": "ストリーミング中の数式レンダリングを遅延", + "desc": "応答のストリーミング中は生の LaTeX を表示し、各テキストブロックの完了時に一度だけ数式をレンダリングします。" + }, + "expandFileEditsByDefault": { + "name": "ファイル編集をデフォルトで展開", + "desc": "Write と Edit の diff が最初に表示されるとき、展開した状態で表示します。" + }, + "chatViewPlacement": { + "name": "Claudian を開く場所", + "desc": "チャットパネルを作成するときに開く場所を選択します", + "rightSidebar": "右サイドバー", + "leftSidebar": "左サイドバー", + "mainTab": "メインエディタタブ" + }, + "cliPath": { + "name": "Claude CLI パス", + "desc": "Claude Code CLI のカスタムパス。空欄で自動検出を使用。", + "descWindows": "ネイティブインストーラーの場合は claude.exe を使用。npm/pnpm/yarn やその他のパッケージマネージャーでのインストールの場合は cli-wrapper.cjs パスを使用(claude.cmd ではない)。", + "descUnix": "\"which claude\" の出力を貼り付けてください - ネイティブと npm/pnpm/yarn インストールの両方で動作します。", + "validation": { + "notExist": "パスが存在しません", + "isDirectory": "パスはディレクトリでファイルではありません" + } + }, + "language": { + "name": "言語", + "desc": "プラグインインターフェースの表示言語を変更" + }, + "codex": { + "enableProvider": { + "name": "Codex プロバイダーを有効化", + "desc": "有効にすると、新しい会話のモデルセレクターに Codex モデルが表示されます。既存の Codex セッションは保持されます。" + }, + "installationMethod": { + "name": "インストール方式", + "desc": "Windows で Claudian が Codex を起動する方法。ネイティブ Windows は Windows 実行ファイルパスを使い、WSL は選択したディストリビューション内で Linux CLI を起動します。", + "nativeWindows": "ネイティブ Windows", + "wsl": "WSL" + }, + "cliPath": { + "name": "Codex CLI パス", + "descUnix": "ローカル Codex CLI のカスタムパス。空欄の場合は既知の Codex インストール、次に PATH を優先します。", + "descWindows": "ローカル Codex CLI のカスタムパス。空欄の場合は PATH から自動検出します。通常は `codex.exe` のネイティブ Windows 実行ファイルパスを使用してください。", + "descWsl": "WSL 内で実行する Linux 側の Codex コマンドまたは絶対パス。空欄の場合は選択したディストリビューション内の PATH を検索します。", + "validation": { + "wslWindowsPath": "WSL モードでは Windows 実行ファイルパスではなく、Linux コマンドまたは Linux 絶対パスが必要です。" + } + }, + "wslDistroOverride": { + "name": "WSL ディストリビューション上書き", + "desc": "任意の詳細上書き。空欄の場合、可能なら WSL ワークスペースパスから推測し、それ以外は既定の WSL ディストリビューションを使用します。" + }, + "safeMode": { + "workspaceWrite": "ワークスペース書き込み", + "readOnly": "読み取り専用" + }, + "customModels": { + "name": "カスタムモデル", + "desc": "追加の Codex モデル ID を1行に1つずつピッカーに追加します。`OPENAI_MODEL` が設定されている場合は引き続き優先されます。", + "placeholder": "gpt-5.4\ngpt-5.3-codex-spark" + }, + "reasoningSummary": { + "name": "推論サマリー", + "desc": "思考ブロックにモデルの推論プロセスのサマリーを表示します。", + "auto": "自動", + "concise": "簡潔", + "detailed": "詳細", + "off": "オフ" + }, + "skills": { + "name": "Codex スキル", + "desc": ".codex/skills/ または .agents/skills/ に保存された Vault レベルの Codex スキルを管理します。ホームレベルのスキルはここでは除外されます。", + "hiddenName": "非表示のスキル", + "hiddenDesc": "特定の Codex スキルをドロップダウンから非表示にします。先頭の $ なしでスキル名を1行に1つ入力してください。", + "hiddenPlaceholder": "analyze\nexplain\nfix" + }, + "subagents": { + "name": "Codex サブエージェント", + "desc": ".codex/agents/ に保存された Vault レベルの Codex サブエージェントを管理します。各 TOML ファイルがカスタムエージェントを1つ定義します。" + }, + "mcp": { + "descBeforeCommand": "Codex は独自の CLI で MCP サーバーを管理します。", + "descAfterCommand": " で設定すると Claudian で利用できます。", + "learnMore": "詳しく見る" + }, + "environment": { + "name": "Codex 環境", + "desc": "Codex が所有するランタイム変数のみ。OPENAI_* と CODEX_* 設定に使用します。Codex の自動検出に補助が必要な場合は、このプロバイダー環境ではなく共有 PATH にインストールディレクトリを追加してください。" + } + }, + "codexSkills": { + "modal": { + "titleEdit": "Codex スキルを編集", + "titleAdd": "Codex スキルを追加", + "directory": "ディレクトリ", + "directoryDesc": "スキルの保存先", + "skillName": "スキル名", + "skillNameDesc": "$ の後に使う名前(例: $analyze の場合は \"analyze\")", + "description": "説明", + "descriptionDesc": "ドロップダウンに表示される任意の説明", + "instructions": "指示", + "instructionsDesc": "スキルの指示(SKILL.md の内容)", + "instructionsPlaceholder": "Analyze the code for..." + }, + "instructionsRequired": "指示は必須です", + "saveFailed": "Codex スキルの保存に失敗しました", + "header": "Codex スキル", + "noSkills": "Vault に Codex スキルがありません。+ をクリックして作成してください。", + "skillBadge": "スキル", + "deleted": "Codex スキル「{name}」を削除しました", + "deleteFailed": "Codex スキルの削除に失敗しました", + "created": "Codex スキル「{name}」を作成しました", + "updated": "Codex スキル「{name}」を更新しました" + }, + "codexSubagents": { + "modal": { + "titleEdit": "Codex サブエージェントを編集", + "titleAdd": "Codex サブエージェントを追加", + "nameDesc": "Codex が起動時に使用するエージェント名(小文字、ハイフン、アンダースコア)", + "descriptionDesc": "Codex がこのエージェントを使うべきタイミング", + "modelDesc": "モデル上書き(空欄で継承)", + "namePlaceholder": "code_reviewer" + }, + "reasoningEffort": { + "name": "推論強度", + "desc": "モデル推論強度レベル", + "inherit": "継承", + "low": "低", + "medium": "中", + "high": "高", + "xhigh": "非常に高い" + }, + "sandboxMode": { + "name": "サンドボックスモード", + "desc": "このエージェントのサンドボックス制限", + "inherit": "継承", + "readOnly": "読み取り専用", + "dangerFullAccess": "完全アクセス", + "workspaceWrite": "ワークスペース書き込み" + }, + "nicknameCandidates": { + "name": "ニックネーム候補", + "desc": "表示ニックネームをカンマ区切りで指定(例: atlas, delta, echo)" + }, + "developerInstructions": { + "name": "開発者指示", + "desc": "エージェントの動作を定義する中核の指示", + "placeholder": "Review code like an owner.\nPrioritize correctness, security, and missing test coverage.", + "required": "開発者指示は必須です" + }, + "validation": { + "nameRequired": "サブエージェント名は必須です", + "nameTooLong": "サブエージェント名は {count} 文字以内にしてください", + "nameInvalid": "サブエージェント名には小文字、数字、ハイフン、アンダースコアのみ使用できます", + "nicknameInvalid": "ニックネーム候補には ASCII 文字、数字、スペース、ハイフン、アンダースコアのみ使用できます", + "nicknameDuplicate": "ニックネーム候補は一意である必要があります" + }, + "header": "Codex サブエージェント", + "noAgents": "Vault に Codex サブエージェントがありません。+ をクリックして作成してください。" + } + } +} diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json new file mode 100644 index 0000000..f45311a --- /dev/null +++ b/src/i18n/locales/ko.json @@ -0,0 +1,446 @@ +{ + "common": { + "save": "저장", + "cancel": "취소", + "delete": "삭제", + "edit": "편집", + "add": "추가", + "remove": "제거", + "clear": "지우기", + "clearAll": "모두 지우기", + "loading": "로딩 중", + "error": "오류", + "success": "성공", + "warning": "경고", + "confirm": "확인", + "settings": "설정", + "advanced": "고급", + "enabled": "활성화", + "disabled": "비활성화", + "platform": "플랫폼", + "refresh": "새로고침", + "rewind": "되감기" + }, + "chat": { + "rewind": { + "confirmMessage": "이 시점으로 되감으시겠습니까? 이 메시지 이후의 파일 변경 사항이 되돌려집니다. 수동으로 또는 bash를 통해 편집된 파일에는 영향을 미치지 않습니다.", + "confirmMessageConversationOnly": "대화를 이 시점으로 되감으시겠습니까? 파일 변경 사항은 유지됩니다.", + "confirmButton": "되감기", + "ariaLabel": "여기로 되감기", + "menuConversationOnly": "대화만 되감기", + "menuCodeAndConversation": "코드 + 대화 되감기", + "notice": "되감기 완료: {count}개 파일 복원됨", + "noticeConversationOnly": "대화를 되감았습니다. 파일 변경 사항은 유지되었습니다", + "noticeSaveFailed": "되감기 완료: {count}개 파일 복원됨, 하지만 상태를 저장하지 못했습니다: {error}", + "noticeConversationOnlySaveFailed": "대화를 되감았지만 상태를 저장하지 못했습니다: {error}", + "failed": "되감기 실패: {error}", + "cannot": "되감기 불가: {error}", + "unavailableStreaming": "스트리밍 중에는 되감기할 수 없습니다", + "unavailableNoUuid": "되감기 불가: 메시지 식별자 누락" + }, + "fork": { + "ariaLabel": "대화 분기", + "chooseTarget": "대화 분기", + "targetNewTab": "새 탭", + "targetCurrentTab": "현재 탭", + "maxTabsReached": "분기 불가: 최대 {count}개 탭에 도달했습니다", + "notice": "새 탭으로 분기됨", + "noticeCurrentTab": "현재 탭에서 분기됨", + "failed": "분기 실패: {error}", + "unavailableStreaming": "스트리밍 중에는 분기할 수 없습니다", + "unavailableNoUuid": "분기 불가: 메시지 식별자 누락", + "unavailableNoResponse": "분기 불가: 분기할 응답이 없습니다", + "errorMessageNotFound": "메시지를 찾을 수 없습니다", + "errorNoSession": "사용 가능한 세션 ID가 없습니다", + "errorNoActiveTab": "활성 탭이 없습니다", + "commandNoMessages": "포크할 수 없습니다: 대화에 메시지가 없습니다", + "commandNoAssistantUuid": "포크할 수 없습니다: 식별자가 있는 어시스턴트 응답이 없습니다" + }, + "bangBash": { + "placeholder": "> bash 명령 실행...", + "commandPanel": "명령 패널", + "copyAriaLabel": "최신 명령 출력을 복사", + "clearAriaLabel": "bash 출력 지우기", + "commandLabel": "{command}", + "statusLabel": "상태: {status}", + "collapseOutput": "명령 출력 접기", + "expandOutput": "명령 출력 펼치기", + "running": "실행 중...", + "copyFailed": "클립보드에 복사하지 못했습니다" + } + }, + "settings": { + "title": "Claudian 설정", + "tabs": { + "general": "일반", + "claude": "Claude", + "codex": "Codex" + }, + "display": "표시", + "conversations": "대화", + "content": "콘텐츠", + "input": "입력", + "setup": "설정", + "models": "모델", + "experimental": "실험적 기능", + "userName": { + "name": "Claudian이 당신을 어떻게 불러야 합니까?", + "desc": "개인화된 인사에 사용할 이름 (비워두면 일반 인사)" + }, + "excludedTags": { + "name": "제외 태그", + "desc": "이 태그가 포함된 노트는 자동으로 컨텍스트로 로드되지 않습니다 (한 줄에 하나, # 제외)" + }, + "mediaFolder": { + "name": "미디어 폴더", + "desc": "첨부 파일/이미지를 저장할 폴더. 노트가 ![[image.jpg]]를 사용할 때 Claude가 여기서 찾습니다. 비워두면 저장소 루트 사용." + }, + "systemPrompt": { + "name": "커스텀 시스템 프롬프트", + "desc": "기본 시스템 프롬프트에 추가되는 추가 지침" + }, + "autoTitle": { + "name": "대화 제목 자동 생성", + "desc": "첫 번째 사용자 메시지 전송 후 자동으로 대화 제목을 생성합니다." + }, + "titleModel": { + "name": "제목 생성 모델", + "desc": "대화 제목을 자동 생성하는 데 사용되는 모델.", + "auto": "자동 (Haiku)" + }, + "navMappings": { + "name": "Vim 스타일 네비게이션 매핑", + "desc": "한 줄에 하나의 매핑. 형식: \"map <키> <동작>\" (동작: scrollUp, scrollDown, focusInput)." + }, + "requireCommandOrControlEnterToSend": { + "name": "Command/Ctrl+Enter로 보내기 필수", + "desc": "활성화하면 Enter는 줄바꿈을 삽입합니다. macOS에서는 Command+Enter, Windows와 Linux에서는 Ctrl+Enter로 보냅니다." + }, + "hotkeys": "단축키", + "inlineEditHotkey": { + "name": "인라인 편집", + "descWithKey": "현재 단축키: {hotkey}", + "descNoKey": "단축키 미설정", + "btnChange": "변경", + "btnSet": "단축키 설정" + }, + "openChatHotkey": { + "name": "채팅 열기", + "descWithKey": "현재 단축키: {hotkey}", + "descNoKey": "단축키 미설정", + "btnChange": "변경", + "btnSet": "단축키 설정" + }, + "newSessionHotkey": { + "name": "새 세션", + "descWithKey": "현재 단축키: {hotkey}", + "descNoKey": "단축키 미설정", + "btnChange": "변경", + "btnSet": "단축키 설정" + }, + "newTabHotkey": { + "name": "새 탭", + "descWithKey": "현재 단축키: {hotkey}", + "descNoKey": "단축키 미설정", + "btnChange": "변경", + "btnSet": "단축키 설정" + }, + "closeTabHotkey": { + "name": "탭 닫기", + "descWithKey": "현재 단축키: {hotkey}", + "descNoKey": "단축키 미설정", + "btnChange": "변경", + "btnSet": "단축키 설정" + }, + "slashCommands": { + "name": "명령어와 스킬", + "desc": ".claude/commands/ 및 .claude/skills/에 저장된 Vault 수준의 명령어와 스킬을 관리합니다. /이름으로 트리거됩니다." + }, + "hiddenSlashCommands": { + "name": "숨겨진 명령어와 스킬", + "desc": "드롭다운에서 특정 명령어와 스킬을 숨깁니다. Claudian과 관련 없는 Claude Code 항목을 숨기는 데 유용합니다. 앞의 슬래시 없이 이름을 한 줄에 하나씩 입력하세요.", + "placeholder": "commit\nbuild\ntest" + }, + "mcpServers": { + "name": "MCP 서버", + "desc": ".claude/mcp.json에 저장된 Vault 수준의 MCP 서버 구성을 관리합니다. 컨텍스트 저장 모드 서버는 @mention으로 활성화해야 합니다." + }, + "plugins": { + "name": "Claude Code 플러그인", + "desc": "~/.claude/plugins에서 발견된 Claude Code 플러그인을 관리합니다. 활성화된 플러그인은 .claude/settings.json에 Vault별로 저장됩니다." + }, + "subagents": { + "name": "서브에이전트", + "desc": ".claude/agents/에 저장된 Vault 수준의 서브에이전트를 관리합니다. 각 Markdown 파일이 하나의 커스텀 에이전트를 정의합니다.", + "noAgents": "구성된 서브에이전트가 없습니다. +를 클릭해 새로 만드세요.", + "deleteConfirm": "서브에이전트 \"{name}\"을 삭제하시겠습니까?", + "saveFailed": "서브에이전트를 저장하지 못했습니다: {message}", + "refreshFailed": "서브에이전트를 새로고침하지 못했습니다: {message}", + "deleteFailed": "서브에이전트를 삭제하지 못했습니다: {message}", + "renameCleanupFailed": "경고: \"{name}\"의 이전 파일을 제거하지 못했습니다", + "created": "서브에이전트 \"{name}\"를 생성했습니다", + "updated": "서브에이전트 \"{name}\"를 업데이트했습니다", + "deleted": "서브에이전트 \"{name}\"를 삭제했습니다", + "duplicateName": "\"{name}\"이라는 이름의 에이전트가 이미 있습니다", + "descriptionRequired": "설명은 필수입니다", + "promptRequired": "시스템 프롬프트는 필수입니다", + "modal": { + "titleEdit": "서브에이전트 편집", + "titleAdd": "서브에이전트 추가", + "name": "이름", + "nameDesc": "소문자, 숫자, 하이픈만 사용할 수 있습니다", + "namePlaceholder": "code-reviewer", + "description": "설명", + "descriptionDesc": "이 에이전트에 대한 간단한 설명", + "descriptionPlaceholder": "코드의 버그와 스타일을 검토합니다", + "advancedOptions": "고급 옵션", + "model": "모델", + "modelDesc": "이 에이전트에 사용할 모델 재정의", + "tools": "도구", + "toolsDesc": "허용할 도구를 쉼표로 구분해 입력하세요 (비워두면 모두 허용)", + "disallowedTools": "금지 도구", + "disallowedToolsDesc": "금지할 도구를 쉼표로 구분해 입력하세요", + "skills": "스킬", + "skillsDesc": "스킬 목록을 쉼표로 구분해 입력하세요", + "prompt": "시스템 프롬프트", + "promptDesc": "에이전트용 지침", + "promptPlaceholder": "당신은 코드 리뷰어입니다. 주어진 코드를 분석하여..." + } + }, + "safety": "보안", + "loadUserSettings": { + "name": "사용자 Claude 설정 로드", + "desc": "~/.claude/settings.json을 로드합니다. 활성화하면 사용자의 Claude Code 허용 규칙이 보안 모드를 우회할 수 있습니다." + }, + "claudeSafeMode": { + "name": "안전 모드", + "desc": "Safe 토글이 활성화되었을 때 사용하는 권한 모드입니다." + }, + "codexSafeMode": { + "name": "안전 모드", + "desc": "Safe 토글이 활성화되었을 때 사용하는 샌드박스 모드입니다." + }, + "environment": "환경", + "customVariables": { + "name": "커스텀 변수", + "desc": "Claude SDK 환경 변수 (KEY=VALUE 형식, 한 줄에 하나). export 접두사 지원." + }, + "envSnippets": { + "name": "스니펫", + "addBtn": "스니펫 추가", + "noSnippets": "저장된 환경 변수 스니펫이 없습니다. +를 클릭하여 현재 구성을 저장하세요.", + "nameRequired": "스니펫 이름을 입력하세요", + "modal": { + "titleEdit": "스니펫 편집", + "titleSave": "스니펫 저장", + "name": "이름", + "namePlaceholder": "이 구성에 대한 설명적인 이름", + "description": "설명", + "descPlaceholder": "선택적 설명", + "envVars": "환경 변수", + "envVarsPlaceholder": "KEY=VALUE 형식, 한 줄에 하나 (export 접두사 지원)", + "save": "저장", + "update": "업데이트", + "cancel": "취소" + } + }, + "customModelOverrides": { + "name": "사용자 정의 모델 재정의", + "desc": "사용자 정의 모델의 모델 선택기 별칭과 컨텍스트 창 크기를 설정합니다." + }, + "customModelAliases": { + "placeholder": "별칭" + }, + "customContextLimits": { + "name": "사용자 정의 컨텍스트 제한", + "desc": "사용자 정의 모델의 컨텍스트 창 크기를 설정합니다. 기본값(200k 토큰)을 사용하려면 비워두세요.", + "invalid": "잘못된 형식입니다. 사용: 256k, 1m 또는 정확한 숫자(1000-10000000)." + }, + "customModels": { + "name": "사용자 지정 모델", + "desc": "추가 Claude 모델 ID를 한 줄에 하나씩 선택기에 추가합니다. 환경 모델 재정의는 계속 선택기를 대체합니다.", + "placeholder": "claude-opus-4-6\nclaude-opus-4-5-20251101" + }, + "enableChrome": { + "name": "Chrome 확장 프로그램 활성화", + "desc": "claude-in-chrome 확장 프로그램을 통해 Claude가 Chrome과 상호작용할 수 있도록 합니다. 확장 프로그램이 설치되어 있어야 합니다. 세션 재시작이 필요합니다." + }, + "enableBangBash": { + "name": "bash 모드 (!) 활성화", + "desc": "입력창이 비어 있을 때 !를 입력하면 bash 모드로 들어갑니다. Node.js child_process를 통해 명령을 직접 실행합니다. 보기를 다시 열어야 합니다.", + "validation": { + "noNode": "PATH에서 Node.js를 찾을 수 없습니다. Node.js를 설치하거나 PATH 설정을 확인하세요." + } + }, + "maxTabs": { + "name": "최대 채팅 탭 수", + "desc": "동시에 열 수 있는 최대 채팅 탭 수(3-10). 각 탭은 별도의 Claude 세션을 사용합니다.", + "warning": "5개 탭을 초과하면 성능 및 메모리 사용량에 영향을 줄 수 있습니다." + }, + "enableAutoScroll": { + "name": "스트리밍 중 자동 스크롤", + "desc": "Claude가 응답을 스트리밍하는 동안 자동으로 아래로 스크롤합니다. 비활성화하면 상단에 머물러 처음부터 읽을 수 있습니다." + }, + "deferMathRenderingDuringStreaming": { + "name": "스트리밍 중 수식 렌더링 지연", + "desc": "응답이 스트리밍되는 동안 원본 LaTeX를 표시하고 각 텍스트 블록이 완료되면 수식을 한 번 렌더링합니다." + }, + "expandFileEditsByDefault": { + "name": "파일 편집을 기본적으로 펼치기", + "desc": "Write 및 Edit diff가 처음 표시될 때 펼친 상태로 보여줍니다." + }, + "chatViewPlacement": { + "name": "Claudian 열 위치", + "desc": "채팅 패널이 생성될 때 열릴 위치를 선택합니다", + "rightSidebar": "오른쪽 사이드바", + "leftSidebar": "왼쪽 사이드바", + "mainTab": "메인 편집기 탭" + }, + "cliPath": { + "name": "Claude CLI 경로", + "desc": "Claude Code CLI의 사용자 정의 경로. 비워두면 자동 감지 사용.", + "descWindows": "네이티브 설치 프로그램의 경우 claude.exe를 사용하세요. npm/pnpm/yarn 또는 기타 패키지 관리자 설치의 경우 cli-wrapper.cjs 경로를 사용하세요 (claude.cmd가 아님).", + "descUnix": "\"which claude\"의 출력을 붙여넣으세요 - 네이티브 및 npm/pnpm/yarn 설치 모두에서 작동합니다.", + "validation": { + "notExist": "경로가 존재하지 않습니다", + "isDirectory": "경로가 디렉토리입니다 파일이 아닙니다" + } + }, + "language": { + "name": "언어", + "desc": "플러그인 인터페이스의 표시 언어 변경" + }, + "codex": { + "enableProvider": { + "name": "Codex 공급자 활성화", + "desc": "활성화하면 새 대화의 모델 선택기에 Codex 모델이 표시됩니다. 기존 Codex 세션은 유지됩니다." + }, + "installationMethod": { + "name": "설치 방식", + "desc": "Windows에서 Claudian이 Codex를 실행하는 방식입니다. 네이티브 Windows는 Windows 실행 파일 경로를 사용하고, WSL은 선택한 배포판 안에서 Linux CLI를 실행합니다.", + "nativeWindows": "네이티브 Windows", + "wsl": "WSL" + }, + "cliPath": { + "name": "Codex CLI 경로", + "descUnix": "로컬 Codex CLI의 사용자 지정 경로입니다. 비워 두면 알려진 Codex 설치를 먼저 사용한 뒤 PATH를 찾습니다.", + "descWindows": "로컬 Codex CLI의 사용자 지정 경로입니다. 비워 두면 PATH에서 자동 감지합니다. 일반적으로 `codex.exe`인 네이티브 Windows 실행 파일 경로를 사용하세요.", + "descWsl": "WSL 안에서 실행할 Linux 측 Codex 명령 또는 절대 경로입니다. 비워 두면 선택한 배포판의 PATH에서 찾습니다.", + "validation": { + "wslWindowsPath": "WSL 모드에는 Windows 실행 파일 경로가 아니라 Linux 명령 또는 Linux 절대 경로가 필요합니다." + } + }, + "wslDistroOverride": { + "name": "WSL 배포판 재정의", + "desc": "선택적 고급 재정의입니다. 비워 두면 가능한 경우 WSL 작업공간 경로에서 배포판을 추론하고, 그렇지 않으면 기본 WSL 배포판을 사용합니다." + }, + "safeMode": { + "workspaceWrite": "작업공간 쓰기", + "readOnly": "읽기 전용" + }, + "customModels": { + "name": "사용자 지정 모델", + "desc": "추가 Codex 모델 ID를 한 줄에 하나씩 선택기에 추가합니다. `OPENAI_MODEL`이 설정되어 있으면 계속 우선 적용됩니다.", + "placeholder": "gpt-5.4\ngpt-5.3-codex-spark" + }, + "reasoningSummary": { + "name": "추론 요약", + "desc": "생각 블록에 모델의 추론 과정 요약을 표시합니다.", + "auto": "자동", + "concise": "간결", + "detailed": "상세", + "off": "끄기" + }, + "skills": { + "name": "Codex 스킬", + "desc": ".codex/skills/ 또는 .agents/skills/에 저장된 vault 수준 Codex 스킬을 관리합니다. 홈 수준 스킬은 여기에서 제외됩니다.", + "hiddenName": "숨긴 스킬", + "hiddenDesc": "드롭다운에서 특정 Codex 스킬을 숨깁니다. 앞의 $ 없이 스킬 이름을 한 줄에 하나씩 입력하세요.", + "hiddenPlaceholder": "analyze\nexplain\nfix" + }, + "subagents": { + "name": "Codex 하위 에이전트", + "desc": ".codex/agents/에 저장된 vault 수준 Codex 하위 에이전트를 관리합니다. 각 TOML 파일은 사용자 지정 에이전트 하나를 정의합니다." + }, + "mcp": { + "descBeforeCommand": "Codex는 자체 CLI로 MCP 서버를 관리합니다. ", + "descAfterCommand": " 명령으로 구성하면 Claudian에서 사용할 수 있습니다. ", + "learnMore": "자세히 알아보기" + }, + "environment": { + "name": "Codex 환경", + "desc": "Codex 전용 런타임 변수만 입력하세요. OPENAI_* 및 CODEX_* 설정에 사용합니다. Codex 자동 감지에 도움이 필요하면 이 공급자 섹션 대신 공유 PATH에 설치 디렉터리를 추가하세요." + } + }, + "codexSkills": { + "modal": { + "titleEdit": "Codex 스킬 편집", + "titleAdd": "Codex 스킬 추가", + "directory": "디렉터리", + "directoryDesc": "스킬을 저장할 위치", + "skillName": "스킬 이름", + "skillNameDesc": "$ 뒤에 사용할 이름입니다(예: $analyze의 경우 \"analyze\")", + "description": "설명", + "descriptionDesc": "드롭다운에 표시할 선택적 설명", + "instructions": "지침", + "instructionsDesc": "스킬 지침(SKILL.md 내용)", + "instructionsPlaceholder": "Analyze the code for..." + }, + "instructionsRequired": "지침은 필수입니다", + "saveFailed": "Codex 스킬 저장 실패", + "header": "Codex 스킬", + "noSkills": "Vault에 Codex 스킬이 없습니다. +를 클릭해 만드세요.", + "skillBadge": "스킬", + "deleted": "Codex 스킬 \"{name}\" 삭제됨", + "deleteFailed": "Codex 스킬 삭제 실패", + "created": "Codex 스킬 \"{name}\" 생성됨", + "updated": "Codex 스킬 \"{name}\" 업데이트됨" + }, + "codexSubagents": { + "modal": { + "titleEdit": "Codex 하위 에이전트 편집", + "titleAdd": "Codex 하위 에이전트 추가", + "nameDesc": "Codex가 생성할 때 사용하는 에이전트 이름(소문자, 하이픈, 밑줄)", + "descriptionDesc": "Codex가 이 에이전트를 사용해야 하는 시점", + "modelDesc": "모델 재정의(비워 두면 상속)", + "namePlaceholder": "code_reviewer" + }, + "reasoningEffort": { + "name": "추론 강도", + "desc": "모델 추론 강도 수준", + "inherit": "상속", + "low": "낮음", + "medium": "중간", + "high": "높음", + "xhigh": "매우 높음" + }, + "sandboxMode": { + "name": "샌드박스 모드", + "desc": "이 에이전트의 샌드박스 제한", + "inherit": "상속", + "readOnly": "읽기 전용", + "dangerFullAccess": "전체 액세스", + "workspaceWrite": "작업공간 쓰기" + }, + "nicknameCandidates": { + "name": "닉네임 후보", + "desc": "표시 닉네임을 쉼표로 구분합니다(예: atlas, delta, echo)" + }, + "developerInstructions": { + "name": "개발자 지침", + "desc": "에이전트 동작을 정의하는 핵심 지침", + "placeholder": "Review code like an owner.\nPrioritize correctness, security, and missing test coverage.", + "required": "개발자 지침은 필수입니다" + }, + "validation": { + "nameRequired": "하위 에이전트 이름은 필수입니다", + "nameTooLong": "하위 에이전트 이름은 {count}자 이하여야 합니다", + "nameInvalid": "하위 에이전트 이름은 소문자, 숫자, 하이픈, 밑줄만 포함할 수 있습니다", + "nicknameInvalid": "닉네임 후보는 ASCII 문자, 숫자, 공백, 하이픈, 밑줄만 포함할 수 있습니다", + "nicknameDuplicate": "닉네임 후보는 고유해야 합니다" + }, + "header": "Codex 하위 에이전트", + "noAgents": "Vault에 Codex 하위 에이전트가 없습니다. +를 클릭해 만드세요." + } + } +} diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json new file mode 100644 index 0000000..02edb35 --- /dev/null +++ b/src/i18n/locales/pt.json @@ -0,0 +1,446 @@ +{ + "common": { + "save": "Salvar", + "cancel": "Cancelar", + "delete": "Excluir", + "edit": "Editar", + "add": "Adicionar", + "remove": "Remover", + "clear": "Limpar", + "clearAll": "Limpar tudo", + "loading": "Carregando", + "error": "Erro", + "success": "Sucesso", + "warning": "Aviso", + "confirm": "Confirmar", + "settings": "Configurações", + "advanced": "Avançado", + "enabled": "Ativado", + "disabled": "Desativado", + "platform": "Plataforma", + "refresh": "Atualizar", + "rewind": "Retroceder" + }, + "chat": { + "rewind": { + "confirmMessage": "Retroceder até este ponto? As alterações de arquivos após esta mensagem serão revertidas. O retrocesso não afeta arquivos editados manualmente ou via bash.", + "confirmMessageConversationOnly": "Retroceder a conversa até este ponto? As alterações de arquivos serão mantidas.", + "confirmButton": "Retroceder", + "ariaLabel": "Retroceder até aqui", + "menuConversationOnly": "Retroceder somente conversa", + "menuCodeAndConversation": "Retroceder código + conversa", + "notice": "Retrocedido: {count} arquivo(s) revertido(s)", + "noticeConversationOnly": "Conversa retrocedida; alterações de arquivos mantidas", + "noticeSaveFailed": "Retrocedido: {count} arquivo(s) revertido(s), mas não foi possível salvar o estado: {error}", + "noticeConversationOnlySaveFailed": "Conversa retrocedida, mas não foi possível salvar o estado: {error}", + "failed": "Falha ao retroceder: {error}", + "cannot": "Não é possível retroceder: {error}", + "unavailableStreaming": "Não é possível retroceder durante a transmissão", + "unavailableNoUuid": "Não é possível retroceder: identificadores de mensagem ausentes" + }, + "fork": { + "ariaLabel": "Bifurcar conversa", + "chooseTarget": "Bifurcar conversa", + "targetNewTab": "Nova aba", + "targetCurrentTab": "Aba atual", + "maxTabsReached": "Não é possível bifurcar: máximo de {count} abas atingido", + "notice": "Bifurcado para nova aba", + "noticeCurrentTab": "Bifurcado na aba atual", + "failed": "Falha ao bifurcar: {error}", + "unavailableStreaming": "Não é possível bifurcar durante a transmissão", + "unavailableNoUuid": "Não é possível bifurcar: identificadores de mensagem ausentes", + "unavailableNoResponse": "Não é possível bifurcar: nenhuma resposta para bifurcar", + "errorMessageNotFound": "Mensagem não encontrada", + "errorNoSession": "Nenhum ID de sessão disponível", + "errorNoActiveTab": "Nenhuma aba ativa", + "commandNoMessages": "Não é possível bifurcar: não há mensagens na conversa", + "commandNoAssistantUuid": "Não é possível bifurcar: não há resposta do assistente com identificadores" + }, + "bangBash": { + "placeholder": "> Executar um comando bash...", + "commandPanel": "Painel de comandos", + "copyAriaLabel": "Copiar a saída do comando mais recente", + "clearAriaLabel": "Limpar a saída do bash", + "commandLabel": "{command}", + "statusLabel": "Estado: {status}", + "collapseOutput": "Recolher a saída do comando", + "expandOutput": "Expandir a saída do comando", + "running": "Executando...", + "copyFailed": "Falha ao copiar para a área de transferência" + } + }, + "settings": { + "title": "Configurações do Claudian", + "tabs": { + "general": "Geral", + "claude": "Claude", + "codex": "Codex" + }, + "display": "Exibição", + "conversations": "Conversas", + "content": "Conteúdo", + "input": "Entrada", + "setup": "Configuração", + "models": "Modelos", + "experimental": "Experimental", + "userName": { + "name": "Como o Claudian deve chamá-lo?", + "desc": "Seu nome para saudações personalizadas (deixe vazio para saudações genéricas)" + }, + "excludedTags": { + "name": "Tags excluídas", + "desc": "Notas com estas tags não serão carregadas automaticamente como contexto (uma por linha, sem #)" + }, + "mediaFolder": { + "name": "Pasta de mídia", + "desc": "Pasta contendo anexos/imagens. Quando notas usam ![[image.jpg]], Claude procurará aqui. Deixe vazio para a raiz do repositório." + }, + "systemPrompt": { + "name": "Prompt de sistema personalizado", + "desc": "Instruções adicionais anexadas ao prompt de sistema padrão" + }, + "autoTitle": { + "name": "Gerar automaticamente títulos de conversa", + "desc": "Gera automaticamente títulos de conversa após a primeira mensagem do usuário." + }, + "titleModel": { + "name": "Modelo de geração de título", + "desc": "Modelo usado para gerar automaticamente títulos de conversa.", + "auto": "Automático (Haiku)" + }, + "navMappings": { + "name": "Mapeamentos de navegação estilo Vim", + "desc": "Um mapeamento por linha. Formato: \"map \" (ações: scrollUp, scrollDown, focusInput)." + }, + "requireCommandOrControlEnterToSend": { + "name": "Exigir Command/Ctrl+Enter para enviar", + "desc": "Quando ativado, Enter insere uma quebra de linha. Command+Enter envia no macOS; Ctrl+Enter envia no Windows e Linux." + }, + "hotkeys": "Atalhos", + "inlineEditHotkey": { + "name": "Edição em linha", + "descWithKey": "Atalho atual: {hotkey}", + "descNoKey": "Nenhum atalho definido", + "btnChange": "Alterar", + "btnSet": "Definir" + }, + "openChatHotkey": { + "name": "Abrir chat", + "descWithKey": "Atalho atual: {hotkey}", + "descNoKey": "Nenhum atalho definido", + "btnChange": "Alterar", + "btnSet": "Definir" + }, + "newSessionHotkey": { + "name": "Nova sessão", + "descWithKey": "Atalho atual: {hotkey}", + "descNoKey": "Nenhum atalho definido", + "btnChange": "Alterar", + "btnSet": "Definir" + }, + "newTabHotkey": { + "name": "Nova aba", + "descWithKey": "Atalho atual: {hotkey}", + "descNoKey": "Nenhum atalho definido", + "btnChange": "Alterar", + "btnSet": "Definir" + }, + "closeTabHotkey": { + "name": "Fechar aba", + "descWithKey": "Atalho atual: {hotkey}", + "descNoKey": "Nenhum atalho definido", + "btnChange": "Alterar", + "btnSet": "Definir" + }, + "slashCommands": { + "name": "Comandos e habilidades", + "desc": "Gerencie comandos e habilidades a nível de vault armazenados em .claude/commands/ e .claude/skills/. Acionados por /nome." + }, + "hiddenSlashCommands": { + "name": "Comandos e habilidades ocultos", + "desc": "Oculta comandos e habilidades específicos do menu suspenso. Útil para ocultar entradas do Claude Code que não são relevantes para o Claudian. Digite os nomes sem a barra inicial, um por linha.", + "placeholder": "commit\nbuild\ntest" + }, + "mcpServers": { + "name": "Servidores MCP", + "desc": "Gerencie configurações de servidores MCP a nível de vault armazenadas em .claude/mcp.json. Servidores com modo de salvamento de contexto exigem @mention para ativar." + }, + "plugins": { + "name": "Plugins do Claude Code", + "desc": "Gerencie plugins do Claude Code descobertos em ~/.claude/plugins. Plugins ativados são armazenados por vault em .claude/settings.json." + }, + "subagents": { + "name": "Subagentes", + "desc": "Gerencie subagentes a nível de vault armazenados em .claude/agents/. Cada arquivo Markdown define um agente personalizado.", + "noAgents": "Nenhum subagente configurado. Clique em + para criar um.", + "deleteConfirm": "Excluir o subagente \"{name}\"?", + "saveFailed": "Falha ao salvar o subagente: {message}", + "refreshFailed": "Falha ao atualizar subagentes: {message}", + "deleteFailed": "Falha ao excluir o subagente: {message}", + "renameCleanupFailed": "Aviso: não foi possível remover o arquivo antigo de \"{name}\"", + "created": "Subagente \"{name}\" criado", + "updated": "Subagente \"{name}\" atualizado", + "deleted": "Subagente \"{name}\" excluído", + "duplicateName": "Já existe um agente chamado \"{name}\"", + "descriptionRequired": "A descrição é obrigatória", + "promptRequired": "O prompt do sistema é obrigatório", + "modal": { + "titleEdit": "Editar subagente", + "titleAdd": "Adicionar subagente", + "name": "Nome", + "nameDesc": "Use apenas letras minúsculas, números e hífens", + "namePlaceholder": "code-reviewer", + "description": "Descrição", + "descriptionDesc": "Breve descrição deste agente", + "descriptionPlaceholder": "Revisa código em busca de bugs e estilo", + "advancedOptions": "Opções avançadas", + "model": "Modelo", + "modelDesc": "Substituir o modelo deste agente", + "tools": "Ferramentas", + "toolsDesc": "Lista de ferramentas permitidas separadas por vírgula (vazio = todas)", + "disallowedTools": "Ferramentas não permitidas", + "disallowedToolsDesc": "Lista de ferramentas a desativar separadas por vírgula", + "skills": "Habilidades", + "skillsDesc": "Lista de habilidades separadas por vírgula", + "prompt": "Prompt do sistema", + "promptDesc": "Instruções para o agente", + "promptPlaceholder": "Você é um revisor de código. Analise o código fornecido para..." + } + }, + "safety": "Segurança", + "loadUserSettings": { + "name": "Carregar configurações do usuário Claude", + "desc": "Carrega ~/.claude/settings.json. Quando habilitado, as regras de permissão do usuário podem ignorar o modo seguro." + }, + "claudeSafeMode": { + "name": "Modo seguro", + "desc": "Modo de permissão usado quando o controle Safe está ativo." + }, + "codexSafeMode": { + "name": "Modo seguro", + "desc": "Modo de sandbox usado quando o controle Safe está ativo." + }, + "environment": "Ambiente", + "customVariables": { + "name": "Variáveis personalizadas", + "desc": "Variáveis de ambiente para Claude SDK (formato KEY=VALUE, uma por linha). Prefixo export suportado." + }, + "envSnippets": { + "name": "Trechos", + "addBtn": "Adicionar snippet", + "noSnippets": "Nenhum snippet de ambiente salvo. Clique em + para salvar sua configuração atual.", + "nameRequired": "Por favor, insira um nome para o snippet", + "modal": { + "titleEdit": "Editar snippet", + "titleSave": "Salvar snippet", + "name": "Nome", + "namePlaceholder": "Um nome descritivo para esta configuração", + "description": "Descrição", + "descPlaceholder": "Descrição opcional", + "envVars": "Variáveis de ambiente", + "envVarsPlaceholder": "Formato KEY=VALUE, uma por linha (prefixo export suportado)", + "save": "Salvar", + "update": "Atualizar", + "cancel": "Cancelar" + } + }, + "customModelOverrides": { + "name": "Substituições de modelos personalizados", + "desc": "Defina aliases no seletor de modelos e tamanhos de janela de contexto para modelos personalizados." + }, + "customModelAliases": { + "placeholder": "Apelido" + }, + "customContextLimits": { + "name": "Limites de contexto personalizados", + "desc": "Defina tamanhos de janela de contexto para seus modelos personalizados. Deixe vazio para usar o padrão (200k tokens).", + "invalid": "Formato inválido. Use: 256k, 1m ou número exato (1000-10000000)." + }, + "customModels": { + "name": "Modelos personalizados", + "desc": "Adicione IDs de modelos Claude ao seletor, um por linha. Substituições de modelo por ambiente continuam substituindo o seletor.", + "placeholder": "claude-opus-4-6\nclaude-opus-4-5-20251101" + }, + "enableChrome": { + "name": "Habilitar extensão do Chrome", + "desc": "Permitir que o Claude interaja com o Chrome através da extensão claude-in-chrome. Requer que a extensão esteja instalada. Requer reinício de sessão." + }, + "enableBangBash": { + "name": "Ativar modo bash (!)", + "desc": "Digite ! com a entrada vazia para entrar no modo bash. Executa comandos diretamente via Node.js child_process. Requer reabrir a visualização.", + "validation": { + "noNode": "Node.js não encontrado no PATH. Instale o Node.js ou verifique a configuração do PATH." + } + }, + "maxTabs": { + "name": "Máximo de abas de chat", + "desc": "Número máximo de abas de chat simultâneas (3-10). Cada aba usa uma sessão Claude separada.", + "warning": "Mais de 5 abas pode afetar o desempenho e o uso de memória." + }, + "enableAutoScroll": { + "name": "Rolagem automática durante streaming", + "desc": "Rolar automaticamente para baixo enquanto o Claude transmite respostas. Desativar para ficar no topo e ler desde o início." + }, + "deferMathRenderingDuringStreaming": { + "name": "Adiar renderização matemática durante streaming", + "desc": "Mostrar LaTeX bruto enquanto a resposta é transmitida e renderizar a matemática uma vez quando cada bloco de texto terminar." + }, + "expandFileEditsByDefault": { + "name": "Expandir edições de arquivo por padrão", + "desc": "Mostrar os diffs de Write e Edit expandidos quando aparecerem pela primeira vez." + }, + "chatViewPlacement": { + "name": "Abrir Claudian em", + "desc": "Escolha onde o painel de chat abre quando é criado", + "rightSidebar": "Barra lateral direita", + "leftSidebar": "Barra lateral esquerda", + "mainTab": "Aba do editor principal" + }, + "cliPath": { + "name": "Caminho CLI Claude", + "desc": "Caminho personalizado para Claude Code CLI. Deixe vazio para detecção automática.", + "descWindows": "Para o instalador nativo, use claude.exe. Para instalações com npm/pnpm/yarn ou outros gerenciadores de pacotes, use o caminho cli-wrapper.cjs (não claude.cmd).", + "descUnix": "Cole a saída de \"which claude\" — funciona tanto para instalações nativas quanto npm/pnpm/yarn.", + "validation": { + "notExist": "Caminho não existe", + "isDirectory": "Caminho é um diretório, não um arquivo" + } + }, + "language": { + "name": "Idioma", + "desc": "Alterar o idioma de exibição da interface do plugin" + }, + "codex": { + "enableProvider": { + "name": "Ativar provedor Codex", + "desc": "Quando ativado, modelos Codex aparecem no seletor de modelos para novas conversas. Sessões Codex existentes são preservadas." + }, + "installationMethod": { + "name": "Método de instalação", + "desc": "Como o Claudian deve iniciar o Codex no Windows. Windows nativo usa um caminho de executável do Windows. WSL inicia a CLI Linux dentro de uma distro selecionada.", + "nativeWindows": "Windows nativo", + "wsl": "WSL" + }, + "cliPath": { + "name": "Caminho da CLI do Codex", + "descUnix": "Caminho personalizado para a CLI local do Codex. Deixe vazio para preferir instalações conhecidas do Codex e depois PATH.", + "descWindows": "Caminho personalizado para a CLI local do Codex. Deixe vazio para detectar automaticamente pelo PATH. Use o caminho do executável nativo do Windows, geralmente `codex.exe`.", + "descWsl": "Comando Codex do lado Linux ou caminho absoluto para executar dentro do WSL. Deixe vazio para procurar no PATH dentro da distro selecionada.", + "validation": { + "wslWindowsPath": "O modo WSL espera um comando Linux ou caminho absoluto Linux, não um caminho de executável do Windows." + } + }, + "wslDistroOverride": { + "name": "Substituição de distro WSL", + "desc": "Substituição avançada opcional. Deixe vazio para inferir a distro a partir de um caminho de workspace WSL quando possível; caso contrário, usa a distro WSL padrão." + }, + "safeMode": { + "workspaceWrite": "Gravação no workspace", + "readOnly": "Somente leitura" + }, + "customModels": { + "name": "Modelos personalizados", + "desc": "Adicione IDs de modelos Codex ao seletor, um por linha. `OPENAI_MODEL` continua tendo precedência quando definido.", + "placeholder": "gpt-5.4\ngpt-5.3-codex-spark" + }, + "reasoningSummary": { + "name": "Resumo de raciocínio", + "desc": "Mostra um resumo do processo de raciocínio do modelo no bloco de pensamento.", + "auto": "Automático", + "concise": "Conciso", + "detailed": "Detalhado", + "off": "Desativado" + }, + "skills": { + "name": "Habilidades Codex", + "desc": "Gerencie habilidades Codex de nível vault armazenadas em .codex/skills/ ou .agents/skills/. Habilidades de nível home são excluídas aqui.", + "hiddenName": "Habilidades ocultas", + "hiddenDesc": "Oculte habilidades Codex específicas do menu. Insira nomes sem o $ inicial, um por linha.", + "hiddenPlaceholder": "analyze\nexplain\nfix" + }, + "subagents": { + "name": "Subagentes Codex", + "desc": "Gerencie subagentes Codex de nível vault armazenados em .codex/agents/. Cada arquivo TOML define um agente personalizado." + }, + "mcp": { + "descBeforeCommand": "O Codex gerencia servidores MCP por sua própria CLI. Configure com ", + "descAfterCommand": " e eles ficarão disponíveis no Claudian. ", + "learnMore": "Saiba mais" + }, + "environment": { + "name": "Ambiente do Codex", + "desc": "Somente variáveis de runtime pertencentes ao Codex. Use isto para configurações OPENAI_* e CODEX_*. Se a autodetecção do Codex precisar de ajuda, adicione o diretório de instalação ao PATH compartilhado em vez desta seção do provedor." + } + }, + "codexSkills": { + "modal": { + "titleEdit": "Editar habilidade Codex", + "titleAdd": "Adicionar habilidade Codex", + "directory": "Diretório", + "directoryDesc": "Onde armazenar a habilidade", + "skillName": "Nome da habilidade", + "skillNameDesc": "O nome usado após $ (ex.: \"analyze\" para $analyze)", + "description": "Descrição", + "descriptionDesc": "Descrição opcional exibida no menu", + "instructions": "Instruções", + "instructionsDesc": "Instruções da habilidade (conteúdo de SKILL.md)", + "instructionsPlaceholder": "Analyze the code for..." + }, + "instructionsRequired": "Instruções são obrigatórias", + "saveFailed": "Falha ao salvar habilidade Codex", + "header": "Habilidades Codex", + "noSkills": "Não há habilidades Codex no vault. Clique em + para criar uma.", + "skillBadge": "habilidade", + "deleted": "Habilidade Codex \"{name}\" excluída", + "deleteFailed": "Falha ao excluir habilidade Codex", + "created": "Habilidade Codex \"{name}\" criada", + "updated": "Habilidade Codex \"{name}\" atualizada" + }, + "codexSubagents": { + "modal": { + "titleEdit": "Editar subagente Codex", + "titleAdd": "Adicionar subagente Codex", + "nameDesc": "Nome do agente que o Codex usa ao iniciar (minúsculas, hifens e underscores)", + "descriptionDesc": "Quando o Codex deve usar este agente", + "modelDesc": "Substituição de modelo (vazio = herdar)", + "namePlaceholder": "code_reviewer" + }, + "reasoningEffort": { + "name": "Esforço de raciocínio", + "desc": "Nível de esforço de raciocínio do modelo", + "inherit": "Herdar", + "low": "Baixo", + "medium": "Médio", + "high": "Alto", + "xhigh": "Muito alto" + }, + "sandboxMode": { + "name": "Modo sandbox", + "desc": "Restrição de sandbox para este agente", + "inherit": "Herdar", + "readOnly": "Somente leitura", + "dangerFullAccess": "Acesso total", + "workspaceWrite": "Gravação no workspace" + }, + "nicknameCandidates": { + "name": "Candidatos de apelido", + "desc": "Apelidos exibidos separados por vírgula (ex.: atlas, delta, echo)" + }, + "developerInstructions": { + "name": "Instruções de desenvolvedor", + "desc": "Instruções centrais que definem o comportamento do agente", + "placeholder": "Review code like an owner.\nPrioritize correctness, security, and missing test coverage.", + "required": "Instruções de desenvolvedor são obrigatórias" + }, + "validation": { + "nameRequired": "Nome do subagente é obrigatório", + "nameTooLong": "Nome do subagente deve ter {count} caracteres ou menos", + "nameInvalid": "Nome do subagente só pode conter letras minúsculas, números, hifens e underscores", + "nicknameInvalid": "Candidatos de apelido só podem conter letras ASCII, números, espaços, hifens e underscores", + "nicknameDuplicate": "Candidatos de apelido devem ser únicos" + }, + "header": "Subagentes Codex", + "noAgents": "Não há subagentes Codex no vault. Clique em + para criar um." + } + } +} diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json new file mode 100644 index 0000000..6a1b2ff --- /dev/null +++ b/src/i18n/locales/ru.json @@ -0,0 +1,446 @@ +{ + "common": { + "save": "Сохранить", + "cancel": "Отмена", + "delete": "Удалить", + "edit": "Редактировать", + "add": "Добавить", + "remove": "Удалить", + "clear": "Очистить", + "clearAll": "Очистить всё", + "loading": "Загрузка", + "error": "Ошибка", + "success": "Успех", + "warning": "Предупреждение", + "confirm": "Подтвердить", + "settings": "Настройки", + "advanced": "Дополнительно", + "enabled": "Включено", + "disabled": "Отключено", + "platform": "Платформа", + "refresh": "Обновить", + "rewind": "Откатить" + }, + "chat": { + "rewind": { + "confirmMessage": "Откатить до этой точки? Изменения файлов после этого сообщения будут отменены. Откат не затрагивает файлы, отредактированные вручную или через bash.", + "confirmMessageConversationOnly": "Откатить разговор до этой точки? Изменения файлов будут сохранены.", + "confirmButton": "Откатить", + "ariaLabel": "Откатить сюда", + "menuConversationOnly": "Откатить только разговор", + "menuCodeAndConversation": "Откатить код + разговор", + "notice": "Откачено: восстановлено файлов — {count}", + "noticeConversationOnly": "Разговор откачен; изменения файлов сохранены", + "noticeSaveFailed": "Откат выполнен: восстановлено файлов — {count}, но не удалось сохранить состояние: {error}", + "noticeConversationOnlySaveFailed": "Разговор откачен, но не удалось сохранить состояние: {error}", + "failed": "Ошибка отката: {error}", + "cannot": "Невозможно откатить: {error}", + "unavailableStreaming": "Невозможно откатить во время потоковой передачи", + "unavailableNoUuid": "Невозможно откатить: отсутствуют идентификаторы сообщений" + }, + "fork": { + "ariaLabel": "Ответвить разговор", + "chooseTarget": "Ответвить разговор", + "targetNewTab": "Новая вкладка", + "targetCurrentTab": "Текущая вкладка", + "maxTabsReached": "Невозможно ответвить: достигнут максимум {count} вкладок", + "notice": "Ответвлено в новую вкладку", + "noticeCurrentTab": "Ответвлено в текущей вкладке", + "failed": "Ошибка ответвления: {error}", + "unavailableStreaming": "Невозможно ответвить во время потоковой передачи", + "unavailableNoUuid": "Невозможно ответвить: отсутствуют идентификаторы сообщений", + "unavailableNoResponse": "Невозможно ответвить: нет ответа для ответвления", + "errorMessageNotFound": "Сообщение не найдено", + "errorNoSession": "Идентификатор сессии недоступен", + "errorNoActiveTab": "Нет активной вкладки", + "commandNoMessages": "Нельзя форкнуть: в диалоге нет сообщений", + "commandNoAssistantUuid": "Нельзя форкнуть: нет ответа ассистента с идентификаторами" + }, + "bangBash": { + "placeholder": "> Выполнить команду bash...", + "commandPanel": "Панель команд", + "copyAriaLabel": "Скопировать вывод последней команды", + "clearAriaLabel": "Очистить вывод bash", + "commandLabel": "{command}", + "statusLabel": "Статус: {status}", + "collapseOutput": "Свернуть вывод команды", + "expandOutput": "Развернуть вывод команды", + "running": "Выполняется...", + "copyFailed": "Не удалось скопировать в буфер обмена" + } + }, + "settings": { + "title": "Настройки Claudian", + "tabs": { + "general": "Общие", + "claude": "Claude", + "codex": "Codex" + }, + "display": "Отображение", + "conversations": "Разговоры", + "content": "Содержание", + "input": "Ввод", + "setup": "Настройка", + "models": "Модели", + "experimental": "Экспериментальное", + "userName": { + "name": "Как Claudian должен обращаться к вам?", + "desc": "Ваше имя для персонализированных приветствий (оставьте пустым для общих приветствий)" + }, + "excludedTags": { + "name": "Исключенные теги", + "desc": "Заметки с этими тегами не будут автоматически загружаться как контекст (по одному в строке, без #)" + }, + "mediaFolder": { + "name": "Папка медиафайлов", + "desc": "Папка с вложениями/изображениями. Когда заметки используют ![[image.jpg]], Claude будет искать здесь. Оставьте пустым для корня хранилища." + }, + "systemPrompt": { + "name": "Пользовательский системный промпт", + "desc": "Дополнительные инструкции, добавляемые к системному промпту по умолчанию" + }, + "autoTitle": { + "name": "Автоматически генерировать заголовки бесед", + "desc": "Автоматически генерировать заголовки бесед после первого сообщения пользователя." + }, + "titleModel": { + "name": "Модель генерации заголовков", + "desc": "Модель, используемая для автоматической генерации заголовков бесед.", + "auto": "Авто (Haiku)" + }, + "navMappings": { + "name": "Сопоставления навигации в стиле Vim", + "desc": "По одному сопоставлению в строке. Формат: \"map <ключ> <действие>\" (действия: scrollUp, scrollDown, focusInput)." + }, + "requireCommandOrControlEnterToSend": { + "name": "Требовать Command/Ctrl+Enter для отправки", + "desc": "Если включено, Enter вставляет новую строку. Command+Enter отправляет на macOS; Ctrl+Enter отправляет на Windows и Linux." + }, + "hotkeys": "Горячие клавиши", + "inlineEditHotkey": { + "name": "Инлайн-редактирование", + "descWithKey": "Текущая клавиша: {hotkey}", + "descNoKey": "Клавиша не назначена", + "btnChange": "Изменить", + "btnSet": "Назначить" + }, + "openChatHotkey": { + "name": "Открыть чат", + "descWithKey": "Текущая клавиша: {hotkey}", + "descNoKey": "Клавиша не назначена", + "btnChange": "Изменить", + "btnSet": "Назначить" + }, + "newSessionHotkey": { + "name": "Новая сессия", + "descWithKey": "Текущая клавиша: {hotkey}", + "descNoKey": "Клавиша не назначена", + "btnChange": "Изменить", + "btnSet": "Назначить" + }, + "newTabHotkey": { + "name": "Новая вкладка", + "descWithKey": "Текущая клавиша: {hotkey}", + "descNoKey": "Клавиша не назначена", + "btnChange": "Изменить", + "btnSet": "Назначить" + }, + "closeTabHotkey": { + "name": "Закрыть вкладку", + "descWithKey": "Текущая клавиша: {hotkey}", + "descNoKey": "Клавиша не назначена", + "btnChange": "Изменить", + "btnSet": "Назначить" + }, + "slashCommands": { + "name": "Команды и навыки", + "desc": "Управление командами и навыками на уровне хранилища, расположенными в .claude/commands/ и .claude/skills/. Запускаются через /имя." + }, + "hiddenSlashCommands": { + "name": "Скрытые команды и навыки", + "desc": "Скрыть определённые команды и навыки из выпадающего списка. Полезно для скрытия записей Claude Code, которые не актуальны для Claudian. Вводите имена без начального слэша, по одному на строку.", + "placeholder": "commit\nbuild\ntest" + }, + "mcpServers": { + "name": "MCP серверы", + "desc": "Управление конфигурациями MCP-серверов на уровне хранилища в .claude/mcp.json. Серверы с режимом сохранения контекста требуют @mention для активации." + }, + "plugins": { + "name": "Плагины Claude Code", + "desc": "Управление плагинами Claude Code из ~/.claude/plugins. Включенные плагины сохраняются для каждого хранилища в .claude/settings.json." + }, + "subagents": { + "name": "Субагенты", + "desc": "Управление субагентами на уровне хранилища в .claude/agents/. Каждый Markdown-файл определяет одного пользовательского агента.", + "noAgents": "Субагенты не настроены. Нажмите +, чтобы создать одного.", + "deleteConfirm": "Удалить субагента \"{name}\"?", + "saveFailed": "Не удалось сохранить субагента: {message}", + "refreshFailed": "Не удалось обновить субагентов: {message}", + "deleteFailed": "Не удалось удалить субагента: {message}", + "renameCleanupFailed": "Предупреждение: не удалось удалить старый файл для \"{name}\"", + "created": "Субагент \"{name}\" создан", + "updated": "Субагент \"{name}\" обновлён", + "deleted": "Субагент \"{name}\" удалён", + "duplicateName": "Агент с именем \"{name}\" уже существует", + "descriptionRequired": "Описание обязательно", + "promptRequired": "Системный промпт обязателен", + "modal": { + "titleEdit": "Редактировать субагента", + "titleAdd": "Добавить субагента", + "name": "Имя", + "nameDesc": "Только строчные буквы, цифры и дефисы", + "namePlaceholder": "code-reviewer", + "description": "Описание", + "descriptionDesc": "Краткое описание этого агента", + "descriptionPlaceholder": "Проверяет код на ошибки и стиль", + "advancedOptions": "Дополнительные параметры", + "model": "Модель", + "modelDesc": "Переопределение модели для этого агента", + "tools": "Инструменты", + "toolsDesc": "Список разрешённых инструментов через запятую (пусто = все)", + "disallowedTools": "Запрещённые инструменты", + "disallowedToolsDesc": "Список инструментов, которые нужно запретить, через запятую", + "skills": "Навыки", + "skillsDesc": "Список навыков через запятую", + "prompt": "Системный промпт", + "promptDesc": "Инструкции для агента", + "promptPlaceholder": "Вы специалист по проверке кода. Проанализируйте предоставленный код на предмет..." + } + }, + "safety": "Безопасность", + "loadUserSettings": { + "name": "Загружать пользовательские настройки Claude", + "desc": "Загружает ~/.claude/settings.json. При включении пользовательские правила разрешений Claude Code могут обходить безопасный режим." + }, + "claudeSafeMode": { + "name": "Безопасный режим", + "desc": "Режим разрешений, используемый, когда переключатель Safe активен." + }, + "codexSafeMode": { + "name": "Безопасный режим", + "desc": "Режим песочницы, используемый, когда переключатель Safe активен." + }, + "environment": "Окружение", + "customVariables": { + "name": "Пользовательские переменные", + "desc": "Переменные окружения для Claude SDK (формат KEY=VALUE, по одной в строке). Префикс export поддерживается." + }, + "envSnippets": { + "name": "Сниппеты", + "addBtn": "Добавить сниппет", + "noSnippets": "Нет сохраненных сниппетов окружения. Нажмите +, чтобы сохранить текущую конфигурацию.", + "nameRequired": "Пожалуйста, введите название сниппета", + "modal": { + "titleEdit": "Редактировать сниппет", + "titleSave": "Сохранить сниппет", + "name": "Имя", + "namePlaceholder": "Описательное название для этой конфигурации", + "description": "Описание", + "descPlaceholder": "Опциональное описание", + "envVars": "Переменные окружения", + "envVarsPlaceholder": "Формат KEY=VALUE, по одной в строке (префикс export поддерживается)", + "save": "Сохранить", + "update": "Обновить", + "cancel": "Отмена" + } + }, + "customModelOverrides": { + "name": "Переопределения пользовательских моделей", + "desc": "Настройте псевдонимы в селекторе моделей и размеры окна контекста для пользовательских моделей." + }, + "customModelAliases": { + "placeholder": "Псевдоним" + }, + "customContextLimits": { + "name": "Пользовательские лимиты контекста", + "desc": "Установите размеры окна контекста для ваших пользовательских моделей. Оставьте пустым для использования значения по умолчанию (200k токенов).", + "invalid": "Неверный формат. Используйте: 256k, 1m или точное число (1000-10000000)." + }, + "customModels": { + "name": "Пользовательские модели", + "desc": "Добавьте дополнительные ID моделей Claude в список выбора, по одному на строку. Переопределения модели из окружения по-прежнему заменяют список.", + "placeholder": "claude-opus-4-6\nclaude-opus-4-5-20251101" + }, + "enableChrome": { + "name": "Включить расширение Chrome", + "desc": "Разрешить Claude взаимодействовать с Chrome через расширение claude-in-chrome. Требуется установка расширения. Требуется перезапуск сессии." + }, + "enableBangBash": { + "name": "Включить режим bash (!)", + "desc": "Введите ! в пустом поле ввода, чтобы перейти в режим bash. Команды выполняются напрямую через Node.js child_process. Требуется повторно открыть представление.", + "validation": { + "noNode": "Node.js не найден в PATH. Установите Node.js или проверьте настройку PATH." + } + }, + "maxTabs": { + "name": "Максимум вкладок чата", + "desc": "Максимальное количество одновременных вкладок чата (3-10). Каждая вкладка использует отдельную сессию Claude.", + "warning": "Более 5 вкладок может повлиять на производительность и использование памяти." + }, + "enableAutoScroll": { + "name": "Автопрокрутка во время потоковой передачи", + "desc": "Автоматически прокручивать вниз, пока Claude передает ответы. Отключите, чтобы оставаться наверху и читать с начала." + }, + "deferMathRenderingDuringStreaming": { + "name": "Отложить рендеринг формул во время потока", + "desc": "Показывать исходный LaTeX во время потоковой передачи и рендерить формулы один раз после завершения каждого текстового блока." + }, + "expandFileEditsByDefault": { + "name": "Раскрывать правки файлов по умолчанию", + "desc": "Показывать diff для Write и Edit раскрытым при первом появлении." + }, + "chatViewPlacement": { + "name": "Открывать Claudian в", + "desc": "Выберите, где открывается панель чата при создании", + "rightSidebar": "Правая боковая панель", + "leftSidebar": "Левая боковая панель", + "mainTab": "Основная вкладка редактора" + }, + "cliPath": { + "name": "Путь к CLI Claude", + "desc": "Пользовательский путь к Claude Code CLI. Оставьте пустым для автоматического определения.", + "descWindows": "Для нативного установщика используйте claude.exe. Для установок через npm/pnpm/yarn или другие менеджеры пакетов используйте путь к cli-wrapper.cjs (не claude.cmd).", + "descUnix": "Вставьте вывод команды \"which claude\" — работает как для нативных установок, так и для npm/pnpm/yarn.", + "validation": { + "notExist": "Путь не существует", + "isDirectory": "Путь является директорией, а не файлом" + } + }, + "language": { + "name": "Язык", + "desc": "Изменить язык интерфейса плагина" + }, + "codex": { + "enableProvider": { + "name": "Включить провайдера Codex", + "desc": "Когда включено, модели Codex появляются в выборе моделей для новых бесед. Существующие сеансы Codex сохраняются." + }, + "installationMethod": { + "name": "Способ установки", + "desc": "Как Claudian должен запускать Codex в Windows. Нативный Windows использует путь к Windows-исполняемому файлу. WSL запускает Linux CLI внутри выбранного дистрибутива.", + "nativeWindows": "Нативный Windows", + "wsl": "WSL" + }, + "cliPath": { + "name": "Путь к Codex CLI", + "descUnix": "Пользовательский путь к локальному Codex CLI. Оставьте пустым, чтобы сначала использовать известные установки Codex, затем PATH.", + "descWindows": "Пользовательский путь к локальному Codex CLI. Оставьте пустым для автоопределения через PATH. Используйте путь к нативному Windows-исполняемому файлу, обычно `codex.exe`.", + "descWsl": "Linux-команда Codex или абсолютный путь для запуска внутри WSL. Оставьте пустым для поиска в PATH выбранного дистрибутива.", + "validation": { + "wslWindowsPath": "В режиме WSL ожидается Linux-команда или абсолютный Linux-путь, а не путь к Windows-исполняемому файлу." + } + }, + "wslDistroOverride": { + "name": "Переопределение дистрибутива WSL", + "desc": "Необязательное расширенное переопределение. Оставьте пустым, чтобы по возможности определить дистрибутив из пути рабочей области WSL, иначе будет использован дистрибутив WSL по умолчанию." + }, + "safeMode": { + "workspaceWrite": "Запись в рабочую область", + "readOnly": "Только чтение" + }, + "customModels": { + "name": "Пользовательские модели", + "desc": "Добавьте дополнительные ID моделей Codex в список выбора, по одному на строку. `OPENAI_MODEL` по-прежнему имеет приоритет, если задан.", + "placeholder": "gpt-5.4\ngpt-5.3-codex-spark" + }, + "reasoningSummary": { + "name": "Сводка рассуждений", + "desc": "Показывать сводку процесса рассуждений модели в блоке размышлений.", + "auto": "Авто", + "concise": "Кратко", + "detailed": "Подробно", + "off": "Выкл." + }, + "skills": { + "name": "Навыки Codex", + "desc": "Управляйте навыками Codex уровня vault, сохраненными в .codex/skills/ или .agents/skills/. Навыки уровня home здесь исключены.", + "hiddenName": "Скрытые навыки", + "hiddenDesc": "Скрыть отдельные навыки Codex из списка. Введите имена навыков без начального $, по одному на строку.", + "hiddenPlaceholder": "analyze\nexplain\nfix" + }, + "subagents": { + "name": "Субагенты Codex", + "desc": "Управляйте субагентами Codex уровня vault, сохраненными в .codex/agents/. Каждый TOML-файл задает одного пользовательского агента." + }, + "mcp": { + "descBeforeCommand": "Codex управляет MCP-серверами через собственный CLI. Настройте через ", + "descAfterCommand": ", и они будут доступны в Claudian. ", + "learnMore": "Подробнее" + }, + "environment": { + "name": "Окружение Codex", + "desc": "Только переменные среды выполнения, принадлежащие Codex. Используйте для настроек OPENAI_* и CODEX_*. Если автоопределению Codex нужна помощь, добавьте каталог установки в общий PATH, а не в этот раздел провайдера." + } + }, + "codexSkills": { + "modal": { + "titleEdit": "Редактировать навык Codex", + "titleAdd": "Добавить навык Codex", + "directory": "Каталог", + "directoryDesc": "Где хранить навык", + "skillName": "Имя навыка", + "skillNameDesc": "Имя, используемое после $ (например, \"analyze\" для $analyze)", + "description": "Описание", + "descriptionDesc": "Необязательное описание, отображаемое в списке", + "instructions": "Инструкции", + "instructionsDesc": "Инструкции навыка (содержимое SKILL.md)", + "instructionsPlaceholder": "Analyze the code for..." + }, + "instructionsRequired": "Инструкции обязательны", + "saveFailed": "Не удалось сохранить навык Codex", + "header": "Навыки Codex", + "noSkills": "В vault нет навыков Codex. Нажмите +, чтобы создать навык.", + "skillBadge": "навык", + "deleted": "Навык Codex \"{name}\" удален", + "deleteFailed": "Не удалось удалить навык Codex", + "created": "Навык Codex \"{name}\" создан", + "updated": "Навык Codex \"{name}\" обновлен" + }, + "codexSubagents": { + "modal": { + "titleEdit": "Редактировать субагента Codex", + "titleAdd": "Добавить субагента Codex", + "nameDesc": "Имя агента, которое Codex использует при запуске (строчные буквы, дефисы, подчеркивания)", + "descriptionDesc": "Когда Codex должен использовать этого агента", + "modelDesc": "Переопределение модели (пусто = наследовать)", + "namePlaceholder": "code_reviewer" + }, + "reasoningEffort": { + "name": "Усилие рассуждения", + "desc": "Уровень усилия рассуждения модели", + "inherit": "Наследовать", + "low": "Низкий", + "medium": "Средний", + "high": "Высокий", + "xhigh": "Очень высокий" + }, + "sandboxMode": { + "name": "Режим песочницы", + "desc": "Ограничение песочницы для этого агента", + "inherit": "Наследовать", + "readOnly": "Только чтение", + "dangerFullAccess": "Полный доступ", + "workspaceWrite": "Запись в рабочую область" + }, + "nicknameCandidates": { + "name": "Варианты псевдонимов", + "desc": "Отображаемые псевдонимы через запятую (например: atlas, delta, echo)" + }, + "developerInstructions": { + "name": "Инструкции разработчика", + "desc": "Основные инструкции, определяющие поведение агента", + "placeholder": "Review code like an owner.\nPrioritize correctness, security, and missing test coverage.", + "required": "Инструкции разработчика обязательны" + }, + "validation": { + "nameRequired": "Имя субагента обязательно", + "nameTooLong": "Имя субагента должно быть не длиннее {count} символов", + "nameInvalid": "Имя субагента может содержать только строчные буквы, цифры, дефисы и подчеркивания", + "nicknameInvalid": "Варианты псевдонимов могут содержать только ASCII-буквы, цифры, пробелы, дефисы и подчеркивания", + "nicknameDuplicate": "Варианты псевдонимов должны быть уникальными" + }, + "header": "Субагенты Codex", + "noAgents": "В vault нет субагентов Codex. Нажмите +, чтобы создать одного." + } + } +} diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json new file mode 100644 index 0000000..047b5b3 --- /dev/null +++ b/src/i18n/locales/zh-CN.json @@ -0,0 +1,446 @@ +{ + "common": { + "save": "保存", + "cancel": "取消", + "delete": "删除", + "edit": "编辑", + "add": "添加", + "remove": "移除", + "clear": "清除", + "clearAll": "清除全部", + "loading": "加载中", + "error": "错误", + "success": "成功", + "warning": "警告", + "confirm": "确认", + "settings": "设置", + "advanced": "高级", + "enabled": "已启用", + "disabled": "已禁用", + "platform": "平台", + "refresh": "刷新", + "rewind": "回退" + }, + "chat": { + "rewind": { + "confirmMessage": "回退到此处?此消息之后的文件更改将被还原。回退不会影响手动或通过 bash 编辑的文件。", + "confirmMessageConversationOnly": "将对话回退到此处?文件更改将保留。", + "confirmButton": "回退", + "ariaLabel": "回退到此处", + "menuConversationOnly": "仅回退对话", + "menuCodeAndConversation": "回退代码 + 对话", + "notice": "已回退:还原了 {count} 个文件", + "noticeConversationOnly": "已回退对话;文件更改已保留", + "noticeSaveFailed": "已回退:还原了 {count} 个文件,但无法保存状态:{error}", + "noticeConversationOnlySaveFailed": "已回退对话,但无法保存状态:{error}", + "failed": "回退失败:{error}", + "cannot": "无法回退:{error}", + "unavailableStreaming": "流式响应中无法回退", + "unavailableNoUuid": "无法回退:缺少消息标识符" + }, + "fork": { + "ariaLabel": "分叉对话", + "chooseTarget": "分叉对话", + "targetNewTab": "新标签页", + "targetCurrentTab": "当前标签页", + "maxTabsReached": "无法分叉:已达到最大 {count} 个标签页", + "notice": "已分叉到新标签页", + "noticeCurrentTab": "已在当前标签页分叉", + "failed": "分叉失败:{error}", + "unavailableStreaming": "流式响应中无法分叉", + "unavailableNoUuid": "无法分叉:缺少消息标识符", + "unavailableNoResponse": "无法分叉:没有可分叉的响应", + "errorMessageNotFound": "未找到消息", + "errorNoSession": "没有可用的会话 ID", + "errorNoActiveTab": "没有活动标签页", + "commandNoMessages": "无法分叉:对话中没有消息", + "commandNoAssistantUuid": "无法分叉:没有带标识符的助手回复" + }, + "bangBash": { + "placeholder": "> 运行命令...", + "commandPanel": "命令面板", + "copyAriaLabel": "复制最新命令输出", + "clearAriaLabel": "清除命令输出", + "commandLabel": "{command}", + "statusLabel": "状态: {status}", + "collapseOutput": "折叠命令输出", + "expandOutput": "展开命令输出", + "running": "运行中...", + "copyFailed": "复制到剪贴板失败" + } + }, + "settings": { + "title": "Claudian 设置", + "tabs": { + "general": "通用", + "claude": "Claude", + "codex": "Codex" + }, + "display": "显示", + "conversations": "对话", + "content": "内容", + "input": "输入", + "setup": "设置", + "models": "模型", + "experimental": "实验性功能", + "userName": { + "name": "Claudian 应该如何称呼你?", + "desc": "用于个性化问候的用户名(留空使用通用问候)" + }, + "excludedTags": { + "name": "排除的标签", + "desc": "包含这些标签的笔记不会自动加载为上下文(每行一个,不带 #)" + }, + "mediaFolder": { + "name": "媒体文件夹", + "desc": "存放附件/图片的文件夹。当笔记使用 ![[image.jpg]] 时,Claude 会在此查找。留空使用仓库根目录。" + }, + "systemPrompt": { + "name": "自定义系统提示词", + "desc": "附加到默认系统提示词的额外指令" + }, + "autoTitle": { + "name": "自动生成对话标题", + "desc": "在用户发送首条消息后自动生成对话标题。" + }, + "titleModel": { + "name": "标题生成模型", + "desc": "用于自动生成对话标题的模型。", + "auto": "自动 (Haiku)" + }, + "navMappings": { + "name": "Vim 风格导航映射", + "desc": "每行一个映射。格式:\"map <键> <动作>\"(动作:scrollUp, scrollDown, focusInput)。" + }, + "requireCommandOrControlEnterToSend": { + "name": "需要 Command/Ctrl+Enter 发送", + "desc": "启用后,Enter 会插入换行。macOS 使用 Command+Enter 发送;Windows 和 Linux 使用 Ctrl+Enter 发送。" + }, + "hotkeys": "快捷键", + "inlineEditHotkey": { + "name": "内联编辑", + "descWithKey": "当前快捷键:{hotkey}", + "descNoKey": "未设置快捷键", + "btnChange": "更改", + "btnSet": "设置快捷键" + }, + "openChatHotkey": { + "name": "打开聊天", + "descWithKey": "当前快捷键:{hotkey}", + "descNoKey": "未设置快捷键", + "btnChange": "更改", + "btnSet": "设置快捷键" + }, + "newSessionHotkey": { + "name": "新会话", + "descWithKey": "当前快捷键:{hotkey}", + "descNoKey": "未设置快捷键", + "btnChange": "更改", + "btnSet": "设置快捷键" + }, + "newTabHotkey": { + "name": "新标签页", + "descWithKey": "当前快捷键:{hotkey}", + "descNoKey": "未设置快捷键", + "btnChange": "更改", + "btnSet": "设置快捷键" + }, + "closeTabHotkey": { + "name": "关闭标签页", + "descWithKey": "当前快捷键:{hotkey}", + "descNoKey": "未设置快捷键", + "btnChange": "更改", + "btnSet": "设置快捷键" + }, + "slashCommands": { + "name": "命令与技能", + "desc": "管理存储在 .claude/commands/ 和 .claude/skills/ 中的 Vault 级命令与技能。由 /名称 触发。" + }, + "hiddenSlashCommands": { + "name": "隐藏命令与技能", + "desc": "从下拉菜单中隐藏特定的命令与技能。适用于隐藏与 Claudian 无关的 Claude Code 条目。每行输入一个名称,无需前导斜杠。", + "placeholder": "commit\nbuild\ntest" + }, + "mcpServers": { + "name": "MCP 服务器", + "desc": "管理存储在 .claude/mcp.json 中的 Vault 级 MCP 服务器配置。启用上下文保存模式的服务器需要 @ 提及才能激活。" + }, + "plugins": { + "name": "Claude Code 插件", + "desc": "管理从 ~/.claude/plugins 发现的 Claude Code 插件。启用的插件按 Vault 存储在 .claude/settings.json 中。" + }, + "subagents": { + "name": "子代理", + "desc": "管理存储在 .claude/agents/ 中的 Vault 级子代理。每个 Markdown 文件定义一个自定义代理。", + "noAgents": "尚未配置子代理。点击 + 创建一个。", + "deleteConfirm": "删除子代理“{name}”?", + "saveFailed": "保存子代理失败:{message}", + "refreshFailed": "刷新子代理失败:{message}", + "deleteFailed": "删除子代理失败:{message}", + "renameCleanupFailed": "警告:无法删除“{name}”的旧文件", + "created": "已创建子代理“{name}”", + "updated": "已更新子代理“{name}”", + "deleted": "已删除子代理“{name}”", + "duplicateName": "名为“{name}”的代理已存在", + "descriptionRequired": "描述为必填项", + "promptRequired": "系统提示词为必填项", + "modal": { + "titleEdit": "编辑子代理", + "titleAdd": "添加子代理", + "name": "名称", + "nameDesc": "仅允许小写字母、数字和连字符", + "namePlaceholder": "code-reviewer", + "description": "描述", + "descriptionDesc": "该代理的简要描述", + "descriptionPlaceholder": "审查代码中的错误和风格问题", + "advancedOptions": "高级选项", + "model": "模型", + "modelDesc": "该代理的模型覆盖", + "tools": "工具", + "toolsDesc": "允许使用的工具列表,用逗号分隔(留空 = 全部)", + "disallowedTools": "禁用工具", + "disallowedToolsDesc": "要禁用的工具列表,用逗号分隔", + "skills": "技能", + "skillsDesc": "技能列表,用逗号分隔", + "prompt": "系统提示词", + "promptDesc": "给代理的指令", + "promptPlaceholder": "你是一名代码审查员。请分析给定代码中的..." + } + }, + "safety": "安全", + "loadUserSettings": { + "name": "加载用户 Claude 设置", + "desc": "加载 ~/.claude/settings.json。启用后,用户的 Claude Code 权限规则可能绕过安全模式。" + }, + "claudeSafeMode": { + "name": "安全模式", + "desc": "启用 Safe 开关时使用的权限模式。" + }, + "codexSafeMode": { + "name": "安全模式", + "desc": "启用 Safe 开关时使用的沙盒模式。" + }, + "environment": "环境", + "customVariables": { + "name": "自定义变量", + "desc": "Claude SDK 的环境变量(KEY=VALUE 格式,每行一个)。支持 export 前缀。" + }, + "envSnippets": { + "name": "片段", + "addBtn": "添加片段", + "noSnippets": "尚无保存的环境变量片段。点击 + 保存当前配置。", + "nameRequired": "请输入片段名称", + "modal": { + "titleEdit": "编辑片段", + "titleSave": "保存片段", + "name": "名称", + "namePlaceholder": "此配置的描述性名称", + "description": "描述", + "descPlaceholder": "可选描述", + "envVars": "环境变量", + "envVarsPlaceholder": "KEY=VALUE 格式,每行一个(支持 export 前缀)", + "save": "保存", + "update": "更新", + "cancel": "取消" + } + }, + "customModelOverrides": { + "name": "自定义模型覆盖", + "desc": "为自定义模型设置模型选择器别名和上下文窗口大小。" + }, + "customModelAliases": { + "placeholder": "别名" + }, + "customContextLimits": { + "name": "自定义上下文限制", + "desc": "为您的自定义模型设置上下文窗口大小。留空使用默认值(200k 令牌)。", + "invalid": "格式无效。使用:256k、1m 或精确数量(1000-10000000)。" + }, + "customModels": { + "name": "自定义模型", + "desc": "向选择器追加额外的 Claude 模型 ID,每行一个。环境中的模型覆盖仍会替换选择器。", + "placeholder": "claude-opus-4-6\nclaude-opus-4-5-20251101" + }, + "enableChrome": { + "name": "启用 Chrome 扩展", + "desc": "允许 Claude 通过 claude-in-chrome 扩展与 Chrome 交互。需要安装该扩展。需要重启会话。" + }, + "enableBangBash": { + "name": "启用命令模式 (!)", + "desc": "在空输入框中输入 ! 进入命令模式。通过 Node.js child_process 直接运行命令。需要重新打开视图。", + "validation": { + "noNode": "未在 PATH 中找到 Node.js。请安装 Node.js 或检查 PATH 配置。" + } + }, + "maxTabs": { + "name": "最大聊天标签数", + "desc": "同时开启的最大聊天标签数(3-10)。每个标签使用独立的 Claude 会话。", + "warning": "超过 5 个标签可能会影响性能和内存使用。" + }, + "enableAutoScroll": { + "name": "流式传输时自动滚动", + "desc": "在 Claude 流式传输响应时自动滚动到底部。禁用后将停留在顶部,从头开始阅读。" + }, + "deferMathRenderingDuringStreaming": { + "name": "流式传输时延迟渲染数学公式", + "desc": "响应流式传输时显示原始 LaTeX,并在每个文本块完成后只渲染一次数学公式。" + }, + "expandFileEditsByDefault": { + "name": "默认展开文件编辑", + "desc": "Write 和 Edit diff 首次出现时以展开状态显示。" + }, + "chatViewPlacement": { + "name": "Claudian 打开位置", + "desc": "选择新建聊天面板时的打开位置", + "rightSidebar": "右侧边栏", + "leftSidebar": "左侧边栏", + "mainTab": "主编辑器标签页" + }, + "cliPath": { + "name": "Claude CLI 路径", + "desc": "Claude Code CLI 的自定义路径。留空使用自动检测。", + "descWindows": "对于原生安装程序,使用 claude.exe。对于 npm/pnpm/yarn 或其他包管理器安装,使用 cli-wrapper.cjs 路径(不是 claude.cmd)。", + "descUnix": "粘贴 \"which claude\" 的输出 - 适用于原生安装和 npm/pnpm/yarn 安装。", + "validation": { + "notExist": "路径不存在", + "isDirectory": "路径是目录,不是文件" + } + }, + "language": { + "name": "语言", + "desc": "更改插件界面的显示语言" + }, + "codex": { + "enableProvider": { + "name": "启用 Codex 提供商", + "desc": "启用后,Codex 模型会出现在新对话的模型选择器中。现有 Codex 会话会保留。" + }, + "installationMethod": { + "name": "安装方式", + "desc": "Claudian 在 Windows 上启动 Codex 的方式。原生 Windows 使用 Windows 可执行文件路径;WSL 会在所选发行版内启动 Linux CLI。", + "nativeWindows": "原生 Windows", + "wsl": "WSL" + }, + "cliPath": { + "name": "Codex CLI 路径", + "descUnix": "本机 Codex CLI 的自定义路径。留空会优先使用已知 Codex 安装,然后查找 PATH。", + "descWindows": "本机 Codex CLI 的自定义路径。留空会从 PATH 自动检测。请使用原生 Windows 可执行文件路径,通常为 `codex.exe`。", + "descWsl": "在 WSL 内运行的 Linux 端 Codex 命令或绝对路径。留空会在所选发行版内查找 PATH。", + "validation": { + "wslWindowsPath": "WSL 模式需要 Linux 命令或 Linux 绝对路径,而不是 Windows 可执行文件路径。" + } + }, + "wslDistroOverride": { + "name": "WSL 发行版覆盖", + "desc": "可选高级覆盖。留空时会尽可能从 WSL 工作区路径推断发行版,否则使用默认 WSL 发行版。" + }, + "safeMode": { + "workspaceWrite": "工作区可写", + "readOnly": "只读" + }, + "customModels": { + "name": "自定义模型", + "desc": "向选择器追加额外的 Codex 模型 ID,每行一个。设置 `OPENAI_MODEL` 时仍优先生效。", + "placeholder": "gpt-5.4\ngpt-5.3-codex-spark" + }, + "reasoningSummary": { + "name": "推理摘要", + "desc": "在思考块中显示模型推理过程的摘要。", + "auto": "自动", + "concise": "简洁", + "detailed": "详细", + "off": "关闭" + }, + "skills": { + "name": "Codex 技能", + "desc": "管理存储在 .codex/skills/ 或 .agents/skills/ 中的 Vault 级 Codex 技能。此处不包含主目录级技能。", + "hiddenName": "隐藏技能", + "hiddenDesc": "从下拉菜单中隐藏特定 Codex 技能。每行输入一个技能名称,不带前导 $。", + "hiddenPlaceholder": "analyze\nexplain\nfix" + }, + "subagents": { + "name": "Codex 子代理", + "desc": "管理存储在 .codex/agents/ 中的 Vault 级 Codex 子代理。每个 TOML 文件定义一个自定义代理。" + }, + "mcp": { + "descBeforeCommand": "Codex 通过自己的 CLI 管理 MCP 服务器。请使用 ", + "descAfterCommand": " 进行配置,它们会在 Claudian 中可用。", + "learnMore": "了解更多" + }, + "environment": { + "name": "Codex 环境", + "desc": "仅限 Codex 拥有的运行时变量。用于 OPENAI_* 和 CODEX_* 设置。如果 Codex 自动检测需要帮助,请把安装目录添加到共享 PATH,而不是此提供商环境。" + } + }, + "codexSkills": { + "modal": { + "titleEdit": "编辑 Codex 技能", + "titleAdd": "添加 Codex 技能", + "directory": "目录", + "directoryDesc": "技能的存储位置", + "skillName": "技能名称", + "skillNameDesc": "在 $ 后使用的名称(例如 $analyze 的名称为 “analyze”)", + "description": "描述", + "descriptionDesc": "下拉菜单中显示的可选描述", + "instructions": "指令", + "instructionsDesc": "技能指令(SKILL.md 内容)", + "instructionsPlaceholder": "Analyze the code for..." + }, + "instructionsRequired": "指令为必填项", + "saveFailed": "保存 Codex 技能失败", + "header": "Codex 技能", + "noSkills": "Vault 中没有 Codex 技能。点击 + 创建一个。", + "skillBadge": "技能", + "deleted": "已删除 Codex 技能“{name}”", + "deleteFailed": "删除 Codex 技能失败", + "created": "已创建 Codex 技能“{name}”", + "updated": "已更新 Codex 技能“{name}”" + }, + "codexSubagents": { + "modal": { + "titleEdit": "编辑 Codex 子代理", + "titleAdd": "添加 Codex 子代理", + "nameDesc": "Codex 派生代理时使用的代理名称(小写、连字符、下划线)", + "descriptionDesc": "Codex 应在何时使用该代理", + "modelDesc": "模型覆盖(留空表示继承)", + "namePlaceholder": "code_reviewer" + }, + "reasoningEffort": { + "name": "推理强度", + "desc": "模型推理强度级别", + "inherit": "继承", + "low": "低", + "medium": "中", + "high": "高", + "xhigh": "极高" + }, + "sandboxMode": { + "name": "沙盒模式", + "desc": "该代理的沙盒限制", + "inherit": "继承", + "readOnly": "只读", + "dangerFullAccess": "完全访问", + "workspaceWrite": "工作区可写" + }, + "nicknameCandidates": { + "name": "昵称候选", + "desc": "显示昵称,使用逗号分隔(例如 atlas, delta, echo)" + }, + "developerInstructions": { + "name": "开发者指令", + "desc": "定义代理行为的核心指令", + "placeholder": "Review code like an owner.\nPrioritize correctness, security, and missing test coverage.", + "required": "开发者指令为必填项" + }, + "validation": { + "nameRequired": "子代理名称为必填项", + "nameTooLong": "子代理名称必须不超过 {count} 个字符", + "nameInvalid": "子代理名称只能包含小写字母、数字、连字符和下划线", + "nicknameInvalid": "昵称候选只能包含 ASCII 字母、数字、空格、连字符和下划线", + "nicknameDuplicate": "昵称候选必须唯一" + }, + "header": "Codex 子代理", + "noAgents": "Vault 中没有 Codex 子代理。点击 + 创建一个。" + } + } +} diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json new file mode 100644 index 0000000..c32f0c4 --- /dev/null +++ b/src/i18n/locales/zh-TW.json @@ -0,0 +1,446 @@ +{ + "common": { + "save": "保存", + "cancel": "取消", + "delete": "刪除", + "edit": "編輯", + "add": "添加", + "remove": "移除", + "clear": "清除", + "clearAll": "清除全部", + "loading": "加載中", + "error": "錯誤", + "success": "成功", + "warning": "警告", + "confirm": "確認", + "settings": "設置", + "advanced": "高級", + "enabled": "已啟用", + "disabled": "已禁用", + "platform": "平台", + "refresh": "重新整理", + "rewind": "回退" + }, + "chat": { + "rewind": { + "confirmMessage": "回退到此處?此訊息之後的檔案變更將被還原。回退不會影響手動或透過 bash 編輯的檔案。", + "confirmMessageConversationOnly": "將對話回退到此處?檔案變更將保留。", + "confirmButton": "回退", + "ariaLabel": "回退到此處", + "menuConversationOnly": "僅回退對話", + "menuCodeAndConversation": "回退程式碼 + 對話", + "notice": "已回退:還原了 {count} 個檔案", + "noticeConversationOnly": "已回退對話;檔案變更已保留", + "noticeSaveFailed": "已回退:還原了 {count} 個檔案,但無法儲存狀態:{error}", + "noticeConversationOnlySaveFailed": "已回退對話,但無法儲存狀態:{error}", + "failed": "回退失敗:{error}", + "cannot": "無法回退:{error}", + "unavailableStreaming": "串流回應中無法回退", + "unavailableNoUuid": "無法回退:缺少訊息識別碼" + }, + "fork": { + "ariaLabel": "分叉對話", + "chooseTarget": "分叉對話", + "targetNewTab": "新分頁", + "targetCurrentTab": "目前分頁", + "maxTabsReached": "無法分叉:已達到最大 {count} 個分頁", + "notice": "已分叉到新分頁", + "noticeCurrentTab": "已在目前分頁分叉", + "failed": "分叉失敗:{error}", + "unavailableStreaming": "串流回應中無法分叉", + "unavailableNoUuid": "無法分叉:缺少訊息識別碼", + "unavailableNoResponse": "無法分叉:沒有可分叉的回應", + "errorMessageNotFound": "找不到訊息", + "errorNoSession": "沒有可用的工作階段 ID", + "errorNoActiveTab": "沒有使用中的分頁", + "commandNoMessages": "無法分叉:對話中沒有訊息", + "commandNoAssistantUuid": "無法分叉:沒有帶識別碼的助手回覆" + }, + "bangBash": { + "placeholder": "> 執行 bash 指令...", + "commandPanel": "指令面板", + "copyAriaLabel": "複製最新的指令輸出", + "clearAriaLabel": "清除 bash 輸出", + "commandLabel": "{command}", + "statusLabel": "狀態:{status}", + "collapseOutput": "摺疊指令輸出", + "expandOutput": "展開指令輸出", + "running": "執行中...", + "copyFailed": "複製到剪貼簿失敗" + } + }, + "settings": { + "title": "Claudian 設定", + "tabs": { + "general": "一般", + "claude": "Claude", + "codex": "Codex" + }, + "display": "顯示", + "conversations": "對話", + "content": "內容", + "input": "輸入", + "setup": "設定", + "models": "模型", + "experimental": "實驗性功能", + "userName": { + "name": "Claudian 應該如何稱呼您?", + "desc": "用於個人化問候的使用者名稱(留空使用通用問候)" + }, + "excludedTags": { + "name": "排除的標籤", + "desc": "包含這些標籤的筆記不會自動載入為上下文(每行一個,不帶 #)" + }, + "mediaFolder": { + "name": "媒體資料夾", + "desc": "存放附件/圖片的資料夾。當筆記使用 ![[image.jpg]] 時,Claude 會在此查找。留空使用儲存庫根目錄。" + }, + "systemPrompt": { + "name": "自訂系統提示詞", + "desc": "附加到預設系統提示詞的額外指令" + }, + "autoTitle": { + "name": "自動生成對話標題", + "desc": "在使用者送出第一則訊息後自動生成對話標題。" + }, + "titleModel": { + "name": "標題生成模型", + "desc": "用於自動生成對話標題的模型。", + "auto": "自動 (Haiku)" + }, + "navMappings": { + "name": "Vim 風格導航映射", + "desc": "每行一個映射。格式:\"map <鍵> <動作>\"(動作:scrollUp, scrollDown, focusInput)。" + }, + "requireCommandOrControlEnterToSend": { + "name": "需要 Command/Ctrl+Enter 才能傳送", + "desc": "啟用後,Enter 會插入換行。macOS 使用 Command+Enter 傳送;Windows 和 Linux 使用 Ctrl+Enter 傳送。" + }, + "hotkeys": "快捷鍵", + "inlineEditHotkey": { + "name": "內嵌編輯", + "descWithKey": "目前快捷鍵:{hotkey}", + "descNoKey": "未設定快捷鍵", + "btnChange": "變更", + "btnSet": "設定快捷鍵" + }, + "openChatHotkey": { + "name": "開啟聊天", + "descWithKey": "目前快捷鍵:{hotkey}", + "descNoKey": "未設定快捷鍵", + "btnChange": "變更", + "btnSet": "設定快捷鍵" + }, + "newSessionHotkey": { + "name": "新工作階段", + "descWithKey": "目前快捷鍵:{hotkey}", + "descNoKey": "未設定快捷鍵", + "btnChange": "變更", + "btnSet": "設定快捷鍵" + }, + "newTabHotkey": { + "name": "新分頁", + "descWithKey": "目前快捷鍵:{hotkey}", + "descNoKey": "未設定快捷鍵", + "btnChange": "變更", + "btnSet": "設定快捷鍵" + }, + "closeTabHotkey": { + "name": "關閉分頁", + "descWithKey": "目前快捷鍵:{hotkey}", + "descNoKey": "未設定快捷鍵", + "btnChange": "變更", + "btnSet": "設定快捷鍵" + }, + "slashCommands": { + "name": "命令與技能", + "desc": "管理儲存在 .claude/commands/ 和 .claude/skills/ 中的 Vault 級命令與技能。由 /名稱 觸發。" + }, + "hiddenSlashCommands": { + "name": "隱藏命令與技能", + "desc": "從下拉選單中隱藏特定的命令與技能。適用於隱藏與 Claudian 無關的 Claude Code 項目。每行輸入一個名稱,無需前導斜線。", + "placeholder": "commit\nbuild\ntest" + }, + "mcpServers": { + "name": "MCP 伺服器", + "desc": "管理儲存在 .claude/mcp.json 中的 Vault 級 MCP 伺服器配置。啟用上下文保存模式的伺服器需要 @ 提及才能啟用。" + }, + "plugins": { + "name": "Claude Code 外掛程式", + "desc": "管理從 ~/.claude/plugins 發現的 Claude Code 外掛程式。已啟用的外掛程式按 Vault 儲存在 .claude/settings.json 中。" + }, + "subagents": { + "name": "子代理", + "desc": "管理儲存在 .claude/agents/ 中的 Vault 級子代理。每個 Markdown 檔案定義一個自訂代理。", + "noAgents": "尚未設定子代理。點擊 + 建立一個。", + "deleteConfirm": "刪除子代理「{name}」?", + "saveFailed": "儲存子代理失敗:{message}", + "refreshFailed": "重新整理子代理失敗:{message}", + "deleteFailed": "刪除子代理失敗:{message}", + "renameCleanupFailed": "警告:無法移除「{name}」的舊檔案", + "created": "已建立子代理「{name}」", + "updated": "已更新子代理「{name}」", + "deleted": "已刪除子代理「{name}」", + "duplicateName": "已存在名為「{name}」的代理", + "descriptionRequired": "描述為必填", + "promptRequired": "系統提示詞為必填", + "modal": { + "titleEdit": "編輯子代理", + "titleAdd": "新增子代理", + "name": "名稱", + "nameDesc": "僅限小寫字母、數字與連字號", + "namePlaceholder": "code-reviewer", + "description": "描述", + "descriptionDesc": "此代理的簡短描述", + "descriptionPlaceholder": "檢查程式碼中的錯誤與風格問題", + "advancedOptions": "進階選項", + "model": "模型", + "modelDesc": "此代理的模型覆寫", + "tools": "工具", + "toolsDesc": "允許工具的逗號分隔清單(留空 = 全部)", + "disallowedTools": "禁用工具", + "disallowedToolsDesc": "要禁用的工具清單,以逗號分隔", + "skills": "技能", + "skillsDesc": "技能清單,以逗號分隔", + "prompt": "系統提示詞", + "promptDesc": "給代理的指示", + "promptPlaceholder": "你是一名程式碼審查員。請分析給定的程式碼..." + } + }, + "safety": "安全", + "loadUserSettings": { + "name": "載入使用者 Claude 設定", + "desc": "載入 ~/.claude/settings.json。啟用後,使用者的 Claude Code 權限規則可能繞過安全模式。" + }, + "claudeSafeMode": { + "name": "安全模式", + "desc": "啟用 Safe 開關時使用的權限模式。" + }, + "codexSafeMode": { + "name": "安全模式", + "desc": "啟用 Safe 開關時使用的沙盒模式。" + }, + "environment": "環境", + "customVariables": { + "name": "自訂變數", + "desc": "Claude SDK 的環境變數(KEY=VALUE 格式,每行一個)。支援 export 前綴。" + }, + "envSnippets": { + "name": "片段", + "addBtn": "新增片段", + "noSnippets": "尚無保存的環境變數片段。點擊 + 保存當前配置。", + "nameRequired": "請輸入片段名稱", + "modal": { + "titleEdit": "編輯片段", + "titleSave": "保存片段", + "name": "名稱", + "namePlaceholder": "此配置的描述性名稱", + "description": "描述", + "descPlaceholder": "可選描述", + "envVars": "環境變數", + "envVarsPlaceholder": "KEY=VALUE 格式,每行一個(支援 export 前綴)", + "save": "保存", + "update": "更新", + "cancel": "取消" + } + }, + "customModelOverrides": { + "name": "自訂模型覆寫", + "desc": "為自訂模型設定模型選擇器別名和上下文視窗大小。" + }, + "customModelAliases": { + "placeholder": "別名" + }, + "customContextLimits": { + "name": "自訂上下文限制", + "desc": "為您的自訂模型設定上下文視窗大小。留空使用預設值(200k 權杖)。", + "invalid": "格式無效。使用:256k、1m 或精確數量(1000-10000000)。" + }, + "customModels": { + "name": "自訂模型", + "desc": "向選擇器追加額外的 Claude 模型 ID,每行一個。環境中的模型覆寫仍會取代選擇器。", + "placeholder": "claude-opus-4-6\nclaude-opus-4-5-20251101" + }, + "enableChrome": { + "name": "啟用 Chrome 擴充功能", + "desc": "允許 Claude 透過 claude-in-chrome 擴充功能與 Chrome 互動。需要安裝該擴充功能。需要重新啟動工作階段。" + }, + "enableBangBash": { + "name": "啟用 bash 模式 (!)", + "desc": "在空白輸入框中輸入 ! 以進入 bash 模式。透過 Node.js child_process 直接執行指令。需要重新開啟檢視。", + "validation": { + "noNode": "在 PATH 中找不到 Node.js。請安裝 Node.js 或檢查 PATH 設定。" + } + }, + "maxTabs": { + "name": "最大聊天標籤數", + "desc": "同時開啟的最大聊天標籤數(3-10)。每個標籤使用獨立的 Claude 對話。", + "warning": "超過 5 個標籤可能會影響效能和記憶體使用。" + }, + "enableAutoScroll": { + "name": "串流傳輸時自動捲動", + "desc": "在 Claude 串流傳輸回應時自動捲動到底部。停用後將停留在頂部,從頭開始閱讀。" + }, + "deferMathRenderingDuringStreaming": { + "name": "串流傳輸時延遲渲染數學公式", + "desc": "回應串流傳輸時顯示原始 LaTeX,並在每個文字區塊完成後只渲染一次數學公式。" + }, + "expandFileEditsByDefault": { + "name": "預設展開檔案編輯", + "desc": "Write 和 Edit diff 首次出現時以展開狀態顯示。" + }, + "chatViewPlacement": { + "name": "Claudian 開啟位置", + "desc": "選擇新建聊天面板時的開啟位置", + "rightSidebar": "右側邊欄", + "leftSidebar": "左側邊欄", + "mainTab": "主編輯器分頁" + }, + "cliPath": { + "name": "Claude CLI 路徑", + "desc": "Claude Code CLI 的自訂路徑。留空使用自動檢測。", + "descWindows": "對於原生安裝程式,使用 claude.exe。對於 npm/pnpm/yarn 或其他套件管理器安裝,使用 cli-wrapper.cjs 路徑(不是 claude.cmd)。", + "descUnix": "貼上 \"which claude\" 的輸出 - 適用於原生安裝和 npm/pnpm/yarn 安裝。", + "validation": { + "notExist": "路徑不存在", + "isDirectory": "路徑是目錄,不是檔案" + } + }, + "language": { + "name": "語言", + "desc": "更改插件介面的顯示語言" + }, + "codex": { + "enableProvider": { + "name": "啟用 Codex 提供者", + "desc": "啟用後,Codex 模型會出現在新對話的模型選擇器中。現有 Codex 工作階段會保留。" + }, + "installationMethod": { + "name": "安裝方式", + "desc": "Claudian 在 Windows 上啟動 Codex 的方式。原生 Windows 使用 Windows 可執行檔路徑;WSL 會在所選發行版內啟動 Linux CLI。", + "nativeWindows": "原生 Windows", + "wsl": "WSL" + }, + "cliPath": { + "name": "Codex CLI 路徑", + "descUnix": "本機 Codex CLI 的自訂路徑。留空會優先使用已知 Codex 安裝,然後查找 PATH。", + "descWindows": "本機 Codex CLI 的自訂路徑。留空會從 PATH 自動偵測。請使用原生 Windows 可執行檔路徑,通常為 `codex.exe`。", + "descWsl": "在 WSL 內執行的 Linux 端 Codex 命令或絕對路徑。留空會在所選發行版內查找 PATH。", + "validation": { + "wslWindowsPath": "WSL 模式需要 Linux 命令或 Linux 絕對路徑,而不是 Windows 可執行檔路徑。" + } + }, + "wslDistroOverride": { + "name": "WSL 發行版覆寫", + "desc": "可選進階覆寫。留空時會盡可能從 WSL 工作區路徑推斷發行版,否則使用預設 WSL 發行版。" + }, + "safeMode": { + "workspaceWrite": "工作區可寫", + "readOnly": "唯讀" + }, + "customModels": { + "name": "自訂模型", + "desc": "向選擇器追加額外的 Codex 模型 ID,每行一個。設定 `OPENAI_MODEL` 時仍優先生效。", + "placeholder": "gpt-5.4\ngpt-5.3-codex-spark" + }, + "reasoningSummary": { + "name": "推理摘要", + "desc": "在思考區塊中顯示模型推理過程的摘要。", + "auto": "自動", + "concise": "簡潔", + "detailed": "詳細", + "off": "關閉" + }, + "skills": { + "name": "Codex 技能", + "desc": "管理儲存在 .codex/skills/ 或 .agents/skills/ 中的 Vault 層級 Codex 技能。此處不包含主目錄層級技能。", + "hiddenName": "隱藏技能", + "hiddenDesc": "從下拉選單中隱藏特定 Codex 技能。每行輸入一個技能名稱,不帶前導 $。", + "hiddenPlaceholder": "analyze\nexplain\nfix" + }, + "subagents": { + "name": "Codex 子代理", + "desc": "管理儲存在 .codex/agents/ 中的 Vault 層級 Codex 子代理。每個 TOML 檔案定義一個自訂代理。" + }, + "mcp": { + "descBeforeCommand": "Codex 透過自己的 CLI 管理 MCP 伺服器。請使用 ", + "descAfterCommand": " 進行設定,它們會在 Claudian 中可用。", + "learnMore": "了解更多" + }, + "environment": { + "name": "Codex 環境", + "desc": "僅限 Codex 擁有的執行階段變數。用於 OPENAI_* 和 CODEX_* 設定。如果 Codex 自動偵測需要協助,請把安裝目錄新增到共享 PATH,而不是此提供者環境。" + } + }, + "codexSkills": { + "modal": { + "titleEdit": "編輯 Codex 技能", + "titleAdd": "新增 Codex 技能", + "directory": "目錄", + "directoryDesc": "技能的儲存位置", + "skillName": "技能名稱", + "skillNameDesc": "在 $ 後使用的名稱(例如 $analyze 的名稱為「analyze」)", + "description": "描述", + "descriptionDesc": "下拉選單中顯示的可選描述", + "instructions": "指令", + "instructionsDesc": "技能指令(SKILL.md 內容)", + "instructionsPlaceholder": "Analyze the code for..." + }, + "instructionsRequired": "指令為必填項", + "saveFailed": "儲存 Codex 技能失敗", + "header": "Codex 技能", + "noSkills": "Vault 中沒有 Codex 技能。點擊 + 建立一個。", + "skillBadge": "技能", + "deleted": "已刪除 Codex 技能「{name}」", + "deleteFailed": "刪除 Codex 技能失敗", + "created": "已建立 Codex 技能「{name}」", + "updated": "已更新 Codex 技能「{name}」" + }, + "codexSubagents": { + "modal": { + "titleEdit": "編輯 Codex 子代理", + "titleAdd": "新增 Codex 子代理", + "nameDesc": "Codex 衍生代理時使用的代理名稱(小寫、連字符、底線)", + "descriptionDesc": "Codex 應在何時使用該代理", + "modelDesc": "模型覆寫(留空表示繼承)", + "namePlaceholder": "code_reviewer" + }, + "reasoningEffort": { + "name": "推理強度", + "desc": "模型推理強度級別", + "inherit": "繼承", + "low": "低", + "medium": "中", + "high": "高", + "xhigh": "極高" + }, + "sandboxMode": { + "name": "沙盒模式", + "desc": "該代理的沙盒限制", + "inherit": "繼承", + "readOnly": "唯讀", + "dangerFullAccess": "完整存取", + "workspaceWrite": "工作區可寫" + }, + "nicknameCandidates": { + "name": "暱稱候選", + "desc": "顯示暱稱,使用逗號分隔(例如 atlas, delta, echo)" + }, + "developerInstructions": { + "name": "開發者指令", + "desc": "定義代理行為的核心指令", + "placeholder": "Review code like an owner.\nPrioritize correctness, security, and missing test coverage.", + "required": "開發者指令為必填項" + }, + "validation": { + "nameRequired": "子代理名稱為必填項", + "nameTooLong": "子代理名稱必須不超過 {count} 個字元", + "nameInvalid": "子代理名稱只能包含小寫字母、數字、連字符和底線", + "nicknameInvalid": "暱稱候選只能包含 ASCII 字母、數字、空格、連字符和底線", + "nicknameDuplicate": "暱稱候選必須唯一" + }, + "header": "Codex 子代理", + "noAgents": "Vault 中沒有 Codex 子代理。點擊 + 建立一個。" + } + } +} diff --git a/src/i18n/types.ts b/src/i18n/types.ts new file mode 100644 index 0000000..293707a --- /dev/null +++ b/src/i18n/types.ts @@ -0,0 +1,15 @@ +import type * as en from './locales/en.json'; + +export type Locale = 'en' | 'zh-CN' | 'zh-TW' | 'ja' | 'ko' | 'de' | 'fr' | 'es' | 'ru' | 'pt'; + +type DotJoin = `${Head}.${Tail}`; + +type LeafKeys = { + [K in keyof T & string]: T[K] extends string + ? K + : T[K] extends Record + ? DotJoin> + : never; +}[keyof T & string]; + +export type TranslationKey = LeafKeys; diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..c003d68 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,703 @@ +// Must run before any SDK imports to patch Electron/Node.js realm incompatibility +import { patchSetMaxListenersForElectron } from './utils/electronCompat'; +patchSetMaxListenersForElectron(); + +import './providers'; + +import type { Editor, WorkspaceLeaf } from 'obsidian'; +import { MarkdownView, Notice, Plugin } from 'obsidian'; + +import { ConversationRepository } from './app/conversations/ConversationRepository'; +import { ClaudianProviderHost } from './app/providers/ClaudianProviderHost'; +import { DEFAULT_CLAUDIAN_SETTINGS } from './app/settings/defaultSettings'; +import type { ConditionalSettingsMutation } from './app/settings/SettingsCoordinator'; +import { SettingsCoordinator, type SettingsMutation } from './app/settings/SettingsCoordinator'; +import { SharedStorageService } from './app/storage/SharedStorageService'; +import type { SharedAppStorage } from './core/bootstrap/storage'; +import { + getEnvironmentVariablesForScope as getScopedEnvironmentVariables, + getRuntimeEnvironmentText, + setEnvironmentVariablesForScope, +} from './core/providers/providerEnvironment'; +import { ProviderRegistry } from './core/providers/ProviderRegistry'; +import { ProviderSettingsCoordinator } from './core/providers/ProviderSettingsCoordinator'; +import { ProviderWorkspaceRegistry } from './core/providers/ProviderWorkspaceRegistry'; +import type { + ProviderCliResolutionContext, + ProviderId, +} from './core/providers/types'; +import type { AppTabManagerState } from './core/providers/types'; +import { DEFAULT_CHAT_PROVIDER_ID } from './core/providers/types'; +import type { + ClaudianSettings, + Conversation, + ConversationMeta, +} from './core/types'; +import { + VIEW_TYPE_CLAUDIAN, +} from './core/types'; +import type { ChatViewPlacement, EnvironmentScope } from './core/types/settings'; +import { ClaudianView } from './features/chat/ClaudianView'; +import { type InlineEditContext, InlineEditModal } from './features/inline-edit/ui/InlineEditModal'; +import { ClaudianSettingTab } from './features/settings/ClaudianSettings'; +import { setLocale } from './i18n/i18n'; +import type { Locale } from './i18n/types'; +import { OPENCODE_PLAN_MODE_ID, OPENCODE_SAFE_MODE_ID } from './providers/opencode/modes'; +import { buildCursorContext } from './utils/editor'; +import { revealWorkspaceLeaf } from './utils/obsidianCompat'; +import { getVaultPath } from './utils/path'; + +function isClaudianView(value: unknown): value is ClaudianView { + return !!value + && typeof value === 'object' + && typeof (value as { getTabManager?: unknown }).getTabManager === 'function'; +} + +export default class ClaudianPlugin extends Plugin { + settings!: ClaudianSettings; + storage!: SharedAppStorage; + readonly providerHost = new ClaudianProviderHost(this); + private settingsCoordinator!: SettingsCoordinator; + private conversationRepository!: ConversationRepository; + private lastKnownTabManagerState: AppTabManagerState | null = null; + + async onload() { + await this.loadSettings(); + await ProviderWorkspaceRegistry.initializeAll(this.providerHost); + + this.registerView( + VIEW_TYPE_CLAUDIAN, + (leaf) => new ClaudianView(leaf, this) + ); + + this.addRibbonIcon('bot', 'Open Claudian', () => { + void this.activateView(); + }); + + this.addCommand({ + id: 'open-view', + name: 'Open chat view', + callback: () => { + void this.activateView(); + }, + }); + + this.addCommand({ + id: 'inline-edit', + name: 'Inline edit', + editorCallback: async (editor: Editor, ctx) => { + const view = ctx instanceof MarkdownView + ? ctx + : this.app.workspace.getActiveViewOfType(MarkdownView); + if (!view) { + new Notice('Inline edit unavailable: could not access the active Markdown view.'); + return; + } + + const selectedText = editor.getSelection(); + const notePath = view.file?.path || 'unknown'; + + let editContext: InlineEditContext; + if (selectedText.trim()) { + editContext = { mode: 'selection', selectedText }; + } else { + const cursor = editor.getCursor(); + const cursorContext = buildCursorContext( + (line) => editor.getLine(line), + editor.lineCount(), + cursor.line, + cursor.ch + ); + editContext = { mode: 'cursor', cursorContext }; + } + + const modal = new InlineEditModal( + this.app, + this, + editor, + view, + editContext, + notePath, + () => this.getView()?.getActiveTab()?.ui.externalContextSelector?.getExternalContexts() ?? [] + ); + const result = await modal.openAndWait(); + + if (result.decision === 'accept' && result.editedText !== undefined) { + new Notice(editContext.mode === 'cursor' ? 'Inserted' : 'Edit applied'); + } + }, + }); + + this.addCommand({ + id: 'new-tab', + name: 'New tab', + checkCallback: (checking: boolean) => { + if (!this.canCreateNewTab()) return false; + + if (!checking) { + void this.openNewTab(); + } + return true; + }, + }); + + this.addCommand({ + id: 'new-session', + name: 'New session (in current tab)', + checkCallback: (checking: boolean) => { + const view = this.getView(); + if (!view) return false; + + const tabManager = view.getTabManager(); + if (!tabManager) return false; + + const activeTab = tabManager.getActiveTab(); + if (!activeTab) return false; + + if (activeTab.state.isStreaming) return false; + + if (!checking) { + void tabManager.createNewConversation(); + } + return true; + }, + }); + + this.addCommand({ + id: 'close-current-tab', + name: 'Close current tab', + checkCallback: (checking: boolean) => { + const view = this.getView(); + if (!view) return false; + + const tabManager = view.getTabManager(); + if (!tabManager) return false; + + if (!checking) { + const activeTabId = tabManager.getActiveTabId(); + if (activeTabId) { + void tabManager.closeTab(activeTabId); + } + } + return true; + }, + }); + + this.addSettingTab(new ClaudianSettingTab(this.app, this)); + } + + onunload(): void { + void this.persistOpenTabStates(); + } + + private async persistOpenTabStates(): Promise { + // Ensures state is saved even if Obsidian quits without calling onClose() + for (const view of this.getAllViews()) { + const tabManager = view.getTabManager(); + if (tabManager) { + const state = tabManager.getPersistedState(); + await this.persistTabManagerState(state); + } + } + } + + async activateView() { + const { workspace } = this.app; + let leaf = workspace.getLeavesOfType(VIEW_TYPE_CLAUDIAN)[0]; + + if (!leaf) { + const newLeaf = this.getLeafForPlacement(this.settings.chatViewPlacement); + if (newLeaf) { + await newLeaf.setViewState({ + type: VIEW_TYPE_CLAUDIAN, + active: true, + }); + leaf = newLeaf; + } + } + + if (leaf) { + await revealWorkspaceLeaf(workspace, leaf); + } + } + + private getLeafForPlacement(placement: ChatViewPlacement): WorkspaceLeaf | null { + const { workspace } = this.app; + switch (placement) { + case 'main-tab': + return workspace.getLeaf('tab'); + case 'left-sidebar': + return workspace.getLeftLeaf(false); + case 'right-sidebar': + return workspace.getRightLeaf(false); + } + } + + private canCreateNewTab(): boolean { + const hasClaudianLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDIAN).length > 0; + const view = this.getView(); + const tabManager = view?.getTabManager(); + + if (tabManager) { + return tabManager.canCreateTab(); + } + + if (hasClaudianLeaf) { + return false; + } + + return this.getLastKnownOpenTabCount() < this.getMaxTabsLimit(); + } + + private async ensureViewOpen(): Promise { + const existingView = this.getView(); + if (existingView) { + return existingView; + } + + await this.activateView(); + return this.getView(); + } + + private async openNewTab(): Promise { + const existingView = this.getView(); + if (existingView) { + await existingView.createNewTab(); + return; + } + + const restoredTabCount = this.getLastKnownOpenTabCount(); + const view = await this.ensureViewOpen(); + if (!view) { + return; + } + + // A cold-open view creates its initial tab during restore. Avoid stacking + // an extra blank tab on top when there was no prior layout to restore. + if (restoredTabCount === 0) { + return; + } + + await view.createNewTab(); + } + + async loadSettings() { + this.storage = new SharedStorageService(this); + const { claudian } = await this.storage.initialize(); + this.lastKnownTabManagerState = await this.storage.getTabManagerState(); + + this.settings = { + ...DEFAULT_CLAUDIAN_SETTINGS, + ...claudian, + }; + this.settingsCoordinator = new SettingsCoordinator( + this.settings, + async (settings) => { + ProviderSettingsCoordinator.normalizeProviderSelection(settings); + ProviderSettingsCoordinator.persistProjectedProviderState(settings); + await this.storage.saveClaudianSettings(settings); + }, + ); + this.conversationRepository = new ConversationRepository({ + getSettings: () => this.settings, + getVaultPath: () => getVaultPath(this.app), + sessions: this.storage.sessions, + onConversationDeleted: (conversationId) => this.resetDeletedConversationTabs(conversationId), + }); + + // Plan mode is ephemeral — normalize back to normal on load so the app + // doesn't start stuck in plan mode after a restart (prePlanPermissionMode is lost) + if (this.settings.permissionMode === 'plan') { + this.settings.permissionMode = 'normal'; + } + if ( + this.settings.savedProviderPermissionMode + && typeof this.settings.savedProviderPermissionMode === 'object' + && !Array.isArray(this.settings.savedProviderPermissionMode) + ) { + for (const [providerId, mode] of Object.entries(this.settings.savedProviderPermissionMode)) { + if (mode === 'plan') { + this.settings.savedProviderPermissionMode[providerId] = 'normal'; + } + } + } + const opencodeConfig = this.settings.providerConfigs?.opencode; + if ( + opencodeConfig + && typeof opencodeConfig === 'object' + && !Array.isArray(opencodeConfig) + && opencodeConfig.selectedMode === OPENCODE_PLAN_MODE_ID + ) { + opencodeConfig.selectedMode = OPENCODE_SAFE_MODE_ID; + } + + const didNormalizeProviderSelection = ProviderSettingsCoordinator.normalizeProviderSelection( + this.settings, + ); + const didNormalizeModelVariants = this.normalizeModelVariantSettings(); + + const allMetadata = await this.storage.sessions.listMetadata(); + this.conversationRepository.replaceAll(allMetadata.map(meta => { + const resumeSessionId = meta.sessionId !== undefined ? meta.sessionId : meta.id; + + return { + id: meta.id, + providerId: meta.providerId ?? DEFAULT_CHAT_PROVIDER_ID, + title: meta.title, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + lastResponseAt: meta.lastResponseAt, + sessionId: resumeSessionId, + selectedModel: meta.selectedModel, + providerState: meta.providerState, + messages: [], + currentNote: meta.currentNote, + externalContextPaths: meta.externalContextPaths, + enabledMcpServers: meta.enabledMcpServers, + usage: meta.usage, + titleGenerationStatus: meta.titleGenerationStatus, + resumeAtMessageId: meta.resumeAtMessageId, + }; + }).sort( + (a, b) => (b.lastResponseAt ?? b.updatedAt) - (a.lastResponseAt ?? a.updatedAt) + )); + setLocale(this.settings.locale as Locale); + + const backfilledConversations = this.conversationRepository.backfillResponseTimestamps(); + + const { changed, invalidatedConversations } = this.reconcileModelWithEnvironment(); + + ProviderSettingsCoordinator.projectActiveProviderState( + this.settings, + ); + + if (changed || didNormalizeModelVariants || didNormalizeProviderSelection) { + await this.saveSettings(); + } + + const conversationsToSave = new Set([...backfilledConversations, ...invalidatedConversations]); + for (const conv of conversationsToSave) { + await this.storage.sessions.saveMetadata( + this.storage.sessions.toSessionMetadata(conv) + ); + } + } + + normalizeModelVariantSettings(): boolean { + return ProviderSettingsCoordinator.normalizeAllModelVariants( + this.settings, + ); + } + + async saveSettings() { + await this.settingsCoordinator.persistCurrent(); + } + + async mutateSettings(mutation: SettingsMutation): Promise { + await this.settingsCoordinator.mutate(mutation); + } + + async mutateSettingsConditionally( + mutation: ConditionalSettingsMutation, + ): Promise { + await this.settingsCoordinator.mutateConditionally(mutation); + } + + /** Updates and persists environment variables, restarting processes to apply changes. */ + async applyEnvironmentVariables(scope: EnvironmentScope, envText: string): Promise { + await this.applyEnvironmentVariablesBatch([{ scope, envText }]); + } + + async applyEnvironmentVariablesBatch( + updates: Array<{ scope: EnvironmentScope; envText: string }>, + ): Promise { + const nextEnvironmentByScope = new Map(); + for (const update of updates) { + nextEnvironmentByScope.set(update.scope, update.envText); + } + + let affectedProviderIds: ProviderId[] = []; + let changed = false; + let invalidatedConversations: Conversation[] = []; + await this.mutateSettings((settings) => { + const settingsBag = settings as unknown as Record; + const changedScopes: EnvironmentScope[] = []; + for (const [scope, envText] of nextEnvironmentByScope) { + const currentValue = getScopedEnvironmentVariables(settingsBag, scope); + if (currentValue !== envText) { + changedScopes.push(scope); + } + setEnvironmentVariablesForScope(settingsBag, scope, envText); + } + affectedProviderIds = this.getAffectedEnvironmentProviders(changedScopes); + ProviderSettingsCoordinator.handleEnvironmentChange(settingsBag, affectedProviderIds); + const reconciliation = this.reconcileModelWithEnvironment(affectedProviderIds); + changed = reconciliation.changed; + invalidatedConversations = reconciliation.invalidatedConversations; + }); + + if (affectedProviderIds.length === 0) { + return; + } + + const modelCatalogDiagnostics: string[] = []; + for (const providerId of affectedProviderIds) { + if (ProviderRegistry.isEnabled(providerId, this.settings)) { + const result = await ProviderWorkspaceRegistry.refreshModelCatalog(providerId); + if (result.diagnostics) { + modelCatalogDiagnostics.push( + `${ProviderRegistry.getProviderDisplayName(providerId)}: ${result.diagnostics}`, + ); + } + await ProviderWorkspaceRegistry.refreshAgentMentions(providerId); + } + } + if (invalidatedConversations.length > 0) { + for (const conv of invalidatedConversations) { + await this.storage.sessions.saveMetadata( + this.storage.sessions.toSessionMetadata(conv) + ); + } + } + + const openViews = this.getAllViews(); + let failedTabs = 0; + for (const openView of openViews) { + failedTabs += await this.restartEnvironmentAffectedRuntimes( + openView, + affectedProviderIds, + changed, + ); + openView.invalidateProviderCommandCaches(affectedProviderIds); + openView.refreshModelSelector(); + } + if (failedTabs > 0) { + new Notice(`Environment changes applied, but ${failedTabs} affected tab(s) failed to restart.`); + } + + const noticeText = changed + ? 'Environment variables applied. Sessions will be rebuilt on next message.' + : 'Environment variables applied.'; + new Notice(noticeText); + if (modelCatalogDiagnostics.length > 0) { + new Notice(`Model catalog refresh failed:\n${modelCatalogDiagnostics.join('\n')}`); + } + } + + private async restartEnvironmentAffectedRuntimes( + view: ClaudianView, + affectedProviderIds: ProviderId[], + resetSessions: boolean, + ): Promise { + const tabManager = view.getTabManager(); + if (!tabManager) return 0; + + const affectedTabs = tabManager.getAllTabs().filter((tab) => ( + affectedProviderIds.includes(tab.providerId ?? DEFAULT_CHAT_PROVIDER_ID) + )); + const syncTabRuntimeState = (tab: (typeof affectedTabs)[number]): void => { + if (!tab.service || !tab.serviceInitialized) return; + + const conversation = tab.conversationId + ? this.getConversationSync(tab.conversationId) + : null; + const hasConversationContext = (conversation?.messages.length ?? 0) > 0; + const externalContextPaths = tab.ui.externalContextSelector?.getExternalContexts() + ?? (hasConversationContext + ? conversation?.externalContextPaths ?? [] + : this.settings.persistentExternalContextPaths ?? []); + + tab.service.syncConversationState(conversation, externalContextPaths); + }; + + for (const tab of affectedTabs) { + if (tab.state.isStreaming) { + tab.controllers.inputController?.cancelStreaming(); + } + } + + let failedTabs = 0; + for (const tab of affectedTabs) { + if (!tab.service || !tab.serviceInitialized) continue; + try { + syncTabRuntimeState(tab); + if (resetSessions) { + tab.service.resetSession(); + await tab.service.ensureReady(); + } else { + await tab.service.ensureReady({ force: true }); + } + } catch { + failedTabs++; + } + } + return failedTabs; + } + + /** Returns the runtime environment variables (fixed at plugin load). */ + getActiveEnvironmentVariables( + providerId: ProviderId = ProviderRegistry.resolveSettingsProviderId( + this.settings, + ), + ): string { + return getRuntimeEnvironmentText( + this.settings, + providerId, + ); + } + + getEnvironmentVariablesForScope(scope: EnvironmentScope): string { + return getScopedEnvironmentVariables( + this.settings, + scope, + ); + } + + getResolvedProviderCliPath( + providerId: ProviderId, + context?: ProviderCliResolutionContext, + ): string | null { + const cliResolver = ProviderWorkspaceRegistry.getCliResolver(providerId); + if (!cliResolver) { + return null; + } + + return cliResolver.resolveFromSettings(this.settings, context); + } + + private reconcileModelWithEnvironment(providerIds: ProviderId[] = ProviderRegistry.getRegisteredProviderIds()): { + changed: boolean; + invalidatedConversations: Conversation[]; + } { + return ProviderSettingsCoordinator.reconcileProviders( + this.settings, + this.conversationRepository.getAll(), + providerIds, + ); + } + + private getAffectedEnvironmentProviders(scopes: EnvironmentScope[]): ProviderId[] { + const registeredProviderIds = new Set(ProviderRegistry.getRegisteredProviderIds()); + const affectedProviderIds = new Set(); + + for (const scope of scopes) { + if (scope === 'shared') { + for (const providerId of registeredProviderIds) { + affectedProviderIds.add(providerId); + } + continue; + } + + const providerId = scope.slice('provider:'.length); + if (registeredProviderIds.has(providerId)) { + affectedProviderIds.add(providerId); + } + } + + return Array.from(affectedProviderIds); + } + + async createConversation(options?: { + providerId?: ProviderId; + sessionId?: string; + selectedModel?: string; + }): Promise { + return this.conversationRepository.create(options); + } + + async switchConversation(id: string): Promise { + return this.conversationRepository.switchTo(id); + } + + async deleteConversation( + id: string, + options: { deleteProviderSession?: boolean } = {}, + ): Promise { + await this.conversationRepository.delete(id, options); + } + + private async resetDeletedConversationTabs(id: string): Promise { + for (const view of this.getAllViews()) { + const tabManager = view.getTabManager(); + if (!tabManager) continue; + + for (const tab of tabManager.getAllTabs()) { + if (tab.conversationId === id) { + tab.controllers.inputController?.cancelStreaming(); + await tab.controllers.conversationController?.createNew({ force: true }); + } + } + } + } + + async handleMissingProviderSession( + id: string, + missingProviderSessionId?: string, + ): Promise<'deleted' | 'reset' | 'preserved' | 'not_found'> { + return this.conversationRepository.handleMissingProviderSession(id, missingProviderSessionId); + } + + async renameConversation(id: string, title: string): Promise { + await this.conversationRepository.rename(id, title); + } + + async updateConversation(id: string, updates: Partial): Promise { + await this.conversationRepository.update(id, updates); + } + + async getConversationById(id: string): Promise { + return this.conversationRepository.getById(id); + } + + getConversationSync(id: string): Conversation | null { + return this.conversationRepository.getSync(id); + } + + findEmptyConversation(): Conversation | null { + return this.conversationRepository.findEmpty(); + } + + getConversationList(): ConversationMeta[] { + return this.conversationRepository.list(); + } + + async persistTabManagerState(state: AppTabManagerState): Promise { + this.lastKnownTabManagerState = state; + await this.storage.setTabManagerState(state); + } + + getView(): ClaudianView | null { + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDIAN); + return leaves.map(leaf => leaf.view).find(isClaudianView) ?? null; + } + + getAllViews(): ClaudianView[] { + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_CLAUDIAN); + return leaves.map(leaf => leaf.view).filter(isClaudianView); + } + + findConversationAcrossViews(conversationId: string): { view: ClaudianView; tabId: string } | null { + for (const view of this.getAllViews()) { + const tabManager = view.getTabManager(); + if (!tabManager) continue; + + const tabs = tabManager.getAllTabs(); + for (const tab of tabs) { + if (tab.conversationId === conversationId) { + return { view, tabId: tab.id }; + } + } + } + return null; + } + + private getLastKnownOpenTabCount(): number { + return this.lastKnownTabManagerState?.openTabs.length ?? 0; + } + + private getMaxTabsLimit(): number { + const maxTabs = this.settings.maxTabs ?? 3; + return Math.max(3, Math.min(10, maxTabs)); + } + +} diff --git a/src/providers/acp/AcpClientConnection.ts b/src/providers/acp/AcpClientConnection.ts new file mode 100644 index 0000000..adfae17 --- /dev/null +++ b/src/providers/acp/AcpClientConnection.ts @@ -0,0 +1,361 @@ +import type { AcpJsonRpcTransport, JsonRpcRequestOptions } from './AcpJsonRpcTransport'; +import { JsonRpcErrorResponse } from './AcpJsonRpcTransport'; +import { + ACP_SERVER_NOTIFICATION_ALIASES, + ACP_SERVER_REQUEST_ALIASES, + type AcpLogicalMethod, + type AcpMethodOverrides, + getAcpMethodCandidates, +} from './methodNames'; +import type { + AcpAuthenticateRequest, + AcpAuthenticateResponse, + AcpCancelNotification, + AcpClientCapabilities, + AcpCreateTerminalRequest, + AcpCreateTerminalResponse, + AcpImplementation, + AcpInitializeRequest, + AcpInitializeResponse, + AcpKillTerminalRequest, + AcpKillTerminalResponse, + AcpListSessionsRequest, + AcpListSessionsResponse, + AcpLoadSessionRequest, + AcpLoadSessionResponse, + AcpNewSessionRequest, + AcpNewSessionResponse, + AcpPromptRequest, + AcpPromptResponse, + AcpReadTextFileRequest, + AcpReadTextFileResponse, + AcpReleaseTerminalRequest, + AcpReleaseTerminalResponse, + AcpRequestPermissionRequest, + AcpRequestPermissionResponse, + AcpSessionNotification, + AcpSetSessionConfigOptionRequest, + AcpSetSessionConfigOptionResponse, + AcpSetSessionModeRequest, + AcpSetSessionModeResponse, + AcpTerminalOutputRequest, + AcpTerminalOutputResponse, + AcpWaitForTerminalExitRequest, + AcpWaitForTerminalExitResponse, + AcpWriteTextFileRequest, + AcpWriteTextFileResponse, +} from './types'; + +type SessionNotificationListener = ( + notification: AcpSessionNotification, +) => void | Promise; + +// ACP prompt turns are long-running RPCs; session/update notifications stream progress until the final response. +const ACP_PROMPT_TURN_TIMEOUT_MS = 0; + +export interface AcpFileSystemDelegate { + readTextFile?: (request: AcpReadTextFileRequest) => Promise; + writeTextFile?: (request: AcpWriteTextFileRequest) => Promise; +} + +export interface AcpTerminalDelegate { + createTerminal: (request: AcpCreateTerminalRequest) => Promise; + killTerminal: (request: AcpKillTerminalRequest) => Promise; + releaseTerminal: (request: AcpReleaseTerminalRequest) => Promise; + terminalOutput: (request: AcpTerminalOutputRequest) => Promise; + waitForTerminalExit: ( + request: AcpWaitForTerminalExitRequest, + ) => Promise; +} + +export interface AcpClientConnectionDelegate { + fileSystem?: AcpFileSystemDelegate; + onSessionNotification?: SessionNotificationListener; + requestPermission?: ( + request: AcpRequestPermissionRequest, + ) => Promise; + terminal?: AcpTerminalDelegate; +} + +export interface AcpClientConnectionOptions { + clientCapabilities?: Partial; + clientInfo?: AcpImplementation | null; + delegate?: AcpClientConnectionDelegate; + methodOverrides?: AcpMethodOverrides; + transport: AcpJsonRpcTransport; +} + +export class AcpClientConnection { + private agentInfo: AcpInitializeResponse['agentInfo'] | null = null; + private agentCapabilities: AcpInitializeResponse['agentCapabilities'] | null = null; + private authMethods: AcpInitializeResponse['authMethods'] | null = null; + private readonly methodCache = new Map(); + private readonly sessionNotificationListeners = new Set(); + private readonly unsubscribeHandlers: Array<() => void> = []; + + constructor(private readonly options: AcpClientConnectionOptions) { + this.registerServerHandlers(); + } + + get signal(): AbortSignal { + return this.options.transport.signal; + } + + get negotiatedAgentInfo(): AcpInitializeResponse['agentInfo'] | null { + return this.agentInfo; + } + + get negotiatedAgentCapabilities(): AcpInitializeResponse['agentCapabilities'] | null { + return this.agentCapabilities; + } + + get negotiatedAuthMethods(): AcpInitializeResponse['authMethods'] | null { + return this.authMethods; + } + + onSessionNotification(listener: SessionNotificationListener): () => void { + this.sessionNotificationListeners.add(listener); + return () => { + this.sessionNotificationListeners.delete(listener); + }; + } + + dispose(): void { + while (this.unsubscribeHandlers.length > 0) { + this.unsubscribeHandlers.pop()?.(); + } + this.sessionNotificationListeners.clear(); + } + + async initialize( + partialRequest: Partial = {}, + ): Promise { + const request: AcpInitializeRequest = { + clientCapabilities: mergeCapabilities( + this.buildClientCapabilities(), + partialRequest.clientCapabilities, + ), + clientInfo: partialRequest.clientInfo ?? this.options.clientInfo ?? null, + protocolVersion: partialRequest.protocolVersion ?? 1, + }; + + const response = await this.requestWithFallback('initialize', request); + this.agentInfo = response.agentInfo ?? null; + this.agentCapabilities = response.agentCapabilities ?? null; + this.authMethods = response.authMethods ?? null; + return response; + } + + authenticate(request: AcpAuthenticateRequest): Promise { + return this.requestWithFallback('authenticate', request); + } + + newSession(request: AcpNewSessionRequest): Promise { + return this.requestWithFallback('newSession', request); + } + + loadSession(request: AcpLoadSessionRequest): Promise { + return this.requestWithFallback('loadSession', request); + } + + listSessions(request: AcpListSessionsRequest = {}): Promise { + return this.requestWithFallback('listSessions', request); + } + + prompt(request: AcpPromptRequest): Promise { + return this.requestWithFallback('prompt', request, { + timeoutMs: ACP_PROMPT_TURN_TIMEOUT_MS, + }); + } + + cancel(notification: AcpCancelNotification): void { + this.notifyLogicalMethod('cancel', notification, { sendAllCandidatesIfUncached: true }); + } + + setMode(request: AcpSetSessionModeRequest): Promise { + return this.requestWithFallback('setMode', request); + } + + setConfigOption( + request: AcpSetSessionConfigOptionRequest, + ): Promise { + return this.requestWithFallback('setConfigOption', request); + } + + private buildClientCapabilities(): AcpClientCapabilities | undefined { + const capabilities: AcpClientCapabilities = { ...this.options.clientCapabilities }; + const fileSystem = this.options.delegate?.fileSystem; + const terminal = this.options.delegate?.terminal; + + if (fileSystem?.readTextFile || fileSystem?.writeTextFile) { + capabilities.fs = { + ...capabilities.fs, + ...(fileSystem.readTextFile ? { readTextFile: true } : {}), + ...(fileSystem.writeTextFile ? { writeTextFile: true } : {}), + }; + } + + if (terminal) { + capabilities.terminal = true; + } + + return Object.keys(capabilities).length === 0 ? undefined : capabilities; + } + + private registerServerHandlers(): void { + const transport = this.options.transport; + const delegate = this.options.delegate; + + const subscribeNotification = (aliases: readonly string[], handler: (params: unknown) => Promise): void => { + for (const alias of aliases) { + this.unsubscribeHandlers.push(transport.onNotification(alias, handler)); + } + }; + const subscribeRequest = (aliases: readonly string[], handler: (params: unknown) => Promise): void => { + for (const alias of aliases) { + this.unsubscribeHandlers.push(transport.onRequest(alias, handler)); + } + }; + + subscribeNotification( + ACP_SERVER_NOTIFICATION_ALIASES.sessionUpdate, + async (params) => this.dispatchSessionNotification(params as AcpSessionNotification), + ); + + if (delegate?.requestPermission) { + const requestPermission = delegate.requestPermission; + subscribeRequest( + ACP_SERVER_REQUEST_ALIASES.requestPermission, + (params) => requestPermission(params as AcpRequestPermissionRequest), + ); + } + + const fileSystem = delegate?.fileSystem; + if (fileSystem?.readTextFile) { + const readTextFile = fileSystem.readTextFile; + subscribeRequest( + ACP_SERVER_REQUEST_ALIASES.readTextFile, + (params) => readTextFile(params as AcpReadTextFileRequest), + ); + } + if (fileSystem?.writeTextFile) { + const writeTextFile = fileSystem.writeTextFile; + subscribeRequest( + ACP_SERVER_REQUEST_ALIASES.writeTextFile, + (params) => writeTextFile(params as AcpWriteTextFileRequest), + ); + } + + const terminal = delegate?.terminal; + if (terminal) { + subscribeRequest( + ACP_SERVER_REQUEST_ALIASES.createTerminal, + (params) => terminal.createTerminal(params as AcpCreateTerminalRequest), + ); + subscribeRequest( + ACP_SERVER_REQUEST_ALIASES.terminalOutput, + (params) => terminal.terminalOutput(params as AcpTerminalOutputRequest), + ); + subscribeRequest( + ACP_SERVER_REQUEST_ALIASES.waitForTerminalExit, + (params) => terminal.waitForTerminalExit(params as AcpWaitForTerminalExitRequest), + ); + subscribeRequest( + ACP_SERVER_REQUEST_ALIASES.killTerminal, + (params) => terminal.killTerminal(params as AcpKillTerminalRequest), + ); + subscribeRequest( + ACP_SERVER_REQUEST_ALIASES.releaseTerminal, + (params) => terminal.releaseTerminal(params as AcpReleaseTerminalRequest), + ); + } + } + + private async dispatchSessionNotification(notification: AcpSessionNotification): Promise { + if (this.options.delegate?.onSessionNotification) { + await this.options.delegate.onSessionNotification(notification); + } + + for (const listener of this.sessionNotificationListeners) { + await listener(notification); + } + } + + // -32601 (Method not found) is the only error we absorb; agents that advertise legacy + // method names only reject unknown candidates with it, so every other code is real. + private async requestWithFallback( + logicalMethod: AcpLogicalMethod, + params?: unknown, + requestOptions?: JsonRpcRequestOptions, + ): Promise { + const cachedMethod = this.methodCache.get(logicalMethod); + if (cachedMethod) { + return this.options.transport.request(cachedMethod, params, requestOptions); + } + + const candidates = getAcpMethodCandidates(logicalMethod, this.options.methodOverrides); + let lastError: Error | null = null; + + for (const methodName of candidates) { + try { + const result = await this.options.transport.request(methodName, params, requestOptions); + this.methodCache.set(logicalMethod, methodName); + return result; + } catch (error) { + if (!(error instanceof JsonRpcErrorResponse) || error.code !== -32601) { + throw error; + } + lastError = error; + } + } + + if (lastError) { + throw lastError; + } + + throw new Error(`No ACP method candidates configured for ${logicalMethod}`); + } + + private notifyLogicalMethod( + logicalMethod: AcpLogicalMethod, + params?: unknown, + options: { sendAllCandidatesIfUncached?: boolean } = {}, + ): void { + const cachedMethod = this.methodCache.get(logicalMethod); + if (cachedMethod) { + this.options.transport.notify(cachedMethod, params); + return; + } + + // Notifications get no response, so we cannot probe. Fan out to every candidate + // when the caller explicitly opts in (e.g. cancel, which must reach the agent). + const candidates = getAcpMethodCandidates(logicalMethod, this.options.methodOverrides); + const methodNames = options.sendAllCandidatesIfUncached + ? Array.from(new Set(candidates)) + : candidates.slice(0, 1); + + for (const methodName of methodNames) { + this.options.transport.notify(methodName, params); + } + } +} + +function mergeCapabilities( + base: AcpClientCapabilities | undefined, + override: AcpClientCapabilities | undefined, +): AcpClientCapabilities | undefined { + if (!base && !override) { + return undefined; + } + + const merged: AcpClientCapabilities = { ...base, ...override }; + + if (base?.auth || override?.auth) { + merged.auth = { ...base?.auth, ...override?.auth }; + } + if (base?.fs || override?.fs) { + merged.fs = { ...base?.fs, ...override?.fs }; + } + + return Object.keys(merged).length === 0 ? undefined : merged; +} diff --git a/src/providers/acp/AcpJsonRpcTransport.ts b/src/providers/acp/AcpJsonRpcTransport.ts new file mode 100644 index 0000000..8908ecc --- /dev/null +++ b/src/providers/acp/AcpJsonRpcTransport.ts @@ -0,0 +1,431 @@ +import { createInterface, type Interface } from 'node:readline'; + +import type { AcpRequestId } from './types'; + +const DEFAULT_TIMEOUT_MS = 30_000; + +interface JsonRpcRequestMessage { + id: AcpRequestId; + jsonrpc: '2.0'; + method: string; + params?: unknown; +} + +interface JsonRpcNotificationMessage { + jsonrpc: '2.0'; + method: string; + params?: unknown; +} + +interface JsonRpcResponseMessage { + error?: { + code: number; + data?: unknown; + message: string; + }; + id: AcpRequestId; + jsonrpc: '2.0'; + result?: unknown; +} + +type JsonRpcMessage = + | JsonRpcRequestMessage + | JsonRpcNotificationMessage + | JsonRpcResponseMessage; + +export interface JsonRpcMessageStreams { + input: NodeJS.ReadableStream; + onClose?: (listener: (error?: Error) => void) => () => void; + output: NodeJS.WritableStream; +} + +export interface JsonRpcRequestOptions { + signal?: AbortSignal; + timeoutMs?: number; +} + +type JsonRpcNotificationHandler = (params: unknown) => void | Promise; +type JsonRpcRequestHandler = (params: unknown) => Promise; + +interface PendingRequest { + cleanup: () => void; + method: string; + reject: (error: Error) => void; + resolve: (result: unknown) => void; +} + +export class JsonRpcTransportClosedError extends Error { + constructor(message = 'JSON-RPC transport closed') { + super(message); + this.name = 'JsonRpcTransportClosedError'; + } +} + +export class JsonRpcErrorResponse extends Error { + constructor( + readonly method: string, + readonly code: number, + message: string, + readonly data?: unknown, + ) { + super(message); + this.name = 'JsonRpcErrorResponse'; + } +} + +export class AcpJsonRpcTransport { + private readonly abortController = new AbortController(); + private readonly closeListeners = new Set<(error?: Error) => void>(); + private disposed = false; + private nextId = 1; + private readonly notificationHandlers = new Map>(); + private readonly pending = new Map(); + private readline: Interface | null = null; + private readonly requestHandlers = new Map(); + private readonly streamUnsubscribers: Array<() => void> = []; + private unregisterClose?: () => void; + + constructor( + private readonly streams: JsonRpcMessageStreams, + private readonly defaultTimeoutMs = DEFAULT_TIMEOUT_MS, + ) {} + + get signal(): AbortSignal { + return this.abortController.signal; + } + + get isClosed(): boolean { + return this.disposed; + } + + start(): void { + if (this.readline || this.disposed) { + return; + } + + this.readline = createInterface({ + crlfDelay: Infinity, + input: this.streams.input, + }); + this.readline.on('line', line => this.handleLine(line)); + this.readline.on('close', () => { + if (!this.disposed) { + this.dispose(new JsonRpcTransportClosedError('JSON-RPC input closed')); + } + }); + + this.streamUnsubscribers.push( + subscribeStreamEvent(this.streams.input, 'error', (error?: unknown) => { + if (!this.disposed) { + this.dispose(error instanceof Error + ? error + : new JsonRpcTransportClosedError('JSON-RPC input error')); + } + }), + subscribeStreamEvent(this.streams.output, 'close', () => { + if (!this.disposed) { + this.dispose(new JsonRpcTransportClosedError('JSON-RPC output closed')); + } + }), + subscribeStreamEvent(this.streams.output, 'error', (error?: unknown) => { + if (!this.disposed) { + this.dispose(error instanceof Error + ? error + : new JsonRpcTransportClosedError('JSON-RPC output error')); + } + }), + ); + + this.unregisterClose = this.streams.onClose?.((error) => { + if (!this.disposed) { + this.dispose(error ?? new JsonRpcTransportClosedError()); + } + }); + } + + onClose(listener: (error?: Error) => void): () => void { + this.closeListeners.add(listener); + return () => { + this.closeListeners.delete(listener); + }; + } + + onNotification(method: string, handler: JsonRpcNotificationHandler): () => void { + let handlers = this.notificationHandlers.get(method); + if (!handlers) { + handlers = new Set(); + this.notificationHandlers.set(method, handlers); + } + handlers.add(handler); + + return () => { + const current = this.notificationHandlers.get(method); + if (!current) return; + current.delete(handler); + if (current.size === 0) { + this.notificationHandlers.delete(method); + } + }; + } + + onRequest(method: string, handler: JsonRpcRequestHandler): () => void { + this.requestHandlers.set(method, handler); + return () => { + if (this.requestHandlers.get(method) === handler) { + this.requestHandlers.delete(method); + } + }; + } + + async request( + method: string, + params?: unknown, + options: JsonRpcRequestOptions = {}, + ): Promise { + this.start(); + + if (this.disposed) { + throw new JsonRpcTransportClosedError(); + } + + const id = this.nextId++; + const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs; + + return new Promise((resolve, reject) => { + let timer: number | undefined; + let onAbort: (() => void) | undefined; + + const cleanup = (): void => { + if (timer) window.clearTimeout(timer); + if (onAbort && options.signal) { + options.signal.removeEventListener('abort', onAbort); + } + }; + const resolvePending = (result: unknown): void => { + resolve(result as T); + }; + + const pending: PendingRequest = { + cleanup, + method, + reject, + resolve: resolvePending, + }; + + if (timeoutMs > 0) { + timer = window.setTimeout(() => { + this.pending.delete(id); + cleanup(); + reject(new Error(`Request timeout: ${method} (${timeoutMs}ms)`)); + }, timeoutMs); + } + + if (options.signal) { + if (options.signal.aborted) { + cleanup(); + reject(new Error(`Request aborted: ${method}`)); + return; + } + onAbort = () => { + this.pending.delete(id); + cleanup(); + reject(new Error(`Request aborted: ${method}`)); + }; + options.signal.addEventListener('abort', onAbort, { once: true }); + } + + this.pending.set(id, pending); + + try { + this.sendRaw({ id, jsonrpc: '2.0', method, params }); + } catch (error) { + this.pending.delete(id); + cleanup(); + const transportError = error instanceof Error ? error : new Error(String(error)); + this.dispose(transportError); + reject(transportError); + } + }); + } + + notify(method: string, params?: unknown): void { + this.start(); + if (this.disposed) { + return; + } + this.trySendRaw({ jsonrpc: '2.0', method, params }); + } + + dispose(error: Error = new JsonRpcTransportClosedError('JSON-RPC transport disposed')): void { + if (this.disposed) { + return; + } + + this.disposed = true; + this.abortController.abort(); + + this.unregisterClose?.(); + this.unregisterClose = undefined; + + while (this.streamUnsubscribers.length > 0) { + this.streamUnsubscribers.pop()?.(); + } + + if (this.readline) { + this.readline.removeAllListeners(); + this.readline.close(); + this.readline = null; + } + + for (const [id, pending] of this.pending) { + pending.cleanup(); + pending.reject(error); + this.pending.delete(id); + } + + for (const listener of this.closeListeners) { + try { + listener(error); + } catch { + // Best-effort listener dispatch. + } + } + } + + private handleLine(line: string): void { + if (line.trim().length === 0) { + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(line) as unknown; + } catch { + return; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return; + } + const message = parsed as JsonRpcMessage; + + if ('id' in message && !('method' in message)) { + this.handleResponse(message); + return; + } + if ('method' in message && 'id' in message) { + this.handleRequest(message); + return; + } + if ('method' in message) { + this.handleNotification(message); + } + } + + private handleResponse(message: JsonRpcResponseMessage): void { + if (typeof message.id !== 'number') { + return; + } + + const pending = this.pending.get(message.id); + if (!pending) { + return; + } + + this.pending.delete(message.id); + pending.cleanup(); + + if (message.error) { + pending.reject(new JsonRpcErrorResponse( + pending.method, + message.error.code, + message.error.message, + message.error.data, + )); + return; + } + + pending.resolve(message.result); + } + + private handleNotification(message: JsonRpcNotificationMessage): void { + const handlers = this.notificationHandlers.get(message.method); + if (!handlers || handlers.size === 0) { + return; + } + + for (const handler of handlers) { + void Promise.resolve().then(() => handler(message.params)).catch(() => { + // Notification failures are non-fatal to the transport. + }); + } + } + + private handleRequest(message: JsonRpcRequestMessage): void { + const handler = this.requestHandlers.get(message.method); + if (!handler) { + this.trySendRaw({ + error: { + code: -32601, + message: `Unhandled server request: ${message.method}`, + }, + id: message.id, + jsonrpc: '2.0', + }); + return; + } + + void Promise.resolve().then(() => handler(message.params)).then( + (result) => { + this.trySendRaw({ id: message.id, jsonrpc: '2.0', result }); + }, + (error) => { + this.trySendRaw({ + error: { + code: -32603, + message: error instanceof Error ? error.message : 'Internal error', + }, + id: message.id, + jsonrpc: '2.0', + }); + }, + ); + } + + private sendRaw(message: JsonRpcMessage): void { + if (this.disposed) { + throw new JsonRpcTransportClosedError(); + } + this.streams.output.write(`${JSON.stringify(message)}\n`); + } + + private trySendRaw(message: JsonRpcMessage): void { + try { + this.sendRaw(message); + } catch (error) { + const transportError = error instanceof Error ? error : new Error(String(error)); + this.dispose(transportError); + } + } +} + +type StreamWithEvents = { + off?: (eventName: string, listener: (...args: unknown[]) => void) => void; + on?: (eventName: string, listener: (...args: unknown[]) => void) => void; + removeListener?: (eventName: string, listener: (...args: unknown[]) => void) => void; +}; + +function subscribeStreamEvent( + stream: NodeJS.ReadableStream | NodeJS.WritableStream, + eventName: string, + listener: (...args: unknown[]) => void, +): () => void { + const evented = stream as StreamWithEvents; + evented.on?.(eventName, listener); + return () => { + if (typeof evented.off === 'function') { + evented.off(eventName, listener); + return; + } + evented.removeListener?.(eventName, listener); + }; +} diff --git a/src/providers/acp/AcpSessionConfig.ts b/src/providers/acp/AcpSessionConfig.ts new file mode 100644 index 0000000..65d98eb --- /dev/null +++ b/src/providers/acp/AcpSessionConfig.ts @@ -0,0 +1,139 @@ +import type { + AcpModelInfo, + AcpSessionConfigOption, + AcpSessionConfigSelectGroup, + AcpSessionConfigSelectOption, + AcpSessionConfigSelectOptions, + AcpSessionMode, + AcpSessionModelState, + AcpSessionModeState, +} from './types'; + +export interface AcpResolvedSessionModelState { + availableModels: AcpModelInfo[]; + currentModelId: string | null; +} + +export interface AcpResolvedSessionModeState { + availableModes: AcpSessionMode[]; + currentModeId: string | null; +} + +export interface AcpResolvedSessionThoughtLevelState { + availableLevels: SelectItem[]; + configId: string | null; + currentLevel: string | null; +} + +type SelectItem = { description?: string; id: string; name: string }; + +export function flattenAcpSessionConfigSelectOptions( + options: AcpSessionConfigSelectOptions, +): AcpSessionConfigSelectOption[] { + if (options.length === 0) { + return []; + } + if (isSelectGroup(options[0])) { + return (options as AcpSessionConfigSelectGroup[]).flatMap((group) => group.options); + } + return options as AcpSessionConfigSelectOption[]; +} + +export function extractAcpSessionModelState(params: { + configOptions?: AcpSessionConfigOption[] | null; + models?: AcpSessionModelState | null; +}): AcpResolvedSessionModelState { + const { items, current } = resolveSelectItems(params.configOptions, 'model'); + if (items) { + return { availableModels: items, currentModelId: current }; + } + return { + availableModels: params.models?.availableModels ?? [], + currentModelId: params.models?.currentModelId ?? current, + }; +} + +export function extractAcpSessionModeState(params: { + configOptions?: AcpSessionConfigOption[] | null; + modes?: AcpSessionModeState | null; +}): AcpResolvedSessionModeState { + const { items, current } = resolveSelectItems(params.configOptions, 'mode'); + if (items) { + return { availableModes: items, currentModeId: current }; + } + return { + availableModes: params.modes?.availableModes ?? [], + currentModeId: params.modes?.currentModeId ?? current, + }; +} + +export function extractAcpSessionThoughtLevelState(params: { + configOptions?: AcpSessionConfigOption[] | null; +}): AcpResolvedSessionThoughtLevelState { + const { configId, items, current } = resolveSelectItems(params.configOptions, 'thought_level'); + return { + availableLevels: items ?? [], + configId, + currentLevel: current, + }; +} + +// `items` is null when the config option is missing or empty so callers fall back to +// the session's own metadata. `current` is always the config option's `currentValue` +// when one exists, so fallbacks can still seed a current id from it. +function resolveSelectItems( + configOptions: AcpSessionConfigOption[] | null | undefined, + category: 'model' | 'mode' | 'thought_level', +): { configId: string | null; current: string | null; items: SelectItem[] | null } { + const selectOption = findSessionConfigSelectOption(configOptions, category); + if (!selectOption) { + return { configId: null, current: null, items: null }; + } + + const items = flattenAcpSessionConfigSelectOptions(selectOption.options).map((option) => ({ + ...(option.description ? { description: option.description } : {}), + id: option.value, + name: option.name, + })); + + return { + configId: selectOption.id, + current: selectOption.currentValue, + items: items.length > 0 ? items : null, + }; +} + +function findSessionConfigSelectOption( + configOptions: AcpSessionConfigOption[] | null | undefined, + category: 'model' | 'mode' | 'thought_level', +): Extract | null { + if (!configOptions) { + return null; + } + // Prefer explicit `category` metadata; fall back to id-based matching for older agents + // that have not yet migrated their config options to tag a category. + const byCategory = configOptions.find((option) => ( + option.type === 'select' && normalizeComparableKey(option.category) === category + )); + if (byCategory?.type === 'select') { + return byCategory; + } + const byLegacyId = configOptions.find((option) => ( + option.type === 'select' && normalizeComparableKey(option.id) === legacyConfigIdForCategory(category) + )); + return byLegacyId?.type === 'select' ? byLegacyId : null; +} + +function legacyConfigIdForCategory(category: 'model' | 'mode' | 'thought_level'): string { + return category === 'thought_level' ? 'effort' : category; +} + +function isSelectGroup( + option: AcpSessionConfigSelectOption | AcpSessionConfigSelectGroup, +): option is AcpSessionConfigSelectGroup { + return 'options' in option; +} + +function normalizeComparableKey(value: string | null | undefined): string { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} diff --git a/src/providers/acp/AcpSessionUpdateNormalizer.ts b/src/providers/acp/AcpSessionUpdateNormalizer.ts new file mode 100644 index 0000000..7301ecc --- /dev/null +++ b/src/providers/acp/AcpSessionUpdateNormalizer.ts @@ -0,0 +1,371 @@ +import type { SlashCommand, StreamChunk } from '../../core/types'; +import type { + AcpAvailableCommand, + AcpContentBlock, + AcpContentChunk, + AcpPlan, + AcpSessionConfigOption, + AcpSessionInfoUpdate, + AcpSessionUpdate, + AcpToolCall, + AcpToolCallContent, + AcpToolCallStatus, + AcpToolCallUpdate, + AcpUsageUpdate, +} from './types'; + +export type AcpNormalizedUpdate = + | { + content: AcpContentBlock; + messageId?: string | null; + role: 'assistant' | 'thinking' | 'user'; + streamChunks: StreamChunk[]; + type: 'message_chunk'; + } + | { + commands: SlashCommand[]; + type: 'commands'; + } + | { + configOptions: AcpSessionConfigOption[]; + type: 'config_options'; + } + | { + currentModeId: string; + type: 'current_mode'; + } + | { + plan: AcpPlan; + type: 'plan'; + } + | { + sessionInfo: AcpSessionInfoUpdate & { updatedAtMs?: number | null }; + type: 'session_info'; + } + | { + streamChunks: StreamChunk[]; + toolCall: AcpToolCall; + toolState: AcpToolCallSnapshot; + type: 'tool_call'; + } + | { + streamChunks: StreamChunk[]; + toolCallUpdate: AcpToolCallUpdate; + toolState: AcpToolCallSnapshot; + type: 'tool_call_update'; + } + | { + type: 'usage'; + usage: AcpUsageUpdate; + }; + +export interface AcpToolCallSnapshot { + input: Record; + name: string; + output: string; + status?: AcpToolCallStatus | null; +} + +type MessageRole = 'assistant' | 'thinking' | 'user'; + +// Sentinel key for anonymous (messageId-less) streams so we only emit one start per role. +const ANONYMOUS_MESSAGE_KEY = '\u0000anonymous'; + +export class AcpSessionUpdateNormalizer { + private readonly seenMessages = new Map>(); + private readonly toolCalls = new Map(); + + reset(): void { + this.seenMessages.clear(); + this.toolCalls.clear(); + } + + normalize(update: AcpSessionUpdate): AcpNormalizedUpdate { + switch (update.sessionUpdate) { + case 'user_message_chunk': + return this.normalizeMessageChunk('user', update); + case 'agent_message_chunk': + return this.normalizeMessageChunk('assistant', update); + case 'agent_thought_chunk': + return this.normalizeMessageChunk('thinking', update); + case 'tool_call': + return this.normalizeToolCall(update); + case 'tool_call_update': + return this.normalizeToolCallUpdate(update); + case 'plan': + return { plan: update, type: 'plan' }; + case 'available_commands_update': + return { + commands: update.availableCommands.map(mapAcpCommandToSlashCommand), + type: 'commands', + }; + case 'current_mode_update': + return { currentModeId: update.currentModeId, type: 'current_mode' }; + case 'config_option_update': + return { configOptions: update.configOptions, type: 'config_options' }; + case 'session_info_update': + return { + sessionInfo: { ...update, updatedAtMs: parseIsoDate(update.updatedAt) }, + type: 'session_info', + }; + case 'usage_update': + return { type: 'usage', usage: update }; + } + } + + private normalizeMessageChunk( + role: MessageRole, + update: AcpContentChunk, + ): Extract { + const streamChunks: StreamChunk[] = []; + + if (role === 'user' && this.claimMessageStart('user', update.messageId)) { + streamChunks.push({ + content: extractPrimaryText(update.content), + itemId: update.messageId ?? undefined, + type: 'user_message_start', + }); + } else if (role === 'assistant' && this.claimMessageStart('assistant', update.messageId)) { + streamChunks.push({ + itemId: update.messageId ?? undefined, + type: 'assistant_message_start', + }); + } + + const text = renderAcpContentBlock(update.content); + if (text && role === 'thinking') { + streamChunks.push({ content: text, type: 'thinking' }); + } else if (text && role === 'assistant') { + streamChunks.push({ content: text, type: 'text' }); + } + + return { + content: update.content, + messageId: update.messageId ?? null, + role, + streamChunks, + type: 'message_chunk', + }; + } + + private normalizeToolCall(toolCall: AcpToolCall): Extract { + const toolState: AcpToolCallSnapshot = { + input: normalizeToolInput(toolCall.rawInput), + name: normalizeToolName(toolCall.title, toolCall.kind), + output: renderToolPayload(toolCall.content, toolCall.rawOutput), + status: toolCall.status, + }; + this.toolCalls.set(toolCall.toolCallId, toolState); + + const streamChunks: StreamChunk[] = [{ + id: toolCall.toolCallId, + input: toolState.input, + name: toolState.name, + type: 'tool_use', + }]; + + if (toolState.status === 'completed' || toolState.status === 'failed') { + streamChunks.push({ + content: toolState.output || defaultToolResultText(toolState.status), + id: toolCall.toolCallId, + isError: toolState.status === 'failed', + type: 'tool_result', + }); + } + + return { streamChunks, toolCall, toolState, type: 'tool_call' }; + } + + private normalizeToolCallUpdate( + toolCallUpdate: AcpToolCallUpdate, + ): Extract { + const current = this.toolCalls.get(toolCallUpdate.toolCallId) ?? { + input: {}, + name: 'tool', + output: '', + status: null, + }; + + if (toolCallUpdate.title) { + current.name = normalizeToolName(toolCallUpdate.title, toolCallUpdate.kind ?? null); + } else if (toolCallUpdate.kind && current.name === 'tool') { + current.name = normalizeToolName(undefined, toolCallUpdate.kind); + } + + if (toolCallUpdate.rawInput !== undefined) { + current.input = normalizeToolInput(toolCallUpdate.rawInput); + } + + const nextOutput = renderToolPayload(toolCallUpdate.content ?? undefined, toolCallUpdate.rawOutput) + || current.output; + const streamChunks: StreamChunk[] = []; + + // Emit only the delta so the UI can append incrementally without re-rendering prior output. + if (nextOutput.length > current.output.length && nextOutput.startsWith(current.output)) { + streamChunks.push({ + content: nextOutput.slice(current.output.length), + id: toolCallUpdate.toolCallId, + type: 'tool_output', + }); + } + + current.output = nextOutput; + if (toolCallUpdate.status !== undefined) { + current.status = toolCallUpdate.status; + } + + if (current.status === 'completed' || current.status === 'failed') { + streamChunks.push({ + content: current.output || defaultToolResultText(current.status), + id: toolCallUpdate.toolCallId, + isError: current.status === 'failed', + type: 'tool_result', + }); + } + + this.toolCalls.set(toolCallUpdate.toolCallId, current); + + return { + streamChunks, + toolCallUpdate, + toolState: { ...current }, + type: 'tool_call_update', + }; + } + + // A message-start chunk must fire exactly once per (role, messageId). Anonymous streams + // share a single slot per role so repeated chunks without an id do not restart the message. + private claimMessageStart(role: 'assistant' | 'user', messageId?: string | null): boolean { + const key = messageId ?? ANONYMOUS_MESSAGE_KEY; + let seen = this.seenMessages.get(role); + if (!seen) { + seen = new Set(); + this.seenMessages.set(role, seen); + } + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + } +} + +function mapAcpCommandToSlashCommand(command: AcpAvailableCommand): SlashCommand { + const name = command.name.replace(/^\//, ''); + return { + argumentHint: command.input?.hint ?? undefined, + content: '', + description: command.description ?? undefined, + id: `acp:${name}`, + name, + source: 'sdk', + }; +} + +function normalizeToolName(title?: string | null, kind?: string | null): string { + return title?.trim() || kind?.trim() || 'tool'; +} + +function normalizeToolInput(rawInput: unknown): Record { + if (isPlainObject(rawInput)) { + return rawInput; + } + if (rawInput === undefined) { + return {}; + } + return { value: rawInput }; +} + +function renderToolPayload( + content: AcpToolCallContent[] | null | undefined, + rawOutput: unknown, +): string { + if (Array.isArray(content) && content.length > 0) { + return content + .map(renderToolCallContent) + .filter(text => text.length > 0) + .join('\n\n'); + } + + return rawOutput === undefined ? '' : formatUnknownValue(rawOutput); +} + +function renderToolCallContent(content: AcpToolCallContent): string { + switch (content.type) { + case 'content': + return renderAcpContentBlock(content.content); + case 'diff': + return `Diff: ${content.path}`; + case 'terminal': + return `Terminal: ${content.terminalId}`; + } +} + +function defaultToolResultText(status: AcpToolCallStatus): string { + return status === 'failed' ? 'Tool failed' : 'Tool completed'; +} + +// User-visible preview text for the first chunk of a user message. Non-textual blocks +// show nothing here because they round-trip through the message content itself. +function extractPrimaryText(content: AcpContentBlock): string { + if (content.type === 'text') { + return content.text; + } + if (content.type === 'resource' && 'text' in content.resource) { + return content.resource.text; + } + return ''; +} + +export function renderAcpContentBlock(content: AcpContentBlock): string { + switch (content.type) { + case 'text': + return content.text; + case 'image': + return content.uri ? `[image: ${content.uri}]` : `[image: ${content.mimeType}]`; + case 'audio': + return `[audio: ${content.mimeType}]`; + case 'resource_link': + return content.title || content.name || content.uri; + case 'resource': + return 'text' in content.resource + ? content.resource.text + : `[resource: ${content.resource.uri}]`; + } +} + +function formatUnknownValue(value: unknown): string { + if (typeof value === 'string') { + return value; + } + if (value === null) { + return 'null'; + } + if (value === undefined) { + return ''; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return `${value}`; + } + try { + return JSON.stringify(value, null, 2) ?? ''; + } catch { + return '[unserializable]'; + } +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +// Tri-state: undefined when the field is absent, null when present but unparseable, number on success. +function parseIsoDate(value?: string | null): number | null | undefined { + if (value === undefined) { + return undefined; + } + if (value === null) { + return null; + } + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : timestamp; +} diff --git a/src/providers/acp/AcpSubprocess.ts b/src/providers/acp/AcpSubprocess.ts new file mode 100644 index 0000000..1010194 --- /dev/null +++ b/src/providers/acp/AcpSubprocess.ts @@ -0,0 +1,160 @@ +import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; +import type { Readable, Writable } from 'node:stream'; + +import { + resolveWindowsCmdShimSpawnSpec, + terminateSpawnedProcess, + type WindowsCmdShimSpawnSpec, +} from '../../utils/windowsCmdShim'; + +const SIGKILL_TIMEOUT_MS = 3_000; +const FINAL_SHUTDOWN_TIMEOUT_MS = 3_000; +const STDERR_BUFFER_LIMIT = 8_000; + +export interface AcpSubprocessLaunchSpec { + args: string[]; + command: string; + cwd: string; + env: NodeJS.ProcessEnv; +} + +type CloseListener = (error?: Error) => void; + +export class AcpSubprocess { + private closeError: Error | null = null; + private readonly closeListeners = new Set(); + private notifiedClose = false; + private proc: ChildProcessWithoutNullStreams | null = null; + private resolvedSpawnSpec: WindowsCmdShimSpawnSpec | null = null; + private stderrBuffer = ''; + + constructor(private readonly launchSpec: AcpSubprocessLaunchSpec) {} + + get stdin(): Writable { + return this.requireProc().stdin; + } + + get stdout(): Readable { + return this.requireProc().stdout; + } + + get stderr(): Readable { + return this.requireProc().stderr; + } + + private requireProc(): ChildProcessWithoutNullStreams { + if (!this.proc) { + throw new Error('ACP subprocess is not started'); + } + return this.proc; + } + + start(): void { + if (this.proc) { + return; + } + + const resolvedSpawnSpec = resolveWindowsCmdShimSpawnSpec(this.launchSpec); + this.resolvedSpawnSpec = resolvedSpawnSpec; + const proc = spawn(resolvedSpawnSpec.command, resolvedSpawnSpec.args, { + cwd: this.launchSpec.cwd, + env: this.launchSpec.env, + stdio: 'pipe', + windowsHide: true, + ...(resolvedSpawnSpec.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + }); + + proc.stderr.on('data', (chunk: Buffer | string) => { + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8'); + this.stderrBuffer = `${this.stderrBuffer}${text}`.slice(-STDERR_BUFFER_LIMIT); + }); + + proc.on('error', (error) => { + this.closeError = error; + this.notifyClose(error); + }); + + proc.on('exit', (code, signal) => { + const exitError = this.closeError ?? ( + code === 0 && signal === null + ? undefined + : new Error(`ACP subprocess exited (${formatExit(code, signal)})`) + ); + this.notifyClose(exitError); + }); + + this.proc = proc; + } + + isAlive(): boolean { + return this.proc !== null && this.proc.exitCode === null && !this.proc.killed; + } + + getStderrSnapshot(): string { + return this.stderrBuffer.trim(); + } + + onClose(listener: CloseListener): () => void { + this.closeListeners.add(listener); + return () => { + this.closeListeners.delete(listener); + }; + } + + async shutdown(): Promise { + if (!this.proc || this.proc.exitCode !== null) { + return; + } + + await new Promise((resolve) => { + const proc = this.proc!; + let killTimer: number | null = null; + let finalTimer: number | null = null; + const onClose = () => { + cleanup(); + resolve(); + }; + killTimer = window.setTimeout(() => { + this.killProc(proc, 'SIGKILL'); + finalTimer = window.setTimeout(onClose, FINAL_SHUTDOWN_TIMEOUT_MS); + }, SIGKILL_TIMEOUT_MS); + const cleanup = () => { + if (killTimer !== null) window.clearTimeout(killTimer); + if (finalTimer !== null) window.clearTimeout(finalTimer); + proc.off('exit', onClose); + }; + + proc.once('exit', onClose); + this.killProc(proc, 'SIGTERM'); + }); + } + + private killProc(proc: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): boolean { + return terminateSpawnedProcess(proc, signal, spawn, this.resolvedSpawnSpec); + } + + private notifyClose(error?: Error): void { + if (this.notifiedClose) { + return; + } + + this.notifiedClose = true; + for (const listener of this.closeListeners) { + try { + listener(error); + } catch { + // Best-effort cleanup notification. + } + } + } +} + +function formatExit(code: number | null, signal: string | null): string { + if (signal) { + return `signal ${signal}`; + } + if (code === null) { + return 'unknown'; + } + return `code ${code}`; +} diff --git a/src/providers/acp/AcpToolStreamAdapter.ts b/src/providers/acp/AcpToolStreamAdapter.ts new file mode 100644 index 0000000..43a065e --- /dev/null +++ b/src/providers/acp/AcpToolStreamAdapter.ts @@ -0,0 +1,132 @@ +import type { StreamChunk } from '../../core/types'; +import type { SDKToolUseResult } from '../../core/types/diff'; +import type { AcpToolCall, AcpToolCallUpdate } from './types'; + +interface AcpToolStreamState { + input: Record; + rawName: string; +} + +export interface AcpToolStreamPresentationAdapter { + normalizeToolInput(rawName: string | undefined, input: Record): Record; + normalizeToolName(rawName: string | undefined): string; + normalizeToolUseResult( + rawName: string | undefined, + input: Record, + rawOutput: unknown, + ): SDKToolUseResult | undefined; + resolveRawToolName( + currentRawName: string | undefined, + update: { + kind?: string | null; + title?: string | null; + }, + ): string; +} + +export class AcpToolStreamAdapter { + private readonly toolStates = new Map(); + + constructor(private readonly adapter: AcpToolStreamPresentationAdapter) {} + + reset(): void { + this.toolStates.clear(); + } + + normalizeToolCall(toolCall: AcpToolCall, chunks: StreamChunk[]): StreamChunk[] { + const state = this.updateToolState(undefined, { + kind: toolCall.kind, + rawInput: toolCall.rawInput, + title: toolCall.title, + }); + this.toolStates.set(toolCall.toolCallId, state); + return chunks.map((chunk) => this.normalizeChunk(chunk, state, toolCall.rawOutput)); + } + + normalizeToolCallUpdate(toolCallUpdate: AcpToolCallUpdate, chunks: StreamChunk[]): StreamChunk[] { + const state = this.updateToolState(this.toolStates.get(toolCallUpdate.toolCallId), { + kind: toolCallUpdate.kind, + rawInput: toolCallUpdate.rawInput, + title: toolCallUpdate.title, + }); + this.toolStates.set(toolCallUpdate.toolCallId, state); + + const result: StreamChunk[] = []; + if (toolCallUpdate.rawInput !== undefined) { + result.push({ + id: toolCallUpdate.toolCallId, + input: state.input, + name: this.adapter.normalizeToolName(state.rawName), + type: 'tool_use', + }); + } + + for (const chunk of chunks) { + result.push(this.normalizeChunk(chunk, state, toolCallUpdate.rawOutput)); + } + + return result; + } + + private updateToolState( + current: AcpToolStreamState | undefined, + update: { + kind?: string | null; + rawInput?: unknown; + title?: string | null; + }, + ): AcpToolStreamState { + const nextRawName = this.adapter.resolveRawToolName(current?.rawName, update); + const nextInput = current?.input ?? {}; + + if (update.rawInput !== undefined) { + const rawInput = normalizeRawToolInput(update.rawInput); + return this.buildToolState(nextRawName, { ...nextInput, ...rawInput }); + } + + if (nextRawName !== current?.rawName) { + return this.buildToolState(nextRawName, nextInput); + } + + return current ?? this.buildToolState(nextRawName, {}); + } + + private buildToolState( + rawName: string, + input: Record, + ): AcpToolStreamState { + return { + input: this.adapter.normalizeToolInput(rawName, input), + rawName, + }; + } + + private normalizeChunk( + chunk: StreamChunk, + state: AcpToolStreamState, + rawOutput?: unknown, + ): StreamChunk { + switch (chunk.type) { + case 'tool_use': + return { + ...chunk, + input: state.input, + name: this.adapter.normalizeToolName(state.rawName), + }; + case 'tool_result': { + const toolUseResult = this.adapter.normalizeToolUseResult(state.rawName, state.input, rawOutput); + return toolUseResult + ? { ...chunk, toolUseResult } + : chunk; + } + default: + return chunk; + } + } +} + +function normalizeRawToolInput(rawInput: unknown): Record { + return rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) + ? rawInput as Record + : {}; +} diff --git a/src/providers/acp/buildAcpUsageInfo.ts b/src/providers/acp/buildAcpUsageInfo.ts new file mode 100644 index 0000000..ce86fe9 --- /dev/null +++ b/src/providers/acp/buildAcpUsageInfo.ts @@ -0,0 +1,41 @@ +import type { UsageInfo } from '../../core/types'; +import type { AcpUsage, AcpUsageUpdate } from './types'; + +export interface BuildAcpUsageInfoParams { + contextWindow?: AcpUsageUpdate | null; + model?: string; + promptUsage?: AcpUsage | null; +} + +export function buildAcpUsageInfo(params: BuildAcpUsageInfoParams): UsageInfo | null { + const promptUsage = params.promptUsage ?? null; + const contextWindow = params.contextWindow ?? null; + + if (!promptUsage && !contextWindow) { + return null; + } + + const contextTokens = contextWindow?.used ?? promptUsage?.totalTokens ?? 0; + const contextWindowSize = contextWindow?.size ?? 0; + + return { + cacheCreationInputTokens: promptUsage?.cachedWriteTokens ?? 0, + cacheReadInputTokens: promptUsage?.cachedReadTokens ?? 0, + contextTokens, + contextWindow: contextWindowSize, + // Only the contextWindow update speaks authoritatively about window size; falling back + // to promptUsage alone is a best-effort approximation. + contextWindowIsAuthoritative: Boolean(contextWindow), + inputTokens: promptUsage?.inputTokens ?? 0, + model: params.model, + percentage: computePercentage(contextTokens, contextWindowSize), + }; +} + +function computePercentage(used: number, total: number): number { + if (total <= 0) { + return 0; + } + const ratio = Math.round((used / total) * 100); + return Math.min(100, Math.max(0, ratio)); +} diff --git a/src/providers/acp/index.ts b/src/providers/acp/index.ts new file mode 100644 index 0000000..0ca2bc7 --- /dev/null +++ b/src/providers/acp/index.ts @@ -0,0 +1,9 @@ +export * from './AcpClientConnection'; +export * from './AcpJsonRpcTransport'; +export * from './AcpSessionConfig'; +export * from './AcpSessionUpdateNormalizer'; +export * from './AcpSubprocess'; +export * from './AcpToolStreamAdapter'; +export * from './buildAcpUsageInfo'; +export * from './methodNames'; +export * from './types'; diff --git a/src/providers/acp/methodNames.ts b/src/providers/acp/methodNames.ts new file mode 100644 index 0000000..a751580 --- /dev/null +++ b/src/providers/acp/methodNames.ts @@ -0,0 +1,50 @@ +export type AcpLogicalMethod = + | 'initialize' + | 'authenticate' + | 'newSession' + | 'loadSession' + | 'listSessions' + | 'prompt' + | 'cancel' + | 'setMode' + | 'setConfigOption'; + +export type AcpMethodOverrides = Partial>; + +const ACP_METHOD_CANDIDATES = { + authenticate: ['authenticate'], + cancel: ['session/cancel', 'cancel'], + initialize: ['initialize'], + listSessions: ['session/list', 'listSessions'], + loadSession: ['session/load', 'loadSession'], + newSession: ['session/new', 'newSession'], + prompt: ['session/prompt', 'prompt'], + setConfigOption: ['session/set_config_option', 'setSessionConfigOption'], + setMode: ['session/set_mode', 'setSessionMode'], +} as const satisfies Record; + +export const ACP_SERVER_NOTIFICATION_ALIASES = { + sessionUpdate: ['session/update', 'sessionUpdate'], +} as const; + +export const ACP_SERVER_REQUEST_ALIASES = { + createTerminal: ['terminal/create', 'terminalCreate'], + killTerminal: ['terminal/kill', 'terminalKill'], + readTextFile: ['fs/read_text_file', 'fs/readTextFile'], + releaseTerminal: ['terminal/release', 'terminalRelease'], + requestPermission: ['session/request_permission', 'requestPermission'], + terminalOutput: ['terminal/output', 'terminalOutput'], + waitForTerminalExit: ['terminal/wait_for_exit', 'terminalWaitForExit'], + writeTextFile: ['fs/write_text_file', 'fs/writeTextFile'], +} as const; + +export function getAcpMethodCandidates( + logicalMethod: AcpLogicalMethod, + overrides?: AcpMethodOverrides, +): string[] { + const override = overrides?.[logicalMethod]; + if (override) { + return Array.isArray(override) ? [...override] : [override]; + } + return [...ACP_METHOD_CANDIDATES[logicalMethod]]; +} diff --git a/src/providers/acp/types.ts b/src/providers/acp/types.ts new file mode 100644 index 0000000..307d216 --- /dev/null +++ b/src/providers/acp/types.ts @@ -0,0 +1,566 @@ +export const ACP_PROTOCOL_VERSION = 1 as const; + +export type AcpProtocolVersion = typeof ACP_PROTOCOL_VERSION; +export type AcpRequestId = number | string | null; +export type AcpSessionId = string; +export type AcpSessionModeId = string; +export type AcpSessionConfigId = string; +export type AcpSessionConfigValueId = string; +export type AcpToolCallId = string; +export type AcpPermissionOptionId = string; +export type AcpPositionEncodingKind = 'utf-16' | 'utf-32' | 'utf-8'; +export type AcpRole = 'assistant' | 'user'; +export type AcpStopReason = string; + +export interface AcpImplementation { + name: string; + version: string; + title?: string | null; +} + +export interface AcpAuthEnvVar { + name: string; + label?: string | null; + optional?: boolean; + secret?: boolean; +} + +export type AcpAuthMethod = { + description?: string | null; + id: string; + name?: string | null; +} & ( + | { type?: 'agent' } + | { envVars: AcpAuthEnvVar[]; type: 'env_var' } + | { args?: string[]; command: string; type: 'terminal' } +); + +export interface AcpFileSystemCapabilities { + readTextFile?: boolean; + writeTextFile?: boolean; +} + +export interface AcpClientAuthCapabilities { + terminal?: boolean; +} + +export interface AcpClientCapabilities { + auth?: AcpClientAuthCapabilities; + fs?: AcpFileSystemCapabilities; + terminal?: boolean; + positionEncodings?: AcpPositionEncodingKind[]; +} + +export interface AcpPromptCapabilities { + audio?: boolean; + embeddedContext?: boolean; + image?: boolean; +} + +export interface AcpMcpCapabilities { + http?: boolean; + sse?: boolean; +} + +export interface AcpSessionCapabilities { + close?: Record | null; + fork?: Record | null; + list?: Record | null; + resume?: Record | null; +} + +export interface AcpAgentCapabilities { + auth?: { + logout?: Record | null; + }; + loadSession?: boolean; + mcpCapabilities?: AcpMcpCapabilities; + positionEncoding?: AcpPositionEncodingKind | null; + promptCapabilities?: AcpPromptCapabilities; + sessionCapabilities?: AcpSessionCapabilities; +} + +export interface AcpInitializeRequest { + clientCapabilities?: AcpClientCapabilities; + clientInfo?: AcpImplementation | null; + protocolVersion: AcpProtocolVersion; +} + +export interface AcpInitializeResponse { + agentCapabilities?: AcpAgentCapabilities; + agentInfo?: AcpImplementation | null; + authMethods?: AcpAuthMethod[]; + protocolVersion: AcpProtocolVersion; +} + +export interface AcpAuthenticateRequest { + methodId: string; +} + +export type AcpAuthenticateResponse = Record; + +export interface AcpEnvVariable { + name: string; + value: string; +} + +export interface AcpHttpHeader { + name: string; + value: string; +} + +export type AcpMcpServer = + | { + type: 'http'; + headers?: AcpHttpHeader[]; + name: string; + url: string; + } + | { + type: 'sse'; + headers?: AcpHttpHeader[]; + name: string; + url: string; + } + | { + type?: 'stdio'; + args: string[]; + command: string; + env?: AcpEnvVariable[]; + name: string; + }; + +export interface AcpSessionMode { + description?: string | null; + id: AcpSessionModeId; + name: string; +} + +export interface AcpSessionModeState { + availableModes: AcpSessionMode[]; + currentModeId: AcpSessionModeId; +} + +export interface AcpModelInfo { + id: string; + name: string; + description?: string | null; +} + +export interface AcpSessionModelState { + availableModels: AcpModelInfo[]; + currentModelId: string; +} + +export interface AcpSessionConfigSelectOption { + description?: string | null; + name: string; + value: AcpSessionConfigValueId; +} + +export interface AcpSessionConfigSelectGroup { + group: string; + name: string; + options: AcpSessionConfigSelectOption[]; +} + +export type AcpSessionConfigSelectOptions = + | AcpSessionConfigSelectOption[] + | AcpSessionConfigSelectGroup[]; + +export type AcpSessionConfigOption = { + category?: string | null; + description?: string | null; + id: AcpSessionConfigId; + name: string; +} & ( + | { type: 'boolean'; value: boolean } + | { + currentValue: AcpSessionConfigValueId; + options: AcpSessionConfigSelectOptions; + type: 'select'; + } +); + +export interface AcpNewSessionRequest { + additionalDirectories?: string[]; + cwd: string; + mcpServers: AcpMcpServer[]; +} + +export interface AcpNewSessionResponse { + configOptions?: AcpSessionConfigOption[] | null; + models?: AcpSessionModelState | null; + modes?: AcpSessionModeState | null; + sessionId: AcpSessionId; +} + +export interface AcpLoadSessionRequest { + additionalDirectories?: string[]; + cwd: string; + mcpServers: AcpMcpServer[]; + sessionId: AcpSessionId; +} + +export interface AcpLoadSessionResponse { + configOptions?: AcpSessionConfigOption[] | null; + models?: AcpSessionModelState | null; + modes?: AcpSessionModeState | null; + sessionId: AcpSessionId; +} + +export interface AcpListSessionsRequest { + additionalDirectories?: string[]; + cursor?: string | null; + cwd?: string | null; +} + +export interface AcpSessionInfo { + sessionId: AcpSessionId; + title?: string | null; + updatedAt?: string | null; +} + +export interface AcpListSessionsResponse { + nextCursor?: string | null; + sessions: AcpSessionInfo[]; +} + +export interface AcpTextContent { + type: 'text'; + text: string; +} + +export interface AcpImageContent { + data: string; + mimeType: string; + type: 'image'; + uri?: string | null; +} + +export interface AcpAudioContent { + data: string; + mimeType: string; + type: 'audio'; +} + +export interface AcpResourceLink { + description?: string | null; + mimeType?: string | null; + name: string; + size?: number | null; + title?: string | null; + type: 'resource_link'; + uri: string; +} + +export type AcpEmbeddedResource = + | { + resource: { + mimeType?: string | null; + text: string; + uri: string; + }; + type: 'resource'; + } + | { + resource: { + blob: string; + mimeType?: string | null; + uri: string; + }; + type: 'resource'; + }; + +export type AcpContentBlock = + | AcpTextContent + | AcpImageContent + | AcpAudioContent + | AcpResourceLink + | AcpEmbeddedResource; + +export interface AcpPromptRequest { + messageId?: string | null; + prompt: AcpContentBlock[]; + sessionId: AcpSessionId; +} + +export interface AcpUsage { + cachedReadTokens?: number | null; + cachedWriteTokens?: number | null; + inputTokens: number; + outputTokens: number; + thoughtTokens?: number | null; + totalTokens: number; +} + +export interface AcpPromptResponse { + stopReason: AcpStopReason; + usage?: AcpUsage | null; + userMessageId?: string | null; +} + +export interface AcpCancelNotification { + sessionId: AcpSessionId; +} + +export interface AcpSetSessionModeRequest { + modeId: AcpSessionModeId; + sessionId: AcpSessionId; +} + +export type AcpSetSessionModeResponse = Record; + +export type AcpSetSessionConfigOptionRequest = + | { + configId: AcpSessionConfigId; + sessionId: AcpSessionId; + type: 'boolean'; + value: boolean; + } + | { + configId: AcpSessionConfigId; + sessionId: AcpSessionId; + type: 'select'; + value: AcpSessionConfigValueId; + }; + +export interface AcpSetSessionConfigOptionResponse { + configOptions: AcpSessionConfigOption[]; +} + +export interface AcpContentChunk { + content: AcpContentBlock; + messageId?: string | null; +} + +export type AcpToolKind = + | 'read' + | 'edit' + | 'delete' + | 'move' + | 'search' + | 'execute' + | 'think' + | 'fetch' + | 'switch_mode' + | 'other'; + +export type AcpToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; + +export interface AcpDiffToolContent { + newText: string; + oldText?: string | null; + path: string; + type: 'diff'; +} + +export interface AcpTerminalToolContent { + terminalId: string; + type: 'terminal'; +} + +export interface AcpWrappedContentToolContent { + content: AcpContentBlock; + type: 'content'; +} + +export type AcpToolCallContent = + | AcpDiffToolContent + | AcpTerminalToolContent + | AcpWrappedContentToolContent; + +export interface AcpToolCallLocation { + line?: number | null; + path: string; +} + +export interface AcpToolCall { + content?: AcpToolCallContent[]; + kind?: AcpToolKind | null; + locations?: AcpToolCallLocation[]; + rawInput?: unknown; + rawOutput?: unknown; + status?: AcpToolCallStatus | null; + title: string; + toolCallId: AcpToolCallId; +} + +export interface AcpToolCallUpdate { + content?: AcpToolCallContent[] | null; + kind?: AcpToolKind | null; + locations?: AcpToolCallLocation[] | null; + rawInput?: unknown; + rawOutput?: unknown; + status?: AcpToolCallStatus | null; + title?: string | null; + toolCallId: AcpToolCallId; +} + +export type AcpPlanEntryPriority = 'high' | 'medium' | 'low'; +export type AcpPlanEntryStatus = 'pending' | 'in_progress' | 'completed'; + +export interface AcpPlanEntry { + content: string; + priority: AcpPlanEntryPriority; + status: AcpPlanEntryStatus; +} + +export interface AcpPlan { + entries: AcpPlanEntry[]; +} + +export interface AcpAvailableCommandInput { + hint: string; +} + +export interface AcpAvailableCommand { + description?: string | null; + input?: AcpAvailableCommandInput | null; + name: string; +} + +export interface AcpAvailableCommandsUpdate { + availableCommands: AcpAvailableCommand[]; +} + +export interface AcpCurrentModeUpdate { + currentModeId: AcpSessionModeId; +} + +export interface AcpConfigOptionUpdate { + configOptions: AcpSessionConfigOption[]; +} + +export interface AcpSessionInfoUpdate { + title?: string | null; + updatedAt?: string | null; +} + +export interface AcpUsageUpdate { + cost?: { + amount: number; + currency: string; + } | null; + size: number; + used: number; +} + +export type AcpSessionUpdate = + | (AcpContentChunk & { sessionUpdate: 'user_message_chunk' }) + | (AcpContentChunk & { sessionUpdate: 'agent_message_chunk' }) + | (AcpContentChunk & { sessionUpdate: 'agent_thought_chunk' }) + | (AcpToolCall & { sessionUpdate: 'tool_call' }) + | (AcpToolCallUpdate & { sessionUpdate: 'tool_call_update' }) + | (AcpPlan & { sessionUpdate: 'plan' }) + | (AcpAvailableCommandsUpdate & { sessionUpdate: 'available_commands_update' }) + | (AcpCurrentModeUpdate & { sessionUpdate: 'current_mode_update' }) + | (AcpConfigOptionUpdate & { sessionUpdate: 'config_option_update' }) + | (AcpSessionInfoUpdate & { sessionUpdate: 'session_info_update' }) + | (AcpUsageUpdate & { sessionUpdate: 'usage_update' }); + +export interface AcpSessionNotification { + sessionId: AcpSessionId; + update: AcpSessionUpdate; +} + +export type AcpPermissionOptionKind = + | 'allow_once' + | 'allow_always' + | 'reject_once' + | 'reject_always'; + +export interface AcpPermissionOption { + kind: AcpPermissionOptionKind; + name: string; + optionId: AcpPermissionOptionId; +} + +export interface AcpRequestPermissionRequest { + options: AcpPermissionOption[]; + sessionId: AcpSessionId; + toolCall: AcpToolCallUpdate; +} + +export type AcpRequestPermissionResponse = { + outcome: + | { + outcome: 'cancelled'; + } + | { + optionId: AcpPermissionOptionId; + outcome: 'selected'; + }; +}; + +export interface AcpReadTextFileRequest { + limit?: number | null; + line?: number | null; + path: string; + sessionId: AcpSessionId; +} + +export interface AcpReadTextFileResponse { + content: string; +} + +export interface AcpWriteTextFileRequest { + content: string; + path: string; + sessionId: AcpSessionId; +} + +export type AcpWriteTextFileResponse = Record; + +export interface AcpCreateTerminalRequest { + args?: string[]; + command: string; + cwd?: string | null; + env?: AcpEnvVariable[]; + outputByteLimit?: number | null; + sessionId: AcpSessionId; +} + +export interface AcpCreateTerminalResponse { + terminalId: string; +} + +export interface AcpTerminalOutputRequest { + sessionId: AcpSessionId; + terminalId: string; +} + +export interface AcpTerminalExitStatus { + exitCode?: number | null; + signal?: string | null; +} + +export interface AcpTerminalOutputResponse { + exitStatus?: AcpTerminalExitStatus | null; + output: string; + truncated: boolean; +} + +export interface AcpWaitForTerminalExitRequest { + sessionId: AcpSessionId; + terminalId: string; +} + +export interface AcpWaitForTerminalExitResponse { + exitCode?: number | null; + signal?: string | null; +} + +export interface AcpKillTerminalRequest { + sessionId: AcpSessionId; + terminalId: string; +} + +export type AcpKillTerminalResponse = Record; + +export interface AcpReleaseTerminalRequest { + sessionId: AcpSessionId; + terminalId: string; +} + +export type AcpReleaseTerminalResponse = Record; diff --git a/src/providers/claude/AGENTS.md b/src/providers/claude/AGENTS.md new file mode 100644 index 0000000..d669cec --- /dev/null +++ b/src/providers/claude/AGENTS.md @@ -0,0 +1,33 @@ +# Claude Provider + +`src/providers/claude/` wraps `@anthropic-ai/claude-agent-sdk` behind `ChatRuntime` and layers Claude Code CLI compatibility around it. + +## Ownership + +- Runtime lifecycle, prompt encoding, stream transforms, history hydration, CLI resolution, plugin discovery, agent discovery, MCP storage, settings UI, and Claude-specific storage live here. +- Shared feature code should consume Claude behavior through core contracts and registries. + +## Design Rules + +- Keep the persistent SDK query alive across turns when possible. Update model, permission mode, MCP servers, and effort through SDK calls. +- Restart the persistent query when the effective system prompt, disabled-tool set, plugin set, settings source set, CLI path, Chrome enablement, or external context paths change. +- Do not duplicate assistant text. The SDK can emit text incrementally and again in the final assistant message; stream handling must preserve the existing dedupe behavior. +- Token usage is intentionally merged from assistant and result messages. Assistant messages provide accurate input-side counts; result messages provide authoritative context-window data. +- `createCustomSpawnFunction()` handles Obsidian/Electron process quirks. Preserve full-path `node` resolution and manual abort handling. + +## Storage Rules + +- `CCSettingsStorage.save()` must merge with existing `.claude/settings.json`; Claudian only owns permissions and plugin enablement. +- `.claude/mcp.json` has a Claude-compatible `mcpServers` namespace and a Claudian `_claudian.servers` metadata namespace. Keep them separate. +- Plugin enabled state is dual-written to `.claude/settings.json` and `PluginManager.plugins[].enabled`. Keep both in sync. +- Slash command IDs use reversible encoding: dashes become `-_`, slashes become `--`. + +## Runtime Gotchas + +- SDK amnesia is detected when the returned session ID differs from the resume ID. The next turn injects full conversation history unless this is the first `session_init` after a fork. +- Crash recovery retries once only when the previous send produced no chunks. +- Auto-triggered SDK turns can arrive without a registered handler; they buffer until the result event. +- `MessageChannel` coalesces text-only queued messages and keeps only one queued attachment message. +- Claude session files are tree-structured. Branch filtering must preserve the canonical branch plus relevant sibling tool results. +- `EnterPlanMode` does not hit `canUseTool`; `ExitPlanMode` does. +- Context-window selection must handle multi-model runs by exact model match first, then family match, and null on ambiguity. diff --git a/src/providers/claude/CLAUDE.md b/src/providers/claude/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/src/providers/claude/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/providers/claude/agents/AgentManager.ts b/src/providers/claude/agents/AgentManager.ts new file mode 100644 index 0000000..482482b --- /dev/null +++ b/src/providers/claude/agents/AgentManager.ts @@ -0,0 +1,230 @@ +/** + * Agent load order (earlier sources take precedence for duplicate IDs): + * 0. Built-in agents: dynamically provided via SDK init message + * 1. Plugin agents: {installPath}/agents/*.md (namespaced as plugin-name:agent-name) + * 2. Vault agents: {vaultPath}/.claude/agents/*.md + * 3. Global agents: {CLAUDE_CONFIG_DIR}/agents/*.md + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import type { AgentDefinition, AgentFrontmatter } from '../../../core/types'; +import { resolveClaudeConfigDir } from '../config/ClaudeConfigDir'; +import type { PluginManager } from '../plugins/PluginManager'; +import { buildAgentFromFrontmatter, parseAgentFile } from './AgentStorage'; + +const VAULT_AGENTS_DIR = '.claude/agents'; +const PLUGIN_AGENTS_DIR = 'agents'; + +// Fallback built-in agent names for before the init message arrives. +const FALLBACK_BUILTIN_AGENT_NAMES = ['Explore', 'Plan', 'Bash', 'general-purpose']; + +const BUILTIN_AGENT_DESCRIPTIONS: Record = { + 'Explore': 'Fast codebase exploration and search', + 'Plan': 'Implementation planning and architecture', + 'Bash': 'Command execution specialist', + 'general-purpose': 'Multi-step tasks and complex workflows', +}; + +function makeBuiltinAgent(name: string): AgentDefinition { + return { + id: name, + name: name.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), + description: BUILTIN_AGENT_DESCRIPTIONS[name] ?? '', + prompt: '', // Built-in — prompt managed by SDK + source: 'builtin', + }; +} + +function normalizePluginName(name: string): string { + return name.toLowerCase().replace(/\s+/g, '-'); +} + +export class AgentManager { + private agents: AgentDefinition[] = []; + private builtinAgentNames: string[] = FALLBACK_BUILTIN_AGENT_NAMES; + private vaultPath: string; + private pluginManager: PluginManager; + private resolveConfigDir: () => string; + + constructor( + vaultPath: string, + pluginManager: PluginManager, + configDir: string | (() => string) = () => resolveClaudeConfigDir(), + ) { + this.vaultPath = vaultPath; + this.pluginManager = pluginManager; + this.resolveConfigDir = typeof configDir === 'function' ? configDir : () => configDir; + } + + /** Built-in agents are those from init that are NOT loaded from files. */ + setBuiltinAgentNames(names: string[]): void { + this.builtinAgentNames = names; + // Rebuild agents to reflect the new built-in list + const fileAgentIds = new Set( + this.agents.filter(a => a.source !== 'builtin').map(a => a.id) + ); + // Replace built-in entries with updated list + this.agents = [ + ...names.filter(n => !fileAgentIds.has(n)).map(makeBuiltinAgent), + ...this.agents.filter(a => a.source !== 'builtin'), + ]; + } + + async loadAgents(): Promise { + this.agents = []; + + for (const name of this.builtinAgentNames) { + this.addAgent(makeBuiltinAgent(name)); + } + + try { this.loadPluginAgents(); } catch { /* non-critical */ } + try { this.loadVaultAgents(); } catch { /* non-critical */ } + try { this.loadGlobalAgents(); } catch { /* non-critical */ } + } + + getAvailableAgents(): AgentDefinition[] { + return [...this.agents]; + } + + getAgentById(id: string): AgentDefinition | undefined { + return this.agents.find(a => a.id === id); + } + + /** Used for @-mention filtering in the chat input. */ + searchAgents(query: string): AgentDefinition[] { + const q = query.toLowerCase(); + return this.agents.filter(a => + a.name.toLowerCase().includes(q) || + a.id.toLowerCase().includes(q) || + a.description.toLowerCase().includes(q) + ); + } + + private loadPluginAgents(): void { + for (const plugin of this.pluginManager.getPlugins()) { + if (!plugin.enabled) continue; + + const agentsDir = path.join(plugin.installPath, PLUGIN_AGENTS_DIR); + if (!fs.existsSync(agentsDir)) continue; + + this.loadAgentsFromFiles( + this.listMarkdownFiles(agentsDir), + (filePath) => this.parsePluginAgentFromFile(filePath, plugin.name), + ); + } + } + + private loadVaultAgents(): void { + this.loadAgentsFromDirectory(path.join(this.vaultPath, VAULT_AGENTS_DIR), 'vault'); + } + + private loadGlobalAgents(): void { + this.loadAgentsFromDirectory(path.join(this.resolveConfigDir(), 'agents'), 'global'); + } + + private loadAgentsFromDirectory( + dir: string, + source: 'vault' | 'global' + ): void { + if (!fs.existsSync(dir)) return; + + this.loadAgentsFromFiles( + this.listMarkdownFiles(dir), + (filePath) => this.parseAgentFromFile(filePath, source), + ); + } + + private listMarkdownFiles(dir: string): string[] { + const files: string[] = []; + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith('.md')) { + files.push(path.join(dir, entry.name)); + } + } + } catch { + // Non-critical: directory may be unreadable + } + + return files; + } + + private parsePluginAgentFromFile( + filePath: string, + pluginName: string + ): AgentDefinition | null { + return this.parseAgentDefinition( + filePath, + (agentName) => `${normalizePluginName(pluginName)}:${agentName}`, + (frontmatter, body, id) => buildAgentFromFrontmatter(frontmatter, body, { + id, + source: 'plugin', + pluginName, + filePath, + }), + ); + } + + private parseAgentFromFile( + filePath: string, + source: 'vault' | 'global' + ): AgentDefinition | null { + return this.parseAgentDefinition( + filePath, + (agentName) => agentName, + (frontmatter, body, id) => buildAgentFromFrontmatter(frontmatter, body, { + id, + source, + filePath, + }), + ); + } + + private loadAgentsFromFiles( + filePaths: string[], + loadAgent: (filePath: string) => AgentDefinition | null, + ): void { + for (const filePath of filePaths) { + this.addAgent(loadAgent(filePath)); + } + } + + private addAgent(agent: AgentDefinition | null): void { + if (!agent) { + return; + } + if (this.agents.some(existing => existing.id === agent.id)) { + return; + } + this.agents.push(agent); + } + + private parseAgentDefinition( + filePath: string, + buildId: (agentName: string) => string, + buildAgent: ( + frontmatter: AgentFrontmatter, + body: string, + id: string, + ) => AgentDefinition, + ): AgentDefinition | null { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const parsed = parseAgentFile(content); + + if (!parsed) { + return null; + } + + const { frontmatter, body } = parsed; + return buildAgent(frontmatter, body, buildId(frontmatter.name)); + } catch { + return null; + } + } +} diff --git a/src/providers/claude/agents/AgentStorage.ts b/src/providers/claude/agents/AgentStorage.ts new file mode 100644 index 0000000..ed0cde8 --- /dev/null +++ b/src/providers/claude/agents/AgentStorage.ts @@ -0,0 +1,101 @@ +import type { AgentDefinition, AgentFrontmatter } from '../../../core/types'; +import { extractStringArray, isRecord, normalizeStringArray, parseFrontmatter } from '../../../utils/frontmatter'; +import { AGENT_PERMISSION_MODES, type AgentPermissionMode } from '../types/agent'; + +const KNOWN_AGENT_KEYS = new Set([ + 'name', 'description', 'tools', 'disallowedTools', 'model', + 'skills', 'permissionMode', 'hooks', +]); + +export function parseAgentFile(content: string): { frontmatter: AgentFrontmatter; body: string } | null { + const parsed = parseFrontmatter(content); + if (!parsed) return null; + + const { frontmatter: fm, body } = parsed; + + const name = fm.name; + const description = fm.description; + + if (typeof name !== 'string' || !name.trim()) return null; + if (typeof description !== 'string' || !description.trim()) return null; + + const tools = fm.tools; + const disallowedTools = fm.disallowedTools; + + if (tools !== undefined && !isStringOrArray(tools)) return null; + if (disallowedTools !== undefined && !isStringOrArray(disallowedTools)) return null; + + const model = typeof fm.model === 'string' ? fm.model : undefined; + + const extra: Record = {}; + for (const key of Object.keys(fm)) { + if (!KNOWN_AGENT_KEYS.has(key)) { + extra[key] = fm[key]; + } + } + + const frontmatter: AgentFrontmatter = { + name, + description, + tools, + disallowedTools, + model, + skills: extractStringArray(fm, 'skills'), + permissionMode: typeof fm.permissionMode === 'string' ? fm.permissionMode : undefined, + hooks: isRecord(fm.hooks) ? fm.hooks : undefined, + extraFrontmatter: Object.keys(extra).length > 0 ? extra : undefined, + }; + + return { frontmatter, body: body.trim() }; +} + +function isStringOrArray(value: unknown): value is string | string[] { + return typeof value === 'string' || Array.isArray(value); +} + +export function parseToolsList(tools?: string | string[]): string[] | undefined { + return normalizeStringArray(tools); +} + +export function parsePermissionMode(mode?: string): AgentPermissionMode | undefined { + if (!mode) return undefined; + const trimmed = mode.trim(); + if ((AGENT_PERMISSION_MODES as readonly string[]).includes(trimmed)) { + return trimmed as AgentPermissionMode; + } + return undefined; +} + +const VALID_MODELS = ['sonnet', 'opus', 'haiku', 'inherit'] as const; + +export function parseModel(model?: string): 'sonnet' | 'opus' | 'haiku' | 'inherit' { + if (!model) return 'inherit'; + const normalized = model.toLowerCase().trim(); + if (VALID_MODELS.includes(normalized as typeof VALID_MODELS[number])) { + return normalized as 'sonnet' | 'opus' | 'haiku' | 'inherit'; + } + return 'inherit'; +} + +export function buildAgentFromFrontmatter( + frontmatter: AgentFrontmatter, + body: string, + meta: { id: string; source: AgentDefinition['source']; filePath?: string; pluginName?: string } +): AgentDefinition { + return { + id: meta.id, + name: frontmatter.name, + description: frontmatter.description, + prompt: body, + tools: parseToolsList(frontmatter.tools), + disallowedTools: parseToolsList(frontmatter.disallowedTools), + model: parseModel(frontmatter.model), + source: meta.source, + filePath: meta.filePath, + pluginName: meta.pluginName, + skills: frontmatter.skills, + permissionMode: parsePermissionMode(frontmatter.permissionMode), + hooks: frontmatter.hooks, + extraFrontmatter: frontmatter.extraFrontmatter, + }; +} diff --git a/src/providers/claude/app/ClaudeWorkspaceServices.ts b/src/providers/claude/app/ClaudeWorkspaceServices.ts new file mode 100644 index 0000000..7c6d689 --- /dev/null +++ b/src/providers/claude/app/ClaudeWorkspaceServices.ts @@ -0,0 +1,105 @@ +import { McpServerManager } from '../../../core/mcp/McpServerManager'; +import type { ProviderCommandCatalog } from '../../../core/providers/commands/ProviderCommandCatalog'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { ProviderWorkspaceRegistry } from '../../../core/providers/ProviderWorkspaceRegistry'; +import type { + AppAgentManager, + AppAgentStorage, + AppMcpStorage, + AppPluginManager, + ProviderCliResolver, + ProviderWorkspaceRegistration, + ProviderWorkspaceServices, +} from '../../../core/providers/types'; +import type { VaultFileAdapter } from '../../../core/storage/VaultFileAdapter'; +import { parseEnvironmentVariables } from '../../../utils/env'; +import { getVaultPath } from '../../../utils/path'; +import { AgentManager } from '../agents/AgentManager'; +import { ClaudeCommandCatalog } from '../commands/ClaudeCommandCatalog'; +import { probeRuntimeCommands } from '../commands/probeRuntimeCommands'; +import { resolveClaudeConfigDir } from '../config/ClaudeConfigDir'; +import { PluginManager } from '../plugins/PluginManager'; +import { ClaudeCliResolver } from '../runtime/ClaudeCliResolver'; +import { StorageService } from '../storage/StorageService'; +import { claudeSettingsTabRenderer } from '../ui/ClaudeSettingsTab'; + +export interface ClaudeWorkspaceServices extends ProviderWorkspaceServices { + claudeStorage: StorageService; + cliResolver: ProviderCliResolver; + mcpStorage: AppMcpStorage; + mcpManager: McpServerManager; + pluginManager: AppPluginManager; + agentStorage: AppAgentStorage; + agentManager: AppAgentManager; + commandCatalog: ProviderCommandCatalog; + agentMentionProvider: AppAgentManager; +} + +export async function createClaudeWorkspaceServices( + plugin: ProviderHost, + adapter: VaultFileAdapter, +): Promise { + const claudeStorage = new StorageService(plugin, adapter); + await claudeStorage.ensureDirectories(); + + const cliResolver = new ClaudeCliResolver(); + const mcpStorage = claudeStorage.mcp; + const mcpManager = new McpServerManager(mcpStorage); + await mcpManager.loadServers(); + + const vaultPath = getVaultPath(plugin.app) ?? ''; + const getClaudeConfigDir = () => resolveClaudeConfigDir({ + environment: { + ...process.env, + ...parseEnvironmentVariables(plugin.getActiveEnvironmentVariables('claude')), + }, + hostPlatform: process.platform, + vaultPath, + }); + const pluginManager = new PluginManager( + vaultPath, + claudeStorage.ccSettings, + getClaudeConfigDir, + ); + await pluginManager.loadPlugins(); + + const agentStorage = claudeStorage.agents; + const agentManager = new AgentManager(vaultPath, pluginManager, getClaudeConfigDir); + await agentManager.loadAgents(); + + const commandCatalog = new ClaudeCommandCatalog( + claudeStorage.commands, + claudeStorage.skills, + () => probeRuntimeCommands(plugin), + ); + + return { + claudeStorage, + cliResolver, + mcpStorage, + mcpServerManager: mcpManager, + mcpManager, + pluginManager, + agentStorage, + agentManager, + commandCatalog, + agentMentionProvider: agentManager, + settingsTabRenderer: claudeSettingsTabRenderer, + refreshAgentMentions: async () => { + await pluginManager.loadPlugins(); + await agentManager.loadAgents(); + }, + }; +} + +export const claudeWorkspaceRegistration: ProviderWorkspaceRegistration = { + initialize: async ({ plugin, vaultAdapter }) => createClaudeWorkspaceServices(plugin, vaultAdapter), +}; + +export function maybeGetClaudeWorkspaceServices(): ClaudeWorkspaceServices | null { + return ProviderWorkspaceRegistry.getServices('claude') as ClaudeWorkspaceServices | null; +} + +export function getClaudeWorkspaceServices(): ClaudeWorkspaceServices { + return ProviderWorkspaceRegistry.requireServices('claude') as ClaudeWorkspaceServices; +} diff --git a/src/providers/claude/auxiliary/ClaudeInlineEditService.ts b/src/providers/claude/auxiliary/ClaudeInlineEditService.ts new file mode 100644 index 0000000..3ff283f --- /dev/null +++ b/src/providers/claude/auxiliary/ClaudeInlineEditService.ts @@ -0,0 +1,129 @@ +import type { HookCallbackMatcher } from '@anthropic-ai/claude-agent-sdk'; + +import { + buildInlineEditPrompt, + getInlineEditSystemPrompt, + parseInlineEditResponse, +} from '../../../core/prompt/inlineEdit'; +import { getProviderSettingsSnapshotWithModel } from '../../../core/providers/conversationModel'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import type { + InlineEditRequest, + InlineEditResult, +} from '../../../core/providers/types'; +import { + isReadOnlyTool, + READ_ONLY_TOOLS, +} from '../../../core/tools/toolNames'; +import { appendContextFiles } from '../../../utils/context'; +import { runColdStartQuery } from '../runtime/claudeColdStartQuery'; + +export type { InlineEditRequest }; + +export function createReadOnlyHook(): HookCallbackMatcher { + return { + hooks: [ + async (hookInput) => { + const input = hookInput as { + tool_name: string; + tool_input: Record; + }; + const toolName = input.tool_name; + + if (isReadOnlyTool(toolName)) { + return { continue: true }; + } + + return { + continue: false, + hookSpecificOutput: { + hookEventName: 'PreToolUse' as const, + permissionDecision: 'deny' as const, + permissionDecisionReason: `Inline edit mode: tool "${toolName}" is not allowed (read-only)`, + }, + }; + }, + ], + }; +} + +export class InlineEditService { + private plugin: ProviderHost; + private abortController: AbortController | null = null; + private modelOverride: string | undefined; + private sessionId: string | null = null; + + constructor(plugin: ProviderHost) { + this.plugin = plugin; + } + + private getScopedSettings(): Record { + return getProviderSettingsSnapshotWithModel( + this.plugin.settings, + 'claude', + this.modelOverride, + ); + } + + setModelOverride(model?: string): void { + const trimmed = model?.trim(); + this.modelOverride = trimmed ? trimmed : undefined; + } + + resetConversation(): void { + this.sessionId = null; + } + + async editText(request: InlineEditRequest): Promise { + this.sessionId = null; + const prompt = buildInlineEditPrompt(request); + return this.sendMessage(prompt); + } + + async continueConversation(message: string, contextFiles?: string[]): Promise { + if (!this.sessionId) { + return { success: false, error: 'No active conversation to continue' }; + } + let prompt = message; + if (contextFiles && contextFiles.length > 0) { + prompt = appendContextFiles(message, contextFiles); + } + return this.sendMessage(prompt); + } + + private async sendMessage(prompt: string): Promise { + const settings = this.getScopedSettings(); + + this.abortController = new AbortController(); + + const hooks = { + PreToolUse: [createReadOnlyHook()], + }; + + try { + const result = await runColdStartQuery({ + plugin: this.plugin, + systemPrompt: getInlineEditSystemPrompt(), + tools: [...READ_ONLY_TOOLS], + hooks, + resumeSessionId: this.sessionId ?? undefined, + abortController: this.abortController, + providerSettings: settings, + }, prompt); + + this.sessionId = result.sessionId; + return parseInlineEditResponse(result.text); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: msg }; + } finally { + this.abortController = null; + } + } + + cancel(): void { + if (this.abortController) { + this.abortController.abort(); + } + } +} diff --git a/src/providers/claude/auxiliary/ClaudeInstructionRefineService.ts b/src/providers/claude/auxiliary/ClaudeInstructionRefineService.ts new file mode 100644 index 0000000..2e6e26e --- /dev/null +++ b/src/providers/claude/auxiliary/ClaudeInstructionRefineService.ts @@ -0,0 +1,90 @@ +import { buildRefineSystemPrompt } from '../../../core/prompt/instructionRefine'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import type { RefineProgressCallback } from '../../../core/providers/types'; +import type { InstructionRefineResult } from '../../../core/types'; +import { runColdStartQuery } from '../runtime/claudeColdStartQuery'; + +export class InstructionRefineService { + private plugin: ProviderHost; + private abortController: AbortController | null = null; + private sessionId: string | null = null; + private existingInstructions: string = ''; + + constructor(plugin: ProviderHost) { + this.plugin = plugin; + } + + resetConversation(): void { + this.sessionId = null; + } + + async refineInstruction( + rawInstruction: string, + existingInstructions: string, + onProgress?: RefineProgressCallback + ): Promise { + this.sessionId = null; + this.existingInstructions = existingInstructions; + const prompt = `Please refine this instruction: "${rawInstruction}"`; + return this.sendMessage(prompt, onProgress); + } + + async continueConversation( + message: string, + onProgress?: RefineProgressCallback + ): Promise { + if (!this.sessionId) { + return { success: false, error: 'No active conversation to continue' }; + } + return this.sendMessage(message, onProgress); + } + + cancel(): void { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + + private async sendMessage( + prompt: string, + onProgress?: RefineProgressCallback + ): Promise { + this.abortController = new AbortController(); + + try { + const result = await runColdStartQuery({ + plugin: this.plugin, + systemPrompt: buildRefineSystemPrompt(this.existingInstructions), + tools: [], + resumeSessionId: this.sessionId ?? undefined, + abortController: this.abortController, + onTextChunk: onProgress + ? (accumulatedText: string) => onProgress(this.parseResponse(accumulatedText)) + : undefined, + }, prompt); + + this.sessionId = result.sessionId; + return this.parseResponse(result.text); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + return { success: false, error: msg }; + } finally { + this.abortController = null; + } + } + + private parseResponse(responseText: string): InstructionRefineResult { + const instructionMatch = responseText.match(/([\s\S]*?)<\/instruction>/); + if (instructionMatch) { + return { success: true, refinedInstruction: instructionMatch[1].trim() }; + } + + const trimmed = responseText.trim(); + if (trimmed) { + return { success: true, clarification: trimmed }; + } + + return { success: false, error: 'Empty response' }; + } +} diff --git a/src/providers/claude/auxiliary/ClaudeTitleGenerationService.ts b/src/providers/claude/auxiliary/ClaudeTitleGenerationService.ts new file mode 100644 index 0000000..5e81ade --- /dev/null +++ b/src/providers/claude/auxiliary/ClaudeTitleGenerationService.ts @@ -0,0 +1,129 @@ +import { TITLE_GENERATION_SYSTEM_PROMPT } from '../../../core/prompt/titleGeneration'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import type { + TitleGenerationCallback, + TitleGenerationResult, +} from '../../../core/providers/types'; +import { parseEnvironmentVariables } from '../../../utils/env'; +import { toClaudeRuntimeModelId } from '../modelSelection'; +import { runColdStartQuery } from '../runtime/claudeColdStartQuery'; +import { claudeChatUIConfig } from '../ui/ClaudeChatUIConfig'; + +export type { TitleGenerationResult }; + +export class TitleGenerationService { + private plugin: ProviderHost; + private activeGenerations: Map = new Map(); + + constructor(plugin: ProviderHost) { + this.plugin = plugin; + } + + async generateTitle( + conversationId: string, + userMessage: string, + callback: TitleGenerationCallback + ): Promise { + // Cancel any existing generation for this conversation + const existingController = this.activeGenerations.get(conversationId); + if (existingController) { + existingController.abort(); + } + + const abortController = new AbortController(); + this.activeGenerations.set(conversationId, abortController); + + const truncatedUser = this.truncateText(userMessage, 500); + const prompt = `User's request:\n"""\n${truncatedUser}\n"""\n\nGenerate a title for this conversation:`; + + try { + const result = await runColdStartQuery({ + plugin: this.plugin, + systemPrompt: TITLE_GENERATION_SYSTEM_PROMPT, + tools: [], + model: this.resolveTitleModel(), + thinking: { disabled: true }, + persistSession: false, + abortController, + }, prompt); + + const title = this.parseTitle(result.text); + if (title) { + await this.safeCallback(callback, conversationId, { success: true, title }); + } else { + await this.safeCallback(callback, conversationId, { + success: false, + error: 'Failed to parse title from response', + }); + } + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + await this.safeCallback(callback, conversationId, { success: false, error: msg }); + } finally { + this.activeGenerations.delete(conversationId); + } + } + + cancel(): void { + for (const controller of this.activeGenerations.values()) { + controller.abort(); + } + this.activeGenerations.clear(); + } + + private resolveTitleModel(): string { + const envVars = parseEnvironmentVariables( + this.plugin.getActiveEnvironmentVariables('claude') + ); + const titleModel = this.plugin.settings.titleGenerationModel; + if (titleModel && claudeChatUIConfig.ownsModel( + titleModel, + this.plugin.settings, + )) { + return toClaudeRuntimeModelId(titleModel); + } + + return ( + envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL || + 'claude-haiku-4-5' + ); + } + + private truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return text.substring(0, maxLength) + '...'; + } + + private parseTitle(responseText: string): string | null { + const trimmed = responseText.trim(); + if (!trimmed) return null; + + let title = trimmed; + if ( + (title.startsWith('"') && title.endsWith('"')) || + (title.startsWith("'") && title.endsWith("'")) + ) { + title = title.slice(1, -1); + } + + title = title.replace(/[.!?:;,]+$/, ''); + + if (title.length > 50) { + title = title.substring(0, 47) + '...'; + } + + return title || null; + } + + private async safeCallback( + callback: TitleGenerationCallback, + conversationId: string, + result: TitleGenerationResult + ): Promise { + try { + await callback(conversationId, result); + } catch { + // Silently ignore callback errors + } + } +} diff --git a/src/providers/claude/auxiliary/extractAssistantText.ts b/src/providers/claude/auxiliary/extractAssistantText.ts new file mode 100644 index 0000000..9daf6a6 --- /dev/null +++ b/src/providers/claude/auxiliary/extractAssistantText.ts @@ -0,0 +1,28 @@ +export function extractAssistantText( + message: { type: string; message?: unknown } +): string { + if (message.type !== 'assistant') { + return ''; + } + + const payload = message.message; + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + return ''; + } + + const content = (payload as { content?: unknown }).content; + if (!Array.isArray(content)) { + return ''; + } + + return (content as unknown[]) + .filter((block): block is { type: 'text'; text: string } => { + if (!block || typeof block !== 'object' || Array.isArray(block)) { + return false; + } + const record = block as Record; + return record.type === 'text' && typeof record.text === 'string'; + }) + .map((block) => block.text) + .join(''); +} diff --git a/src/providers/claude/capabilities.ts b/src/providers/claude/capabilities.ts new file mode 100644 index 0000000..887f8b6 --- /dev/null +++ b/src/providers/claude/capabilities.ts @@ -0,0 +1,17 @@ +import type { ProviderCapabilities } from '../../core/providers/types'; + +export const CLAUDE_PROVIDER_CAPABILITIES: Readonly = Object.freeze({ + providerId: 'claude', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: true, + supportsFork: true, + supportsProviderCommands: true, + supportsImageAttachments: true, + supportsInstructionMode: true, + supportsMcpTools: true, + supportsTurnSteer: false, + reasoningControl: 'effort', + planPathPrefix: '/.claude/plans/', +}); diff --git a/src/providers/claude/cli/findClaudeCLIPath.ts b/src/providers/claude/cli/findClaudeCLIPath.ts new file mode 100644 index 0000000..c83398f --- /dev/null +++ b/src/providers/claude/cli/findClaudeCLIPath.ts @@ -0,0 +1,261 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { parsePathEntries, resolveNvmDefaultBin } from '../../../utils/path'; + +const CLAUDE_CODE_PACKAGE_SEGMENTS = ['node_modules', '@anthropic-ai', 'claude-code']; +const CLAUDE_CODE_NODE_ENTRYPOINTS = ['cli-wrapper.cjs', 'cli.js']; + +function getEnvValue(name: string): string | undefined { + return process.env[name]; +} + +function dedupePaths(entries: string[]): string[] { + const seen = new Set(); + return entries.filter(entry => { + const key = process.platform === 'win32' ? entry.toLowerCase() : entry; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function findFirstExistingPath(entries: string[], candidates: string[]): string | null { + for (const dir of entries) { + if (!dir) continue; + for (const candidate of candidates) { + const fullPath = path.join(dir, candidate); + if (isExistingFile(fullPath)) { + return fullPath; + } + } + } + return null; +} + +function isExistingFile(filePath: string): boolean { + try { + if (fs.existsSync(filePath)) { + const stat = fs.statSync(filePath); + return stat.isFile(); + } + } catch { + // Inaccessible path + } + return false; +} + +function findClaudeCodeNodeEntrypoint(packageRoot: string): string | null { + for (const entrypoint of CLAUDE_CODE_NODE_ENTRYPOINTS) { + const candidate = path.join(packageRoot, entrypoint); + if (isExistingFile(candidate)) { + return candidate; + } + } + + return null; +} + +function resolveClaudeCodeEntrypointNearPathEntry(entry: string, isWindows: boolean): string | null { + const directCandidate = findClaudeCodeNodeEntrypoint( + path.join(entry, ...CLAUDE_CODE_PACKAGE_SEGMENTS) + ); + if (directCandidate) { + return directCandidate; + } + + const baseName = path.basename(entry).toLowerCase(); + if (baseName === 'bin') { + const prefix = path.dirname(entry); + const packageParent = isWindows ? prefix : path.join(prefix, 'lib'); + const candidate = findClaudeCodeNodeEntrypoint( + path.join(packageParent, ...CLAUDE_CODE_PACKAGE_SEGMENTS) + ); + if (candidate) { + return candidate; + } + } + + return null; +} + +function resolveClaudeCodeEntrypointFromPathEntries(entries: string[], isWindows: boolean): string | null { + for (const entry of entries) { + const candidate = resolveClaudeCodeEntrypointNearPathEntry(entry, isWindows); + if (candidate) { + return candidate; + } + } + return null; +} + +function resolveClaudeFromPathEntries( + entries: string[], + isWindows: boolean +): string | null { + if (entries.length === 0) { + return null; + } + + if (!isWindows) { + const unixCandidate = findFirstExistingPath(entries, ['claude']); + return unixCandidate; + } + + const exeCandidate = findFirstExistingPath(entries, ['claude.exe', 'claude']); + if (exeCandidate) { + return exeCandidate; + } + + const packageEntrypoint = resolveClaudeCodeEntrypointFromPathEntries(entries, isWindows); + if (packageEntrypoint) { + return packageEntrypoint; + } + + return null; +} + +function getNpmGlobalPrefix(): string | null { + if (process.env.npm_config_prefix) { + return process.env.npm_config_prefix; + } + + if (process.platform === 'win32') { + const appDataNpm = process.env.APPDATA + ? path.join(process.env.APPDATA, 'npm') + : null; + if (appDataNpm && fs.existsSync(appDataNpm)) { + return appDataNpm; + } + } + + return null; +} + +function addClaudeCodeEntrypointPaths(paths: string[], packageParent: string): void { + const packageRoot = path.join(packageParent, ...CLAUDE_CODE_PACKAGE_SEGMENTS); + for (const entrypoint of CLAUDE_CODE_NODE_ENTRYPOINTS) { + paths.push(path.join(packageRoot, entrypoint)); + } +} + +function getNpmClaudeCodeEntrypointPaths(): string[] { + const homeDir = os.homedir(); + const isWindows = process.platform === 'win32'; + const entrypointPaths: string[] = []; + + if (isWindows) { + addClaudeCodeEntrypointPaths(entrypointPaths, path.join(homeDir, 'AppData', 'Roaming', 'npm')); + + const npmPrefix = getNpmGlobalPrefix(); + if (npmPrefix) { + addClaudeCodeEntrypointPaths(entrypointPaths, npmPrefix); + } + + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + + addClaudeCodeEntrypointPaths(entrypointPaths, path.join(programFiles, 'nodejs', 'node_global')); + addClaudeCodeEntrypointPaths(entrypointPaths, path.join(programFilesX86, 'nodejs', 'node_global')); + addClaudeCodeEntrypointPaths(entrypointPaths, path.join('D:', 'Program Files', 'nodejs', 'node_global')); + } else { + addClaudeCodeEntrypointPaths(entrypointPaths, path.join(homeDir, '.npm-global', 'lib')); + addClaudeCodeEntrypointPaths(entrypointPaths, '/usr/local/lib'); + addClaudeCodeEntrypointPaths(entrypointPaths, '/usr/lib'); + + if (process.env.npm_config_prefix) { + addClaudeCodeEntrypointPaths(entrypointPaths, path.join(process.env.npm_config_prefix, 'lib')); + } + } + + return entrypointPaths; +} + +export function findClaudeCLIPath(pathValue?: string): string | null { + const homeDir = os.homedir(); + const isWindows = process.platform === 'win32'; + + const customEntries = dedupePaths(parsePathEntries(pathValue)); + + if (customEntries.length > 0) { + const customResolution = resolveClaudeFromPathEntries(customEntries, isWindows); + if (customResolution) { + return customResolution; + } + } + + // On Windows, prefer native .exe, then Node-backed package entrypoints. Avoid .cmd fallback + // because it requires shell: true and breaks SDK stdio streaming. + if (isWindows) { + const exePaths: string[] = [ + path.join(homeDir, '.claude', 'local', 'claude.exe'), + path.join(homeDir, 'AppData', 'Local', 'Claude', 'claude.exe'), + path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Claude', 'claude.exe'), + path.join(process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)', 'Claude', 'claude.exe'), + path.join(homeDir, '.local', 'bin', 'claude.exe'), + ]; + + for (const p of exePaths) { + if (isExistingFile(p)) { + return p; + } + } + + const packageEntrypointPaths = getNpmClaudeCodeEntrypointPaths(); + for (const p of packageEntrypointPaths) { + if (isExistingFile(p)) { + return p; + } + } + + } + + const commonPaths: string[] = [ + path.join(homeDir, '.claude', 'local', 'claude'), + path.join(homeDir, '.local', 'bin', 'claude'), + path.join(homeDir, '.volta', 'bin', 'claude'), + path.join(homeDir, '.asdf', 'shims', 'claude'), + path.join(homeDir, '.asdf', 'bin', 'claude'), + '/usr/local/bin/claude', + '/opt/homebrew/bin/claude', + path.join(homeDir, 'bin', 'claude'), + path.join(homeDir, '.npm-global', 'bin', 'claude'), + ]; + + const npmPrefix = getNpmGlobalPrefix(); + if (npmPrefix) { + commonPaths.push(path.join(npmPrefix, 'bin', 'claude')); + } + + // NVM: resolve default version bin when NVM_BIN env var is not available (GUI apps) + const nvmBin = resolveNvmDefaultBin(homeDir); + if (nvmBin) { + commonPaths.push(path.join(nvmBin, 'claude')); + } + + for (const p of commonPaths) { + if (isExistingFile(p)) { + return p; + } + } + + if (!isWindows) { + const packageEntrypointPaths = getNpmClaudeCodeEntrypointPaths(); + for (const p of packageEntrypointPaths) { + if (isExistingFile(p)) { + return p; + } + } + } + + const envEntries = dedupePaths(parsePathEntries(getEnvValue('PATH'))); + if (envEntries.length > 0) { + const envResolution = resolveClaudeFromPathEntries(envEntries, isWindows); + if (envResolution) { + return envResolution; + } + } + + return null; +} diff --git a/src/providers/claude/commands/ClaudeCommandCatalog.ts b/src/providers/claude/commands/ClaudeCommandCatalog.ts new file mode 100644 index 0000000..dd775c8 --- /dev/null +++ b/src/providers/claude/commands/ClaudeCommandCatalog.ts @@ -0,0 +1,149 @@ +import type { + ProviderCommandCatalog, + ProviderCommandDropdownConfig, +} from '../../../core/providers/commands/ProviderCommandCatalog'; +import type { ProviderCommandEntry } from '../../../core/providers/commands/ProviderCommandEntry'; +import type { SlashCommand } from '../../../core/types'; +import { isSkill } from '../../../utils/slashCommand'; +import type { SkillStorage } from '../storage/SkillStorage'; +import type { SlashCommandStorage } from '../storage/SlashCommandStorage'; + +function slashCommandToEntry(cmd: SlashCommand): ProviderCommandEntry { + const skill = isSkill(cmd); + return { + id: cmd.id, + providerId: 'claude', + kind: skill ? 'skill' : 'command', + name: cmd.name, + description: cmd.description, + content: cmd.content, + argumentHint: cmd.argumentHint, + allowedTools: cmd.allowedTools, + model: cmd.model, + disableModelInvocation: cmd.disableModelInvocation, + userInvocable: cmd.userInvocable, + context: cmd.context, + agent: cmd.agent, + hooks: cmd.hooks, + scope: cmd.source === 'sdk' ? 'runtime' : 'vault', + source: cmd.source ?? 'user', + isEditable: cmd.source !== 'sdk', + isDeletable: cmd.source !== 'sdk', + displayPrefix: '/', + insertPrefix: '/', + }; +} + +function entryToSlashCommand(entry: ProviderCommandEntry): SlashCommand { + return { + id: entry.id, + name: entry.name, + description: entry.description, + content: entry.content, + argumentHint: entry.argumentHint, + allowedTools: entry.allowedTools, + model: entry.model, + disableModelInvocation: entry.disableModelInvocation, + userInvocable: entry.userInvocable, + context: entry.context, + agent: entry.agent, + hooks: entry.hooks, + source: entry.source, + kind: entry.kind, + }; +} + +// SDK built-in skills that have no meaning inside Claudian +const BUILTIN_HIDDEN_COMMANDS = new Set([ + 'context', 'cost', 'debug', 'extra-usage', 'heapdump', 'init', + 'insights', 'loop', 'schedule', 'security-review', 'simplify', 'update-config', +]); + +export type CommandProbe = () => Promise; + +export class ClaudeCommandCatalog implements ProviderCommandCatalog { + private sdkCommands: SlashCommand[] = []; + private probePromise: Promise | null = null; + + constructor( + private commandStorage: SlashCommandStorage, + private skillStorage: SkillStorage, + private probe?: CommandProbe, + ) {} + + setRuntimeCommands(commands: SlashCommand[]): void { + this.sdkCommands = commands; + } + + async listDropdownEntries(context: { includeBuiltIns: boolean }): Promise { + void context; + // SDK commands already include vault commands/skills (the SDK scans + // .claude/commands/ and .claude/skills/ internally). No file scan needed. + // When the cache is empty (cold start, no active runtime), probe the SDK. + if (this.sdkCommands.length === 0 && this.probe) { + await this.ensureProbed(); + } + const runtimeEntries = this.sdkCommands + .filter(cmd => !BUILTIN_HIDDEN_COMMANDS.has(cmd.name.toLowerCase())) + .map(slashCommandToEntry); + if (runtimeEntries.length > 0) { + return runtimeEntries; + } + return this.listVaultEntries(); + } + + /** Probe the SDK for commands. Deduplicates concurrent calls. */ + private async ensureProbed(): Promise { + if (!this.probe) return; + if (!this.probePromise) { + this.probePromise = this.probe().then((commands) => { + // Only apply probe results if the runtime hasn't provided fresher data + if (this.sdkCommands.length === 0 && commands.length > 0) { + this.sdkCommands = commands; + } + }).catch(() => { + // Probe is best-effort + }).finally(() => { + this.probePromise = null; + }); + } + await this.probePromise; + } + + async listVaultEntries(): Promise { + const commands = await this.commandStorage.loadAll(); + const skills = await this.skillStorage.loadAll(); + return [...commands, ...skills].map(slashCommandToEntry); + } + + async saveVaultEntry(entry: ProviderCommandEntry): Promise { + const cmd = entryToSlashCommand(entry); + if (entry.kind === 'skill') { + await this.skillStorage.save(cmd); + } else { + await this.commandStorage.save(cmd); + } + } + + async deleteVaultEntry(entry: ProviderCommandEntry): Promise { + if (entry.kind === 'skill') { + await this.skillStorage.delete(entry.id); + } else { + await this.commandStorage.delete(entry.id); + } + } + + getDropdownConfig(): ProviderCommandDropdownConfig { + return { + providerId: 'claude', + triggerChars: ['/'], + builtInPrefix: '/', + skillPrefix: '/', + commandPrefix: '/', + }; + } + + async refresh(): Promise { + // Claude revalidation happens externally via setRuntimeCommands + } +} diff --git a/src/providers/claude/commands/probeRuntimeCommands.ts b/src/providers/claude/commands/probeRuntimeCommands.ts new file mode 100644 index 0000000..48f0573 --- /dev/null +++ b/src/providers/claude/commands/probeRuntimeCommands.ts @@ -0,0 +1,86 @@ +import type { SlashCommand as SDKSlashCommand } from '@anthropic-ai/claude-agent-sdk'; +import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk'; + +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import type { SlashCommand } from '../../../core/types'; +import { getEnhancedPath, parseEnvironmentVariables } from '../../../utils/env'; +import { getVaultPath } from '../../../utils/path'; +import { createCustomSpawnFunction } from '../runtime/customSpawn'; +import { + getClaudeProviderSettings, + resolveClaudeSettingSources, +} from '../settings'; + +function mapSdkCommands(sdkCommands: SDKSlashCommand[]): SlashCommand[] { + return sdkCommands.map((cmd) => ({ + id: `sdk:${cmd.name}`, + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + content: '', + source: 'sdk' as const, + })); +} + +/** + * Probes the Claude SDK locally to discover available commands and skills. + * + * Fires a throwaway query with an empty prompt — the SDK emits a system/init + * event from local config parsing alone (no API call, no cost). The probe + * captures that event, calls supportedCommands() for full metadata, then aborts. + */ +export async function probeRuntimeCommands(plugin: ProviderHost): Promise { + const vaultPath = getVaultPath(plugin.app); + if (!vaultPath) return []; + + const cliPath = plugin.getResolvedProviderCliPath('claude'); + if (!cliPath) return []; + + const customEnv = parseEnvironmentVariables( + plugin.getActiveEnvironmentVariables('claude') + ); + const enhancedPath = getEnhancedPath(customEnv.PATH, cliPath); + const claudeSettings = getClaudeProviderSettings( + plugin.settings, + ); + + const abortController = new AbortController(); + let commands: SlashCommand[] = []; + const extraArgs = { + ...(claudeSettings.safeMode === 'auto' ? { 'enable-auto-mode': null } : {}), + ...(claudeSettings.enableChrome ? { chrome: null } : {}), + }; + + try { + const conversation = agentQuery({ + prompt: '', + options: { + cwd: vaultPath, + abortController, + pathToClaudeCodeExecutable: cliPath, + env: { ...process.env, ...customEnv, PATH: enhancedPath }, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + settingSources: resolveClaudeSettingSources(claudeSettings.loadUserSettings), + ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), + spawnClaudeCodeProcess: createCustomSpawnFunction(enhancedPath), + persistSession: false, + }, + }); + + for await (const event of conversation) { + if (event.type === 'system' && event.subtype === 'init') { + try { + const sdkCommands: SDKSlashCommand[] = await conversation.supportedCommands(); + commands = mapSdkCommands(sdkCommands); + } catch { /* best-effort */ } + abortController.abort(); + break; + } + } + } catch { + // Probe is best-effort; swallow abort errors. + } + + return commands; +} diff --git a/src/providers/claude/config/ClaudeConfigDir.ts b/src/providers/claude/config/ClaudeConfigDir.ts new file mode 100644 index 0000000..b76dfcf --- /dev/null +++ b/src/providers/claude/config/ClaudeConfigDir.ts @@ -0,0 +1,52 @@ +import * as os from 'os'; +import * as path from 'path'; + +export interface ClaudeConfigDirContext { + environment?: NodeJS.ProcessEnv; + hostPlatform?: NodeJS.Platform; + vaultPath?: string | null; +} + +function resolveSdkHomeDir( + environment: NodeJS.ProcessEnv, + hostPlatform: NodeJS.Platform, +): string { + if (hostPlatform === 'win32') { + if (environment.USERPROFILE !== undefined) { + return environment.USERPROFILE; + } + if (environment.HOMEDRIVE !== undefined && environment.HOMEPATH !== undefined) { + return `${environment.HOMEDRIVE}${environment.HOMEPATH}`; + } + } else if (environment.HOME !== undefined) { + return environment.HOME; + } + + return os.homedir(); +} + +function resolveFromSdkWorkingDirectory( + value: string, + vaultPath?: string | null, +): string { + const normalizedValue = value.normalize('NFC'); + return path.isAbsolute(normalizedValue) + ? path.normalize(normalizedValue) + : path.resolve(vaultPath ?? process.cwd(), normalizedValue); +} + +export function resolveClaudeConfigDir(context?: ClaudeConfigDirContext): string { + const environment = context?.environment ?? process.env; + const configuredDir = environment.CLAUDE_CONFIG_DIR; + if (configuredDir === undefined) { + const homeDir = context?.environment + ? resolveSdkHomeDir(environment, context.hostPlatform ?? process.platform) + : os.homedir(); + return resolveFromSdkWorkingDirectory( + path.join(homeDir, '.claude'), + context?.vaultPath, + ); + } + + return resolveFromSdkWorkingDirectory(configuredDir, context?.vaultPath); +} diff --git a/src/providers/claude/env/ClaudeSettingsReconciler.ts b/src/providers/claude/env/ClaudeSettingsReconciler.ts new file mode 100644 index 0000000..f48a458 --- /dev/null +++ b/src/providers/claude/env/ClaudeSettingsReconciler.ts @@ -0,0 +1,90 @@ +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import type { ProviderSettingsReconciler } from '../../../core/providers/types'; +import type { Conversation } from '../../../core/types'; +import { parseEnvironmentVariables } from '../../../utils/env'; +import { resolveClaudeModelSelection } from '../modelOptions'; +import { getClaudeProviderSettings, updateClaudeProviderSettings } from '../settings'; +import { clearClaudeResumeState } from '../types/providerState'; +import { claudeChatUIConfig } from '../ui/ClaudeChatUIConfig'; + +const ENV_HASH_MODEL_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +]; +const ENV_HASH_PROVIDER_KEYS = ['ANTHROPIC_BASE_URL']; + +function computeEnvHash(envText: string): string { + const envVars = parseEnvironmentVariables(envText || ''); + const allKeys = [...ENV_HASH_MODEL_KEYS, ...ENV_HASH_PROVIDER_KEYS]; + return allKeys + .filter(key => envVars[key]) + .map(key => `${key}=${envVars[key]}`) + .sort() + .join('|'); +} + +export const claudeSettingsReconciler: ProviderSettingsReconciler = { + reconcileModelWithEnvironment( + settings: Record, + conversations: Conversation[], + ): { changed: boolean; invalidatedConversations: Conversation[] } { + const envText = getRuntimeEnvironmentText(settings, 'claude'); + const currentHash = computeEnvHash(envText); + const savedHash = getClaudeProviderSettings(settings).environmentHash; + + if (currentHash === savedHash) { + return { changed: false, invalidatedConversations: [] }; + } + + const invalidatedConversations: Conversation[] = []; + for (const conv of conversations) { + if (conv.providerId === 'claude' && clearClaudeResumeState(conv)) { + invalidatedConversations.push(conv); + } + } + + const currentModel = typeof settings.model === 'string' ? settings.model : ''; + const nextModel = resolveClaudeModelSelection(settings, currentModel); + if (nextModel) { + settings.model = nextModel; + } + + updateClaudeProviderSettings(settings, { environmentHash: currentHash }); + return { changed: true, invalidatedConversations }; + }, + + normalizeModelVariantSettings(settings: Record): boolean { + let changed = false; + + const normalize = (model: string): string => claudeChatUIConfig.normalizeModelVariant(model, settings); + + const model = settings.model as string; + const normalizedModel = normalize(model); + if (model !== normalizedModel) { + settings.model = normalizedModel; + changed = true; + } + + const titleModel = settings.titleGenerationModel as string; + if (titleModel) { + const normalizedTitleModel = normalize(titleModel); + if (titleModel !== normalizedTitleModel) { + settings.titleGenerationModel = normalizedTitleModel; + changed = true; + } + } + + const lastClaudeModel = getClaudeProviderSettings(settings).lastModel; + if (lastClaudeModel) { + const normalizedLastClaudeModel = normalize(lastClaudeModel); + if (lastClaudeModel !== normalizedLastClaudeModel) { + updateClaudeProviderSettings(settings, { lastModel: normalizedLastClaudeModel }); + changed = true; + } + } + + return changed; + }, +}; diff --git a/src/providers/claude/env/claudeModelEnv.ts b/src/providers/claude/env/claudeModelEnv.ts new file mode 100644 index 0000000..169e40a --- /dev/null +++ b/src/providers/claude/env/claudeModelEnv.ts @@ -0,0 +1,86 @@ +import { formatCustomModelLabel } from '../modelLabels'; + +const CUSTOM_MODEL_ENV_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +] as const; + +function getModelTypeFromEnvKey(envKey: string): string { + if (envKey === 'ANTHROPIC_MODEL') return 'model'; + const match = envKey.match(/ANTHROPIC_DEFAULT_(\w+)_MODEL/); + return match ? match[1].toLowerCase() : envKey; +} + +export function getModelsFromEnvironment( + envVars: Record, + modelAliases: Record = {}, +): { value: string; label: string; description: string }[] { + const modelMap = new Map(); + + for (const envKey of CUSTOM_MODEL_ENV_KEYS) { + const type = getModelTypeFromEnvKey(envKey); + const modelValue = envVars[envKey]; + if (modelValue) { + const label = modelAliases[modelValue] ?? formatCustomModelLabel(modelValue); + + if (!modelMap.has(modelValue)) { + modelMap.set(modelValue, { types: [type], label }); + } else { + modelMap.get(modelValue)!.types.push(type); + } + } + } + + const models: { value: string; label: string; description: string }[] = []; + const typePriority = { 'model': 4, 'haiku': 3, 'sonnet': 2, 'opus': 1 }; + + const sortedEntries = Array.from(modelMap.entries()).sort(([, aInfo], [, bInfo]) => { + const aPriority = Math.max(...aInfo.types.map(t => typePriority[t as keyof typeof typePriority] || 0)); + const bPriority = Math.max(...bInfo.types.map(t => typePriority[t as keyof typeof typePriority] || 0)); + return bPriority - aPriority; + }); + + for (const [modelValue, info] of sortedEntries) { + const sortedTypes = info.types.sort((a, b) => + (typePriority[b as keyof typeof typePriority] || 0) - + (typePriority[a as keyof typeof typePriority] || 0) + ); + + models.push({ + value: modelValue, + label: info.label, + description: `Custom model (${sortedTypes.join(', ')})` + }); + } + + return models; +} + +export function getCurrentModelFromEnvironment(envVars: Record): string | null { + if (envVars.ANTHROPIC_MODEL) { + return envVars.ANTHROPIC_MODEL; + } + if (envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL) { + return envVars.ANTHROPIC_DEFAULT_HAIKU_MODEL; + } + if (envVars.ANTHROPIC_DEFAULT_SONNET_MODEL) { + return envVars.ANTHROPIC_DEFAULT_SONNET_MODEL; + } + if (envVars.ANTHROPIC_DEFAULT_OPUS_MODEL) { + return envVars.ANTHROPIC_DEFAULT_OPUS_MODEL; + } + return null; +} + +export function getCustomModelIds(envVars: Record): Set { + const modelIds = new Set(); + for (const envKey of CUSTOM_MODEL_ENV_KEYS) { + const modelId = envVars[envKey]; + if (modelId) { + modelIds.add(modelId); + } + } + return modelIds; +} diff --git a/src/providers/claude/history/ClaudeConversationHistoryService.ts b/src/providers/claude/history/ClaudeConversationHistoryService.ts new file mode 100644 index 0000000..5e12bb7 --- /dev/null +++ b/src/providers/claude/history/ClaudeConversationHistoryService.ts @@ -0,0 +1,737 @@ +import type { + ProviderConversationHistoryService, + ProviderConversationSessionAvailability, + ProviderHistoryPathContext, +} from '../../../core/providers/types'; +import { isSubagentToolName, TOOL_TASK } from '../../../core/tools/toolNames'; +import type { + AsyncSubagentStatus, + ChatMessage, + Conversation, + ForkSource, + ImageAttachment, + SubagentInfo, + ToolCallInfo, +} from '../../../core/types'; +import { type ClaudeProviderState, getClaudeState } from '../types/providerState'; +import { + deleteSDKSession, + encodeVaultPathForSDK, + getSDKProjectsPath, + loadSDKSessionMessages, + loadSubagentToolCalls, + locateSDKSession, + locateSDKSessions, +} from './ClaudeHistoryStore'; +import type { SDKSessionLocation } from './sdkSessionPaths'; + +function chooseRicherResult(sdkResult?: string, cachedResult?: string): string | undefined { + const sdkText = typeof sdkResult === 'string' ? sdkResult.trim() : ''; + const cachedText = typeof cachedResult === 'string' ? cachedResult.trim() : ''; + + if (sdkText.length === 0 && cachedText.length === 0) return undefined; + if (sdkText.length === 0) return cachedResult; + if (cachedText.length === 0) return sdkResult; + + return sdkText.length >= cachedText.length ? sdkResult : cachedResult; +} + +function chooseRicherToolCalls( + sdkToolCalls: ToolCallInfo[] = [], + cachedToolCalls: ToolCallInfo[] = [], +): ToolCallInfo[] { + if (sdkToolCalls.length >= cachedToolCalls.length) { + return sdkToolCalls; + } + + return cachedToolCalls; +} + +function normalizeAsyncStatus( + subagent: SubagentInfo | undefined, + modeOverride?: SubagentInfo['mode'], +): AsyncSubagentStatus | undefined { + if (!subagent) return undefined; + + const mode = modeOverride ?? subagent.mode; + if (mode === 'sync') return undefined; + if (mode === 'async') return subagent.asyncStatus ?? subagent.status; + return subagent.asyncStatus; +} + +function isTerminalAsyncStatus(status: AsyncSubagentStatus | undefined): boolean { + return status === 'completed' || status === 'error' || status === 'orphaned'; +} + +function mergeSubagentInfo( + taskToolCall: ToolCallInfo, + cachedSubagent: SubagentInfo, +): SubagentInfo { + const sdkSubagent = taskToolCall.subagent; + const cachedAsyncStatus = normalizeAsyncStatus(cachedSubagent); + if (!sdkSubagent) { + return { + ...cachedSubagent, + asyncStatus: cachedAsyncStatus, + result: chooseRicherResult(taskToolCall.result, cachedSubagent.result), + }; + } + + const sdkAsyncStatus = normalizeAsyncStatus(sdkSubagent); + const sdkIsTerminal = isTerminalAsyncStatus(sdkAsyncStatus); + const cachedIsTerminal = isTerminalAsyncStatus(cachedAsyncStatus); + const sdkResult = taskToolCall.result ?? sdkSubagent.result; + + const preferred = (!sdkIsTerminal && cachedIsTerminal) ? cachedSubagent : sdkSubagent; + + const mergedMode = sdkSubagent.mode + ?? cachedSubagent.mode + ?? (taskToolCall.input?.run_in_background === true ? 'async' : undefined); + const fallbackResult = chooseRicherResult(sdkResult, cachedSubagent.result); + const mergedResult = preferred === cachedSubagent + ? (cachedSubagent.result ?? fallbackResult) + : fallbackResult; + const mergedAsyncStatus = normalizeAsyncStatus(preferred, mergedMode); + + return { + ...cachedSubagent, + ...sdkSubagent, + description: sdkSubagent.description || cachedSubagent.description, + prompt: sdkSubagent.prompt || cachedSubagent.prompt, + mode: mergedMode, + status: preferred.status, + asyncStatus: mergedAsyncStatus, + result: mergedResult, + toolCalls: chooseRicherToolCalls(sdkSubagent.toolCalls, cachedSubagent.toolCalls), + agentId: sdkSubagent.agentId || cachedSubagent.agentId, + outputToolId: sdkSubagent.outputToolId || cachedSubagent.outputToolId, + startedAt: sdkSubagent.startedAt ?? cachedSubagent.startedAt, + completedAt: sdkSubagent.completedAt ?? cachedSubagent.completedAt, + isExpanded: sdkSubagent.isExpanded ?? cachedSubagent.isExpanded, + }; +} + +function ensureTaskToolCall( + msg: ChatMessage, + subagentId: string, + subagent: SubagentInfo, +): ToolCallInfo { + msg.toolCalls = msg.toolCalls || []; + let taskToolCall = msg.toolCalls.find( + tc => tc.id === subagentId && isSubagentToolName(tc.name), + ); + + if (!taskToolCall) { + taskToolCall = { + id: subagentId, + name: TOOL_TASK, + input: { + description: subagent.description, + prompt: subagent.prompt || '', + ...(subagent.mode === 'async' ? { run_in_background: true } : {}), + }, + status: subagent.status, + result: subagent.result, + isExpanded: false, + subagent, + }; + msg.toolCalls.push(taskToolCall); + return taskToolCall; + } + + if (!taskToolCall.input.description) { + taskToolCall.input.description = subagent.description; + } + if (!taskToolCall.input.prompt) { + taskToolCall.input.prompt = subagent.prompt || ''; + } + if (subagent.mode === 'async') { + taskToolCall.input.run_in_background = true; + } + const mergedSubagent = mergeSubagentInfo(taskToolCall, subagent); + taskToolCall.status = mergedSubagent.status; + if (mergedSubagent.mode === 'async') { + taskToolCall.input.run_in_background = true; + } + if (mergedSubagent.result !== undefined) { + taskToolCall.result = mergedSubagent.result; + } + taskToolCall.subagent = mergedSubagent; + return taskToolCall; +} + +function hasImageData(image: ImageAttachment | undefined): boolean { + return typeof image?.data === 'string' && image.data.length > 0; +} + +function mergeImageAttachments( + current: ImageAttachment[] | undefined, + incoming: ImageAttachment[] | undefined, +): ImageAttachment[] | undefined { + if (!incoming?.length) { + return current; + } + if (!current?.length) { + return incoming; + } + + const merged = [...current]; + for (const [index, incomingImage] of incoming.entries()) { + const currentImage = merged[index]; + if (!currentImage) { + merged.push(incomingImage); + continue; + } + + if (!hasImageData(currentImage) && hasImageData(incomingImage)) { + merged[index] = { + ...currentImage, + data: incomingImage.data, + mediaType: incomingImage.mediaType, + name: currentImage.name || incomingImage.name, + size: incomingImage.size, + source: currentImage.source ?? incomingImage.source, + }; + } + } + + return merged; +} + +function mergeDuplicateMessage(target: ChatMessage, incoming: ChatMessage): void { + target.images = mergeImageAttachments(target.images, incoming.images); +} + +function dedupeMessages(messages: ChatMessage[]): ChatMessage[] { + const byId = new Map(); + const result: ChatMessage[] = []; + + for (const message of messages) { + const existing = byId.get(message.id); + if (existing) { + mergeDuplicateMessage(existing, message); + continue; + } + + byId.set(message.id, message); + result.push(message); + } + + return result; +} + +async function enrichAsyncSubagentToolCalls( + subagentData: Record, + vaultPath: string, + sessionIds: string[], + relocatedSessionPaths: Map, + pathContext?: ProviderHistoryPathContext, +): Promise { + const uniqueSessionIds = [...new Set(sessionIds)]; + if (uniqueSessionIds.length === 0) return; + + const loaderCache = new Map>(); + + for (const subagent of Object.values(subagentData)) { + if (subagent.mode !== 'async') continue; + if (!subagent.agentId) continue; + if ((subagent.toolCalls?.length ?? 0) > 0) continue; + + for (const sessionId of uniqueSessionIds) { + const cacheKey = `${sessionId}:${subagent.agentId}`; + + let loader = loaderCache.get(cacheKey); + if (!loader) { + const relocatedSessionPath = relocatedSessionPaths.get(sessionId); + if (pathContext) { + loader = loadSubagentToolCalls( + vaultPath, + sessionId, + subagent.agentId, + relocatedSessionPath, + pathContext, + ); + } else { + loader = loadSubagentToolCalls( + vaultPath, + sessionId, + subagent.agentId, + relocatedSessionPath, + ); + } + loaderCache.set(cacheKey, loader); + } + + const recoveredToolCalls = await loader; + if (recoveredToolCalls.length === 0) continue; + + subagent.toolCalls = recoveredToolCalls.map(toolCall => ({ + ...toolCall, + input: { ...toolCall.input }, + })); + break; + } + } +} + +function applySubagentData( + messages: ChatMessage[], + subagentData: Record, +): void { + const attachedSubagentIds = new Set(); + + for (const msg of messages) { + if (msg.role !== 'assistant') continue; + + for (const [subagentId, subagent] of Object.entries(subagentData)) { + const hasSubagentBlock = msg.contentBlocks?.some( + block => (block.type === 'subagent' && block.subagentId === subagentId) + || (block.type === 'tool_use' && block.toolId === subagentId), + ); + const hasTaskToolCall = msg.toolCalls?.some(tc => tc.id === subagentId) ?? false; + + if (!hasSubagentBlock && !hasTaskToolCall) continue; + ensureTaskToolCall(msg, subagentId, subagent); + + if (!msg.contentBlocks) { + msg.contentBlocks = []; + } + + let hasNormalizedSubagentBlock = false; + for (let i = 0; i < msg.contentBlocks.length; i++) { + const block = msg.contentBlocks[i]; + if (block.type === 'tool_use' && block.toolId === subagentId) { + msg.contentBlocks[i] = { + type: 'subagent', + subagentId, + mode: subagent.mode, + }; + hasNormalizedSubagentBlock = true; + } else if (block.type === 'subagent' && block.subagentId === subagentId && !block.mode) { + block.mode = subagent.mode; + hasNormalizedSubagentBlock = true; + } else if (block.type === 'subagent' && block.subagentId === subagentId) { + hasNormalizedSubagentBlock = true; + } + } + + if (!hasNormalizedSubagentBlock && hasTaskToolCall) { + msg.contentBlocks.push({ + type: 'subagent', + subagentId, + mode: subagent.mode, + }); + } + + attachedSubagentIds.add(subagentId); + } + } + + for (const [subagentId, subagent] of Object.entries(subagentData)) { + if (attachedSubagentIds.has(subagentId)) continue; + + let anchor = [...messages].reverse().find((msg): msg is ChatMessage => msg.role === 'assistant'); + if (!anchor) { + anchor = { + id: `subagent-recovery-${subagentId}`, + role: 'assistant', + content: '', + timestamp: subagent.completedAt ?? subagent.startedAt ?? Date.now(), + contentBlocks: [], + }; + messages.push(anchor); + } + + ensureTaskToolCall(anchor, subagentId, subagent); + + anchor.contentBlocks = anchor.contentBlocks || []; + const hasSubagentBlock = anchor.contentBlocks.some( + block => block.type === 'subagent' && block.subagentId === subagentId, + ); + if (!hasSubagentBlock) { + anchor.contentBlocks.push({ + type: 'subagent', + subagentId, + mode: subagent.mode, + }); + } + } +} + +function buildPersistedSubagentData(messages: ChatMessage[]): Record { + const result: Record = {}; + + for (const msg of messages) { + if (msg.role !== 'assistant' || !msg.toolCalls) continue; + + for (const toolCall of msg.toolCalls) { + if (!isSubagentToolName(toolCall.name) || !toolCall.subagent) continue; + result[toolCall.subagent.id] = toolCall.subagent; + } + } + + return result; +} + +function sanitizeProviderState( + providerState: ClaudeProviderState, +): Record | undefined { + const sanitizedEntries = Object.entries(providerState).filter(([, value]) => value !== undefined); + if (sanitizedEntries.length === 0) { + return undefined; + } + + return Object.fromEntries(sanitizedEntries); +} + +export class ClaudeConversationHistoryService implements ProviderConversationHistoryService { + private hydratedConversationIds = new Set(); + private historyCacheKeysByConversation = new Map(); + private pendingSessionLocationsByConversation = new Map< + string, + Map + >(); + private relocatedSessionPathsByConversation = new Map>(); + + private getConversationSessionIds(conversation: Conversation): string[] { + const state = getClaudeState(conversation.providerState); + if (this.isPendingForkConversation(conversation)) { + return [state.forkSource!.sessionId]; + } + + return [...new Set([ + ...(state.previousProviderSessionIds || []), + state.providerSessionId ?? conversation.sessionId, + ].filter((id): id is string => !!id))]; + } + + private synchronizeHistoryCache( + conversation: Conversation, + vaultPath: string, + pathContext?: ProviderHistoryPathContext, + ): void { + const state = getClaudeState(conversation.providerState); + const cacheKey = JSON.stringify([ + getSDKProjectsPath(pathContext), + encodeVaultPathForSDK(vaultPath), + this.getConversationSessionIds(conversation), + conversation.resumeAtMessageId ?? null, + state.forkSource?.resumeAt ?? null, + ]); + const previousKey = this.historyCacheKeysByConversation.get(conversation.id); + if (previousKey !== undefined && previousKey !== cacheKey) { + this.hydratedConversationIds.delete(conversation.id); + this.pendingSessionLocationsByConversation.delete(conversation.id); + this.relocatedSessionPathsByConversation.delete(conversation.id); + } + this.historyCacheKeysByConversation.set(conversation.id, cacheKey); + } + + async getConversationSessionAvailability( + conversation: Conversation, + vaultPath: string | null, + pathContext?: ProviderHistoryPathContext, + ): Promise { + const sessionId = this.resolveSessionIdForConversation(conversation); + if (!vaultPath) { + return 'unknown'; + } + this.synchronizeHistoryCache(conversation, vaultPath, pathContext); + if (!sessionId) return 'unknown'; + + const location = await (pathContext + ? locateSDKSession(vaultPath, sessionId, pathContext) + : locateSDKSession(vaultPath, sessionId)); + this.pendingSessionLocationsByConversation.set( + conversation.id, + new Map([[sessionId, location]]), + ); + if (location.availability === 'relocated' && location.sessionPath) { + const relocatedSessionPaths = new Map( + this.relocatedSessionPathsByConversation.get(conversation.id) ?? [], + ); + relocatedSessionPaths.set(sessionId, location.sessionPath); + this.relocatedSessionPathsByConversation.set( + conversation.id, + relocatedSessionPaths, + ); + } else if (location.availability !== 'unknown') { + const relocatedSessionPaths = new Map( + this.relocatedSessionPathsByConversation.get(conversation.id) ?? [], + ); + relocatedSessionPaths.delete(sessionId); + if (relocatedSessionPaths.size > 0) { + this.relocatedSessionPathsByConversation.set( + conversation.id, + relocatedSessionPaths, + ); + } else { + this.relocatedSessionPathsByConversation.delete(conversation.id); + } + } + return location.availability; + } + + async prepareRelocatedConversationSession( + conversation: Conversation, + vaultPath: string | null, + pathContext?: ProviderHistoryPathContext, + ): Promise { + const sessionId = this.resolveSessionIdForConversation(conversation); + if (!vaultPath || !sessionId) { + return false; + } + + await this.hydrateConversationHistory(conversation, vaultPath, pathContext); + if (!this.hydratedConversationIds.has(conversation.id)) { + return false; + } + + const state = { ...getClaudeState(conversation.providerState) }; + state.previousProviderSessionIds = [ + ...new Set([...(state.previousProviderSessionIds || []), sessionId]), + ]; + delete state.providerSessionId; + + if (state.forkSource?.sessionId === sessionId) { + conversation.resumeAtMessageId = state.forkSource.resumeAt; + delete state.forkSource; + } + + conversation.sessionId = null; + conversation.providerState = sanitizeProviderState(state); + return true; + } + + async resolveMissingConversationSession( + conversation: Conversation, + vaultPath: string | null, + missingProviderSessionId?: string, + pathContext?: ProviderHistoryPathContext, + ): Promise<'delete' | 'reset' | 'preserve'> { + const currentSessionId = this.resolveSessionIdForConversation(conversation); + if ( + !vaultPath + || !currentSessionId + || (missingProviderSessionId + && missingProviderSessionId.toLowerCase() !== currentSessionId.toLowerCase()) + ) { + return 'preserve'; + } + + this.synchronizeHistoryCache(conversation, vaultPath, pathContext); + + const sessionIds = this.getConversationSessionIds(conversation); + const locations = await (pathContext + ? locateSDKSessions(vaultPath, sessionIds, pathContext) + : locateSDKSessions(vaultPath, sessionIds)); + const preservedSessionIds = sessionIds.filter( + sessionId => locations.get(sessionId)?.availability !== 'missing', + ); + if (preservedSessionIds.length === 0) { + this.pendingSessionLocationsByConversation.delete(conversation.id); + this.relocatedSessionPathsByConversation.delete(conversation.id); + this.hydratedConversationIds.delete(conversation.id); + return 'delete'; + } + + const state = { ...getClaudeState(conversation.providerState) }; + state.previousProviderSessionIds = preservedSessionIds; + delete state.providerSessionId; + if (state.forkSource?.sessionId === currentSessionId) { + conversation.resumeAtMessageId = state.forkSource.resumeAt; + delete state.forkSource; + } + + conversation.sessionId = null; + conversation.providerState = sanitizeProviderState(state); + this.pendingSessionLocationsByConversation.delete(conversation.id); + this.hydratedConversationIds.delete(conversation.id); + return 'reset'; + } + + isPendingForkConversation(conversation: Conversation): boolean { + const state = getClaudeState(conversation.providerState); + return !!state.forkSource + && !state.providerSessionId + && !conversation.sessionId; + } + + resolveSessionIdForConversation(conversation: Conversation | null): string | null { + if (!conversation) return null; + const state = getClaudeState(conversation.providerState); + return state.providerSessionId ?? conversation.sessionId ?? state.forkSource?.sessionId ?? null; + } + + buildForkProviderState( + sourceSessionId: string, + resumeAt: string, + _sourceProviderState?: Record, + ): Record { + const state: ClaudeProviderState = { + forkSource: { sessionId: sourceSessionId, resumeAt } satisfies ForkSource, + }; + return state as Record; + } + + buildPersistedProviderState( + conversation: Conversation, + ): Record | undefined { + const providerState: ClaudeProviderState = { + ...getClaudeState(conversation.providerState), + }; + + const subagentData = buildPersistedSubagentData(conversation.messages); + if (Object.keys(subagentData).length > 0) { + providerState.subagentData = subagentData; + } else { + delete providerState.subagentData; + } + + return sanitizeProviderState(providerState); + } + + async hydrateConversationHistory( + conversation: Conversation, + vaultPath: string | null, + pathContext?: ProviderHistoryPathContext, + ): Promise { + if (!vaultPath) { + return; + } + this.synchronizeHistoryCache(conversation, vaultPath, pathContext); + if (this.hydratedConversationIds.has(conversation.id)) return; + + const state = getClaudeState(conversation.providerState); + const isPendingFork = this.isPendingForkConversation(conversation); + const allSessionIds = this.getConversationSessionIds(conversation); + + if (allSessionIds.length === 0) { + return; + } + + const allSdkMessages: ChatMessage[] = []; + let missingSessionCount = 0; + let unknownSessionCount = 0; + let errorCount = 0; + let successCount = 0; + const relocatedSessionPaths = new Map( + this.relocatedSessionPathsByConversation.get(conversation.id) ?? [], + ); + const cachedLocations = new Map( + this.pendingSessionLocationsByConversation.get(conversation.id) ?? [], + ); + this.pendingSessionLocationsByConversation.delete(conversation.id); + const unresolvedSessionIds = allSessionIds.filter( + id => !relocatedSessionPaths.has(id) && !cachedLocations.has(id), + ); + const locatedSessions = await (pathContext + ? locateSDKSessions(vaultPath, unresolvedSessionIds, pathContext) + : locateSDKSessions(vaultPath, unresolvedSessionIds)); + const resolvedLocations = new Map([...cachedLocations, ...locatedSessions]); + for (const [sessionId, location] of locatedSessions) { + if (location.availability === 'relocated' && location.sessionPath) { + relocatedSessionPaths.set(sessionId, location.sessionPath); + } + } + if (relocatedSessionPaths.size > 0) { + this.relocatedSessionPathsByConversation.set(conversation.id, relocatedSessionPaths); + } + + const resumableSessionId = isPendingFork + ? state.forkSource!.sessionId + : (state.providerSessionId ?? conversation.sessionId); + const checkpointSessionId = resumableSessionId + ?? (conversation.resumeAtMessageId ? allSessionIds[allSessionIds.length - 1] : null); + + for (const sessionId of allSessionIds) { + const relocatedSessionPath = relocatedSessionPaths.get(sessionId); + const location = relocatedSessionPath + ? { availability: 'relocated' as const, sessionPath: relocatedSessionPath } + : resolvedLocations.get(sessionId) ?? { availability: 'unknown' as const }; + if (!location.sessionPath) { + if (location.availability === 'missing') { + missingSessionCount++; + } else { + unknownSessionCount++; + } + continue; + } + + const isCheckpointSession = sessionId === checkpointSessionId; + const truncateAt = isCheckpointSession + ? (isPendingFork ? state.forkSource!.resumeAt : conversation.resumeAtMessageId) + : undefined; + const sessionPathOverride = relocatedSessionPaths.get(sessionId); + const result = pathContext + ? await loadSDKSessionMessages( + vaultPath, + sessionId, + truncateAt, + sessionPathOverride, + pathContext, + ) + : sessionPathOverride + ? await loadSDKSessionMessages(vaultPath, sessionId, truncateAt, sessionPathOverride) + : await loadSDKSessionMessages(vaultPath, sessionId, truncateAt); + + if (result.error) { + errorCount++; + continue; + } + + successCount++; + allSdkMessages.push(...result.messages); + } + + const allSessionsMissing = missingSessionCount === allSessionIds.length; + if (successCount === 0 || allSessionsMissing) { + return; + } + + const filteredSdkMessages = allSdkMessages.filter(msg => !msg.isRebuiltContext); + + const merged = dedupeMessages([ + ...conversation.messages, + ...filteredSdkMessages, + ]).sort((a, b) => a.timestamp - b.timestamp); + + if (state.subagentData) { + await enrichAsyncSubagentToolCalls( + state.subagentData, + vaultPath, + allSessionIds, + relocatedSessionPaths, + pathContext, + ); + applySubagentData(merged, state.subagentData); + } + + conversation.messages = merged; + if (errorCount === 0 && unknownSessionCount === 0) { + this.hydratedConversationIds.add(conversation.id); + } + } + + async deleteConversationSession( + conversation: Conversation, + vaultPath: string | null, + pathContext?: ProviderHistoryPathContext, + ): Promise { + this.pendingSessionLocationsByConversation.delete(conversation.id); + this.relocatedSessionPathsByConversation.delete(conversation.id); + this.hydratedConversationIds.delete(conversation.id); + this.historyCacheKeysByConversation.delete(conversation.id); + const state = getClaudeState(conversation.providerState); + const sessionId = state.providerSessionId ?? conversation.sessionId; + if (!vaultPath || !sessionId) { + return; + } + + if (pathContext) { + await deleteSDKSession(vaultPath, sessionId, pathContext); + } else { + await deleteSDKSession(vaultPath, sessionId); + } + } +} diff --git a/src/providers/claude/history/ClaudeHistoryStore.ts b/src/providers/claude/history/ClaudeHistoryStore.ts new file mode 100644 index 0000000..36d31fe --- /dev/null +++ b/src/providers/claude/history/ClaudeHistoryStore.ts @@ -0,0 +1,191 @@ +import type { ProviderHistoryPathContext } from '../../../core/providers/types'; +import { isSubagentToolName } from '../../../core/tools/toolNames'; +import type { ChatMessage, SubagentInfo, ToolCallInfo } from '../../../core/types'; +import { buildAsyncSubagentInfo } from './sdkAsyncSubagent'; +import { filterActiveBranch } from './sdkBranchFilter'; +import type { SDKSessionLoadResult } from './sdkHistoryTypes'; +import { + collectAsyncSubagentResults, + collectStructuredPatchResults, + collectToolResults, + extractXmlTag, + hydrateFallbackAskUserAnswers, + hydrateStructuredToolResults, + isSystemInjectedMessage, + mergeAssistantMessage, + parseSDKMessageToChat, +} from './sdkMessageParsing'; +import { + deleteSDKSession, + encodeVaultPathForSDK, + getSDKProjectsPath, + getSDKSessionAvailability, + getSDKSessionPath, + isValidSessionId, + locateSDKSession, + locateSDKSessions, + readSDKSession, + readSDKSessionFile, + sdkSessionExists, +} from './sdkSessionPaths'; +import { + isValidAgentId, + loadSubagentFinalResult, + loadSubagentToolCalls, +} from './sdkSubagentSidecar'; + +export type { + AsyncSubagentResult, + ResolvedAsyncStatus, + SDKNativeContentBlock, + SDKNativeMessage, + SDKSessionLoadResult, + SDKSessionReadResult, +} from './sdkHistoryTypes'; +export { + collectAsyncSubagentResults, + deleteSDKSession, + encodeVaultPathForSDK, + extractXmlTag, + filterActiveBranch, + getSDKProjectsPath, + getSDKSessionAvailability, + getSDKSessionPath, + isValidSessionId, + loadSubagentFinalResult, + loadSubagentToolCalls, + locateSDKSession, + locateSDKSessions, + parseSDKMessageToChat, + readSDKSession, + readSDKSessionFile, + sdkSessionExists, +}; +export { + extractAgentIdFromToolUseResult, + resolveToolUseResultStatus, +} from './sdkAsyncSubagent'; + +export async function loadSDKSessionMessages( + vaultPath: string, + sessionId: string, + resumeAtMessageId?: string, + sessionPath?: string, + pathContext?: ProviderHistoryPathContext, +): Promise { + const result = sessionPath + ? await readSDKSessionFile(sessionPath) + : await (pathContext + ? readSDKSession(vaultPath, sessionId, pathContext) + : readSDKSession(vaultPath, sessionId)); + + if (result.error) { + return { messages: [], skippedLines: result.skippedLines, error: result.error }; + } + + const filteredEntries = filterActiveBranch(result.messages, resumeAtMessageId); + + const toolResults = collectToolResults(filteredEntries); + const toolUseResults = collectStructuredPatchResults(filteredEntries); + const asyncSubagentResults = collectAsyncSubagentResults(filteredEntries); + + const chatMessages: ChatMessage[] = []; + let pendingAssistant: ChatMessage | null = null; + + // Merge consecutive assistant messages until an actual user message appears + for (const sdkMsg of filteredEntries) { + if (isSystemInjectedMessage(sdkMsg)) continue; + + // Skip synthetic assistant messages (e.g., "No response requested." after /compact) + if (sdkMsg.type === 'assistant' && sdkMsg.message?.model === '') continue; + + const chatMsg = parseSDKMessageToChat(sdkMsg, toolResults); + if (!chatMsg) continue; + + if (chatMsg.role === 'assistant') { + // context_compacted must not merge with previous assistant (it's a standalone separator) + const isCompactBoundary = chatMsg.contentBlocks?.some(b => b.type === 'context_compacted'); + if (isCompactBoundary) { + if (pendingAssistant) { + chatMessages.push(pendingAssistant); + } + chatMessages.push(chatMsg); + pendingAssistant = null; + } else if (pendingAssistant) { + mergeAssistantMessage(pendingAssistant, chatMsg); + } else { + pendingAssistant = chatMsg; + } + } else { + if (pendingAssistant) { + chatMessages.push(pendingAssistant); + pendingAssistant = null; + } + chatMessages.push(chatMsg); + } + } + + if (pendingAssistant) { + chatMessages.push(pendingAssistant); + } + + hydrateStructuredToolResults(chatMessages, toolUseResults); + hydrateFallbackAskUserAnswers(chatMessages); + + // Build SubagentInfo for async Agent tool calls from toolUseResult + queue-operation data + if (toolUseResults.size > 0 || asyncSubagentResults.size > 0) { + const sidecarLoads: Array<{ subagent: SubagentInfo; promise: Promise }> = []; + + for (const msg of chatMessages) { + if (msg.role !== 'assistant' || !msg.toolCalls) continue; + for (const toolCall of msg.toolCalls) { + if (!isSubagentToolName(toolCall.name)) continue; + if (toolCall.subagent) continue; + if (toolCall.input?.run_in_background !== true) continue; + + const toolUseResult = toolUseResults.get(toolCall.id); + const subagent = buildAsyncSubagentInfo( + toolCall, + toolUseResult, + asyncSubagentResults + ); + if (subagent) { + toolCall.subagent = subagent; + if (subagent.result !== undefined) { + toolCall.result = subagent.result; + } + toolCall.status = subagent.status; + + // Load tool calls from subagent sidecar JSONL in parallel + if (subagent.agentId && isValidAgentId(subagent.agentId)) { + const promise = pathContext + ? loadSubagentToolCalls( + vaultPath, + sessionId, + subagent.agentId, + sessionPath, + pathContext, + ) + : loadSubagentToolCalls(vaultPath, sessionId, subagent.agentId, sessionPath); + sidecarLoads.push({ subagent, promise }); + } + } + } + } + + // Hydrate subagent tool calls from sidecar files + if (sidecarLoads.length > 0) { + const results = await Promise.all(sidecarLoads.map(s => s.promise)); + for (let i = 0; i < sidecarLoads.length; i++) { + const toolCalls = results[i]; + if (toolCalls.length > 0) { + sidecarLoads[i].subagent.toolCalls = toolCalls; + } + } + } + } + + chatMessages.sort((a, b) => a.timestamp - b.timestamp); + + return { messages: chatMessages, skippedLines: result.skippedLines }; +} diff --git a/src/providers/claude/history/sdkAsyncSubagent.ts b/src/providers/claude/history/sdkAsyncSubagent.ts new file mode 100644 index 0000000..994e714 --- /dev/null +++ b/src/providers/claude/history/sdkAsyncSubagent.ts @@ -0,0 +1,92 @@ +import type { SubagentInfo, ToolCallInfo } from '../../../core/types'; +import type { AsyncSubagentResult, ResolvedAsyncStatus } from './sdkHistoryTypes'; + +export function extractAgentIdFromToolUseResult(toolUseResult: unknown): string | null { + if (!toolUseResult || typeof toolUseResult !== 'object') { + return null; + } + + const record = toolUseResult as Record; + const directAgentId = record.agentId ?? record.agent_id; + if (typeof directAgentId === 'string' && directAgentId.length > 0) { + return directAgentId; + } + + const data = record.data; + if (data && typeof data === 'object') { + const nested = data as Record; + const nestedAgentId = nested.agent_id ?? nested.agentId; + if (typeof nestedAgentId === 'string' && nestedAgentId.length > 0) { + return nestedAgentId; + } + } + + return null; +} + +export function resolveToolUseResultStatus( + toolUseResult: unknown, + fallbackStatus: ResolvedAsyncStatus, +): ResolvedAsyncStatus { + if (!toolUseResult || typeof toolUseResult !== 'object') { + return fallbackStatus; + } + + const record = toolUseResult as Record; + const rawStatus = record.retrieval_status ?? record.status; + const status = typeof rawStatus === 'string' ? rawStatus.toLowerCase() : ''; + + if (status === 'error' || status === 'failed' || status === 'stopped' || status === 'killed') { + return 'error'; + } + if (status === 'completed' || status === 'success') { + return 'completed'; + } + if (record.isAsync === true || status === 'async_launched') { + return 'running'; + } + + return fallbackStatus; +} + +export function buildAsyncSubagentInfo( + toolCall: ToolCallInfo, + toolUseResult: unknown, + asyncResults: Map, +): SubagentInfo | null { + const agentId = extractAgentIdFromToolUseResult(toolUseResult); + if (!agentId) { + return null; + } + + const queueResult = asyncResults.get(agentId); + const description = (toolCall.input?.description as string) || 'Background task'; + const prompt = (toolCall.input?.prompt as string) || ''; + const finalResult = queueResult?.result ?? toolCall.result; + + let toolCallFallback: ResolvedAsyncStatus = 'running'; + if (toolCall.status === 'error') { + toolCallFallback = 'error'; + } else if (toolCall.status === 'completed') { + toolCallFallback = 'completed'; + } + + const status = queueResult + ? resolveToolUseResultStatus({ status: queueResult.status }, 'completed') + : resolveToolUseResultStatus(toolUseResult, toolCallFallback); + + const taskStatus = status === 'orphaned' ? 'error' : status; + + return { + id: toolCall.id, + description, + prompt, + mode: 'async', + isExpanded: false, + status: taskStatus, + toolCalls: [], + asyncStatus: status, + agentId, + result: finalResult, + }; +} diff --git a/src/providers/claude/history/sdkBranchFilter.ts b/src/providers/claude/history/sdkBranchFilter.ts new file mode 100644 index 0000000..dcb13b7 --- /dev/null +++ b/src/providers/claude/history/sdkBranchFilter.ts @@ -0,0 +1,271 @@ +import type { SDKNativeMessage } from './sdkHistoryTypes'; + +export function filterActiveBranch( + entries: SDKNativeMessage[], + resumeAtMessageId?: string, +): SDKNativeMessage[] { + if (entries.length === 0) { + return []; + } + + function isRealUserBranchChild(entry: SDKNativeMessage | undefined): boolean { + return !!entry + && entry.type === 'user' + && !('toolUseResult' in entry) + && !entry.isMeta + && !('sourceToolUseID' in entry); + } + + function isDirectRealUserBranchChild(parentUuid: string, entry: SDKNativeMessage | undefined): boolean { + return !!entry && entry.parentUuid === parentUuid && isRealUserBranchChild(entry); + } + + const seen = new Set(); + const deduped: SDKNativeMessage[] = []; + for (const entry of entries) { + if (entry.uuid) { + if (seen.has(entry.uuid)) { + continue; + } + seen.add(entry.uuid); + } + deduped.push(entry); + } + + const progressUuids = new Set(); + const progressParentOf = new Map(); + for (const entry of deduped) { + if ((entry.type as string) === 'progress' && entry.uuid) { + progressUuids.add(entry.uuid); + progressParentOf.set(entry.uuid, entry.parentUuid ?? null); + } + } + + function resolveParent(parentUuid: string | null | undefined): string | null | undefined { + if (!parentUuid) { + return parentUuid; + } + + let current: string | null = parentUuid; + let guard = progressUuids.size + 1; + while (current && progressUuids.has(current)) { + if (--guard < 0) { + break; + } + current = progressParentOf.get(current) ?? null; + } + + return current; + } + + const conversationEntries = deduped.filter(entry => (entry.type as string) !== 'progress'); + const byUuid = new Map(); + const childrenOf = new Map>(); + + for (const entry of conversationEntries) { + if (entry.uuid) { + byUuid.set(entry.uuid, entry); + } + + const effectiveParent = resolveParent(entry.parentUuid) ?? null; + if (effectiveParent && entry.uuid) { + let children = childrenOf.get(effectiveParent); + if (!children) { + children = new Set(); + childrenOf.set(effectiveParent, children); + } + children.add(entry.uuid); + } + } + + function findLatestLeaf(): SDKNativeMessage | undefined { + for (let i = conversationEntries.length - 1; i >= 0; i--) { + const uuid = conversationEntries[i].uuid; + if (uuid && !childrenOf.has(uuid)) { + return conversationEntries[i]; + } + } + return undefined; + } + + const latestLeaf = findLatestLeaf(); + const latestBranchUuids = new Set(); + const activeChildOf = new Map(); + + let latestCurrent = latestLeaf; + while (latestCurrent?.uuid) { + latestBranchUuids.add(latestCurrent.uuid); + const parent = resolveParent(latestCurrent.parentUuid); + if (parent) { + activeChildOf.set(parent, latestCurrent.uuid); + } + latestCurrent = parent ? byUuid.get(parent) : undefined; + } + + const conversationContentCache = new Map(); + function hasConversationContent(uuid: string): boolean { + const cached = conversationContentCache.get(uuid); + if (cached !== undefined) { + return cached; + } + + const entry = byUuid.get(uuid); + let result = false; + if (entry?.type === 'assistant') { + result = true; + } else if (entry?.type === 'user' && !entry.isMeta && !('sourceToolUseID' in entry)) { + result = true; + } else { + const children = childrenOf.get(uuid); + if (children) { + for (const childUuid of children) { + if (hasConversationContent(childUuid)) { + result = true; + break; + } + } + } + } + + conversationContentCache.set(uuid, result); + return result; + } + + const hasBranching = [...latestBranchUuids].some(uuid => { + const children = childrenOf.get(uuid); + if (!children || children.size <= 1) { + return false; + } + + const activeChildUuid = activeChildOf.get(uuid); + let sawRealUserChild = false; + let sawAlternateConversationChild = false; + + for (const childUuid of children) { + const child = byUuid.get(childUuid); + if (isDirectRealUserBranchChild(uuid, child)) { + sawRealUserChild = true; + } + if (childUuid !== activeChildUuid && hasConversationContent(childUuid)) { + sawAlternateConversationChild = true; + } + if (sawRealUserChild && sawAlternateConversationChild) { + return true; + } + } + + return false; + }); + + let leaf: SDKNativeMessage | undefined; + if (hasBranching) { + leaf = latestLeaf; + if (resumeAtMessageId && leaf?.uuid && byUuid.has(resumeAtMessageId)) { + let current: SDKNativeMessage | undefined = leaf; + while (current?.uuid) { + if (current.uuid === resumeAtMessageId) { + leaf = current; + break; + } + const parent = resolveParent(current.parentUuid); + current = parent ? byUuid.get(parent) : undefined; + } + } + } else if (resumeAtMessageId) { + leaf = byUuid.get(resumeAtMessageId); + } else { + return conversationEntries; + } + + if (!leaf?.uuid) { + return conversationEntries; + } + + const activeUuids = new Set(); + let current: SDKNativeMessage | undefined = leaf; + while (current?.uuid) { + activeUuids.add(current.uuid); + const parent = resolveParent(current.parentUuid); + current = parent ? byUuid.get(parent) : undefined; + } + + if (hasBranching) { + const ancestorUuids = [...activeUuids]; + const pending: string[] = []; + + for (const uuid of ancestorUuids) { + const children = childrenOf.get(uuid); + if (!children || children.size <= 1) { + continue; + } + + const activeChildUuid = activeChildOf.get(uuid); + if (activeChildUuid && isDirectRealUserBranchChild(uuid, byUuid.get(activeChildUuid))) { + continue; + } + + for (const childUuid of children) { + if (activeUuids.has(childUuid)) { + continue; + } + + const child = byUuid.get(childUuid); + if (!child || isRealUserBranchChild(child)) { + continue; + } + + activeUuids.add(childUuid); + pending.push(childUuid); + } + } + + while (pending.length > 0) { + const parentUuid = pending.pop()!; + const children = childrenOf.get(parentUuid); + if (!children) { + continue; + } + + for (const childUuid of children) { + if (activeUuids.has(childUuid)) { + continue; + } + + const child = byUuid.get(childUuid); + if (!child || isRealUserBranchChild(child)) { + continue; + } + + activeUuids.add(childUuid); + pending.push(childUuid); + } + } + } + + const entryCount = conversationEntries.length; + const prevIsActive = new Array(entryCount); + const nextIsActive = new Array(entryCount); + + let lastPrevActive = false; + for (let i = 0; i < entryCount; i++) { + if (conversationEntries[i].uuid) { + lastPrevActive = activeUuids.has(conversationEntries[i].uuid!); + } + prevIsActive[i] = lastPrevActive; + } + + let lastNextActive = false; + for (let i = entryCount - 1; i >= 0; i--) { + if (conversationEntries[i].uuid) { + lastNextActive = activeUuids.has(conversationEntries[i].uuid!); + } + nextIsActive[i] = lastNextActive; + } + + return conversationEntries.filter((entry, idx) => { + if (entry.uuid) { + return activeUuids.has(entry.uuid); + } + return prevIsActive[idx] && nextIsActive[idx]; + }); +} diff --git a/src/providers/claude/history/sdkHistoryTypes.ts b/src/providers/claude/history/sdkHistoryTypes.ts new file mode 100644 index 0000000..0e54649 --- /dev/null +++ b/src/providers/claude/history/sdkHistoryTypes.ts @@ -0,0 +1,61 @@ +import type { AsyncSubagentStatus, ChatMessage } from '../../../core/types'; + +export interface SDKSessionReadResult { + messages: SDKNativeMessage[]; + skippedLines: number; + error?: string; +} + +/** Stored in session JSONL files. Based on Claude Agent SDK internal format. */ +export interface SDKNativeMessage { + type: 'user' | 'assistant' | 'system' | 'result' | 'file-history-snapshot' | 'queue-operation'; + parentUuid?: string | null; + sessionId?: string; + uuid?: string; + timestamp?: string; + requestId?: string; + message?: { + role?: string; + content?: string | SDKNativeContentBlock[]; + model?: string; + }; + subtype?: string; + duration_ms?: number; + duration_api_ms?: number; + toolUseResult?: unknown; + sourceToolAssistantUUID?: string; + sourceToolUseID?: string; + isMeta?: boolean; + operation?: string; + content?: string; +} + +export interface SDKNativeContentBlock { + type: 'text' | 'tool_use' | 'tool_result' | 'thinking' | 'image'; + text?: string; + thinking?: string; + id?: string; + name?: string; + input?: Record; + tool_use_id?: string; + content?: unknown; + is_error?: boolean; + source?: { + type: 'base64'; + media_type: string; + data: string; + }; +} + +export interface SDKSessionLoadResult { + messages: ChatMessage[]; + skippedLines: number; + error?: string; +} + +export interface AsyncSubagentResult { + result: string; + status: string; +} + +export type ResolvedAsyncStatus = Exclude; diff --git a/src/providers/claude/history/sdkMessageParsing.ts b/src/providers/claude/history/sdkMessageParsing.ts new file mode 100644 index 0000000..69f7fa1 --- /dev/null +++ b/src/providers/claude/history/sdkMessageParsing.ts @@ -0,0 +1,424 @@ +import { extractResolvedAnswers, extractResolvedAnswersFromResultText } from '../../../core/tools/toolInput'; +import { TOOL_ASK_USER_QUESTION } from '../../../core/tools/toolNames'; +import type { + ChatMessage, + ContentBlock, + ImageAttachment, + ToolCallInfo, +} from '../../../core/types'; +import { extractUserDisplayContent } from '../../../utils/context'; +import { extractDiffData } from '../../../utils/diff'; +import { + buildImageAttachmentFromBase64, + parseImageDataUri, +} from '../../../utils/imageAttachment'; +import { isCompactionCanceledStderr, isInterruptSignalText } from '../../../utils/interrupt'; +import { extractToolResultContent } from '../sdk/toolResultContent'; +import type { + AsyncSubagentResult, + SDKNativeContentBlock, + SDKNativeMessage, +} from './sdkHistoryTypes'; + +function extractTextContent(content: string | SDKNativeContentBlock[] | undefined): string { + if (!content) { + return ''; + } + if (typeof content === 'string') { + return content; + } + + return content + .filter((block): block is SDKNativeContentBlock & { type: 'text'; text: string } => + block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '(no content)') + .map(block => block.text) + .join('\n'); +} + +function isRebuiltContextContent(textContent: string): boolean { + if (!/^(User|Assistant):\s/.test(textContent)) { + return false; + } + + return textContent.includes('\n\nUser:') + || textContent.includes('\n\nAssistant:') + || textContent.includes('\n\nA:'); +} + +function extractImages( + content: string | SDKNativeContentBlock[] | undefined, + messageId: string, +): ImageAttachment[] | undefined { + if (!content || typeof content === 'string') { + return undefined; + } + + const imageBlocks = content.filter( + (block): block is SDKNativeContentBlock & { + type: 'image'; + source: { type: 'base64'; media_type: string; data: string }; + } => block.type === 'image' && !!block.source?.data, + ); + + if (imageBlocks.length === 0) { + return undefined; + } + + const images = imageBlocks.flatMap((block, index): ImageAttachment[] => { + const parsedDataUri = parseImageDataUri(block.source.data); + const image = buildImageAttachmentFromBase64({ + data: parsedDataUri?.data ?? block.source.data, + id: `sdk-img-${messageId}-${index}`, + mediaType: parsedDataUri?.mediaType ?? block.source.media_type, + name: `image-${index + 1}`, + }); + return image ? [image] : []; + }); + + return images.length > 0 ? images : undefined; +} + +function extractToolCalls( + content: string | SDKNativeContentBlock[] | undefined, + toolResults?: Map, +): ToolCallInfo[] | undefined { + if (!content || typeof content === 'string') { + return undefined; + } + + const toolUses = content.filter( + (block): block is SDKNativeContentBlock & { type: 'tool_use'; id: string; name: string } => + block.type === 'tool_use' && !!block.id && !!block.name, + ); + + if (toolUses.length === 0) { + return undefined; + } + + const results = toolResults ?? new Map(); + if (!toolResults) { + for (const block of content) { + if (block.type === 'tool_result' && block.tool_use_id) { + results.set(block.tool_use_id, { + content: extractToolResultContent(block.content), + isError: block.is_error ?? false, + }); + } + } + } + + return toolUses.map(block => { + const result = results.get(block.id); + return { + id: block.id, + name: block.name, + input: block.input ?? {}, + status: result ? (result.isError ? 'error' : 'completed') : 'running', + result: result?.content, + isExpanded: false, + }; + }); +} + +function mapContentBlocks(content: string | SDKNativeContentBlock[] | undefined): ContentBlock[] | undefined { + if (!content || typeof content === 'string') { + return undefined; + } + + const blocks: ContentBlock[] = []; + + for (const block of content) { + switch (block.type) { + case 'text': { + const text = block.text; + const trimmed = text?.trim(); + if (text && trimmed && trimmed !== '(no content)') { + blocks.push({ type: 'text', content: text }); + } + break; + } + case 'thinking': + if (block.thinking) { + blocks.push({ type: 'thinking', content: block.thinking }); + } + break; + case 'tool_use': + if (block.id) { + blocks.push({ type: 'tool_use', toolId: block.id }); + } + break; + default: + break; + } + } + + return blocks.length > 0 ? blocks : undefined; +} + +export function parseSDKMessageToChat( + sdkMsg: SDKNativeMessage, + toolResults?: Map, +): ChatMessage | null { + if (sdkMsg.type === 'file-history-snapshot') { + return null; + } + + if (sdkMsg.type === 'system') { + if (sdkMsg.subtype === 'compact_boundary') { + const timestamp = sdkMsg.timestamp ? new Date(sdkMsg.timestamp).getTime() : Date.now(); + return { + id: sdkMsg.uuid || `compact-${timestamp}-${Math.random().toString(36).slice(2)}`, + role: 'assistant', + content: '', + timestamp, + contentBlocks: [{ type: 'context_compacted' }], + }; + } + return null; + } + + if (sdkMsg.type === 'result') { + return null; + } + + if (sdkMsg.type !== 'user' && sdkMsg.type !== 'assistant') { + return null; + } + + const content = sdkMsg.message?.content; + const textContent = extractTextContent(content); + const timestamp = sdkMsg.timestamp ? new Date(sdkMsg.timestamp).getTime() : Date.now(); + const messageId = sdkMsg.uuid || `sdk-${timestamp}`; + const images = sdkMsg.type === 'user' ? extractImages(content, messageId) : undefined; + + const hasToolUse = Array.isArray(content) && content.some(block => block.type === 'tool_use'); + const hasImages = !!images && images.length > 0; + if (!textContent && !hasToolUse && !hasImages && (!content || typeof content === 'string')) { + return null; + } + + const commandNameMatch = sdkMsg.type === 'user' + ? textContent.match(/(\/[^<]+)<\/command-name>/) + : null; + + let displayContent: string | undefined; + if (sdkMsg.type === 'user') { + displayContent = commandNameMatch ? commandNameMatch[1] : extractUserDisplayContent(textContent); + } + + const isInterrupt = sdkMsg.type === 'user' && isInterruptSignalText(textContent); + const isRebuiltContext = sdkMsg.type === 'user' && isRebuiltContextContent(textContent); + + return { + id: sdkMsg.uuid || `sdk-${timestamp}-${Math.random().toString(36).slice(2)}`, + role: sdkMsg.type, + content: textContent, + displayContent, + timestamp, + toolCalls: sdkMsg.type === 'assistant' ? extractToolCalls(content, toolResults) : undefined, + contentBlocks: sdkMsg.type === 'assistant' ? mapContentBlocks(content) : undefined, + images, + ...(sdkMsg.type === 'user' && sdkMsg.uuid && { userMessageId: sdkMsg.uuid }), + ...(sdkMsg.type === 'assistant' && sdkMsg.uuid && { assistantMessageId: sdkMsg.uuid }), + ...(isInterrupt && { isInterrupt: true }), + ...(isRebuiltContext && { isRebuiltContext: true }), + }; +} + +export function collectToolResults( + sdkMessages: SDKNativeMessage[], +): Map { + const results = new Map(); + + for (const sdkMsg of sdkMessages) { + const content = sdkMsg.message?.content; + if (!content || typeof content === 'string') { + continue; + } + + for (const block of content) { + if (block.type === 'tool_result' && block.tool_use_id) { + results.set(block.tool_use_id, { + content: extractToolResultContent(block.content), + isError: block.is_error ?? false, + }); + } + } + } + + return results; +} + +export function collectStructuredPatchResults(sdkMessages: SDKNativeMessage[]): Map { + const results = new Map(); + + for (const sdkMsg of sdkMessages) { + if (sdkMsg.type !== 'user' || !sdkMsg.toolUseResult) { + continue; + } + + const content = sdkMsg.message?.content; + if (!content || typeof content === 'string') { + continue; + } + + for (const block of content) { + if (block.type === 'tool_result' && block.tool_use_id) { + results.set(block.tool_use_id, sdkMsg.toolUseResult); + } + } + } + + return results; +} + +export function collectAsyncSubagentResults( + sdkMessages: SDKNativeMessage[], +): Map { + const results = new Map(); + + for (const sdkMsg of sdkMessages) { + if (sdkMsg.type !== 'queue-operation') { + continue; + } + if (sdkMsg.operation !== 'enqueue') { + continue; + } + if (typeof sdkMsg.content !== 'string') { + continue; + } + if (!sdkMsg.content.includes('')) { + continue; + } + + const taskId = extractXmlTag(sdkMsg.content, 'task-id'); + const status = extractXmlTag(sdkMsg.content, 'status'); + const result = extractXmlTag(sdkMsg.content, 'result'); + if (!taskId || !result) { + continue; + } + + results.set(taskId, { + result, + status: status ?? 'completed', + }); + } + + return results; +} + +export function extractXmlTag(content: string, tagName: string): string | null { + const regex = new RegExp(`<${tagName}>\\s*([\\s\\S]*?)\\s*`, 'i'); + const match = content.match(regex); + if (!match || !match[1]) { + return null; + } + + const trimmed = match[1].trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function isSystemInjectedMessage(sdkMsg: SDKNativeMessage): boolean { + if (sdkMsg.type !== 'user') { + return false; + } + if ('toolUseResult' in sdkMsg || 'sourceToolUseID' in sdkMsg || !!sdkMsg.isMeta) { + return true; + } + + const text = extractTextContent(sdkMsg.message?.content); + if (!text) { + return false; + } + + if (text.includes('') && text.includes('')) { + return false; + } + if (isCompactionCanceledStderr(text)) { + return false; + } + + if (text.startsWith('This session is being continued from a previous conversation')) { + return true; + } + if (text.includes('')) { + return true; + } + if (text.includes('') || text.includes('')) { + return true; + } + if (text.includes('')) { + return true; + } + + return false; +} + +export function mergeAssistantMessage(target: ChatMessage, source: ChatMessage): void { + if (source.content) { + target.content = target.content ? `${target.content}\n\n${source.content}` : source.content; + } + + if (source.toolCalls) { + target.toolCalls = [...(target.toolCalls || []), ...source.toolCalls]; + } + + if (source.contentBlocks) { + target.contentBlocks = [...(target.contentBlocks || []), ...source.contentBlocks]; + } + + if (source.assistantMessageId) { + target.assistantMessageId = source.assistantMessageId; + } +} + +export function hydrateStructuredToolResults(messages: ChatMessage[], toolUseResults: Map): void { + if (toolUseResults.size === 0) { + return; + } + + for (const msg of messages) { + if (msg.role !== 'assistant' || !msg.toolCalls) { + continue; + } + + for (const toolCall of msg.toolCalls) { + const toolUseResult = toolUseResults.get(toolCall.id); + if (!toolUseResult) { + continue; + } + + if (!toolCall.diffData) { + toolCall.diffData = extractDiffData(toolUseResult, toolCall); + } + + if (toolCall.name === TOOL_ASK_USER_QUESTION) { + const answers = + extractResolvedAnswers(toolUseResult) ?? + extractResolvedAnswersFromResultText(toolCall.result); + if (answers) { + toolCall.resolvedAnswers = answers; + } + } + } + } +} + +export function hydrateFallbackAskUserAnswers(messages: ChatMessage[]): void { + for (const msg of messages) { + if (msg.role !== 'assistant' || !msg.toolCalls) { + continue; + } + + for (const toolCall of msg.toolCalls) { + if (toolCall.name !== TOOL_ASK_USER_QUESTION || toolCall.resolvedAnswers) { + continue; + } + + const answers = extractResolvedAnswersFromResultText(toolCall.result); + if (answers) { + toolCall.resolvedAnswers = answers; + } + } + } +} diff --git a/src/providers/claude/history/sdkSessionPaths.ts b/src/providers/claude/history/sdkSessionPaths.ts new file mode 100644 index 0000000..77a1321 --- /dev/null +++ b/src/providers/claude/history/sdkSessionPaths.ts @@ -0,0 +1,236 @@ +import { existsSync } from 'fs'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import type { ProviderConversationSessionAvailability } from '../../../core/providers/types'; +import type { ClaudeConfigDirContext } from '../config/ClaudeConfigDir'; +import { resolveClaudeConfigDir } from '../config/ClaudeConfigDir'; +import type { SDKNativeMessage, SDKSessionReadResult } from './sdkHistoryTypes'; + +export interface SDKSessionLocation { + availability: ProviderConversationSessionAvailability; + sessionPath?: string; +} + +/** + * Encodes a vault path for the SDK project directory name. + * The SDK replaces ALL non-alphanumeric characters with `-`. + * This handles Unicode characters and special chars. + */ +export function encodeVaultPathForSDK(vaultPath: string): string { + const absolutePath = path.resolve(vaultPath); + return absolutePath.replace(/[^a-zA-Z0-9]/g, '-'); +} + +export function getSDKProjectsPath(context?: ClaudeConfigDirContext): string { + return path.join(resolveClaudeConfigDir(context), 'projects'); +} + +/** Validates an identifier for safe use in filesystem paths (no traversal, bounded length). */ +export function isPathSafeId(value: string): boolean { + if (!value || value.length === 0 || value.length > 128) { + return false; + } + if (value.includes('..') || value.includes('/') || value.includes('\\')) { + return false; + } + return /^[a-zA-Z0-9_-]+$/.test(value); +} + +export function isValidSessionId(sessionId: string): boolean { + return isPathSafeId(sessionId); +} + +export function getSDKSessionPath( + vaultPath: string, + sessionId: string, + context?: ClaudeConfigDirContext, +): string { + if (!isValidSessionId(sessionId)) { + throw new Error(`Invalid session ID: ${sessionId}`); + } + + const projectsPath = getSDKProjectsPath(context); + const encodedVault = encodeVaultPathForSDK(vaultPath); + return path.join(projectsPath, encodedVault, `${sessionId}.jsonl`); +} + +export function sdkSessionExists( + vaultPath: string, + sessionId: string, + context?: ClaudeConfigDirContext, +): boolean { + try { + const sessionPath = getSDKSessionPath(vaultPath, sessionId, context); + return existsSync(sessionPath); + } catch { + return false; + } +} + +function hasFileSystemErrorCode(error: unknown, code: string): boolean { + return !!error + && typeof error === 'object' + && 'code' in error + && error.code === code; +} + +export async function locateSDKSessions( + vaultPath: string, + sessionIds: string[], + context?: ClaudeConfigDirContext, +): Promise> { + const locations = new Map(); + const currentPaths = new Map(); + const unresolvedIds = new Set(); + + await Promise.all([...new Set(sessionIds)].map(async (sessionId) => { + let sessionPath: string; + try { + sessionPath = getSDKSessionPath(vaultPath, sessionId, context); + } catch { + locations.set(sessionId, { availability: 'unknown' }); + return; + } + + currentPaths.set(sessionId, sessionPath); + try { + await fs.access(sessionPath); + locations.set(sessionId, { availability: 'available', sessionPath }); + } catch (error) { + if (hasFileSystemErrorCode(error, 'ENOENT')) { + unresolvedIds.add(sessionId); + } else { + locations.set(sessionId, { availability: 'unknown' }); + } + } + })); + + if (unresolvedIds.size === 0) { + return locations; + } + + const targetIdsByFileName = new Map( + [...unresolvedIds].map(sessionId => [`${sessionId}.jsonl`, sessionId]), + ); + const projectsPath = getSDKProjectsPath(context); + const pendingDirectories = [projectsPath]; + let rootExists = true; + let scanComplete = true; + + while (pendingDirectories.length > 0 && unresolvedIds.size > 0) { + const directory = pendingDirectories.shift()!; + let entries; + try { + entries = await fs.readdir(directory, { withFileTypes: true }); + } catch (error) { + if (directory === projectsPath && hasFileSystemErrorCode(error, 'ENOENT')) { + rootExists = false; + } else if (!hasFileSystemErrorCode(error, 'ENOENT')) { + scanComplete = false; + } + continue; + } + + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isFile()) { + const sessionId = targetIdsByFileName.get(entry.name); + if (sessionId && unresolvedIds.has(sessionId)) { + const availability = entryPath === currentPaths.get(sessionId) + ? 'available' + : 'relocated'; + locations.set(sessionId, { availability, sessionPath: entryPath }); + unresolvedIds.delete(sessionId); + } + } else if (entry.isDirectory() && entry.name !== 'subagents') { + pendingDirectories.push(entryPath); + } else if (!entry.isDirectory()) { + // Do not follow symlinks or special files during a global history scan. + // Their contents remain unverified, so absence cannot be definitive. + scanComplete = false; + } + } + } + + for (const sessionId of unresolvedIds) { + locations.set(sessionId, { + availability: rootExists && scanComplete ? 'missing' : 'unknown', + }); + } + + return locations; +} + +export async function locateSDKSession( + vaultPath: string, + sessionId: string, + context?: ClaudeConfigDirContext, +): Promise { + return (await locateSDKSessions(vaultPath, [sessionId], context)).get(sessionId) + ?? { availability: 'unknown' }; +} + +export async function getSDKSessionAvailability( + vaultPath: string, + sessionId: string, + context?: ClaudeConfigDirContext, +): Promise { + return (await locateSDKSession(vaultPath, sessionId, context)).availability; +} + +export async function deleteSDKSession( + vaultPath: string, + sessionId: string, + context?: ClaudeConfigDirContext, +): Promise { + try { + const sessionPath = getSDKSessionPath(vaultPath, sessionId, context); + if (!existsSync(sessionPath)) { + return; + } + + await fs.unlink(sessionPath); + } catch { + // Best-effort deletion + } +} + +export async function readSDKSession( + vaultPath: string, + sessionId: string, + context?: ClaudeConfigDirContext, +): Promise { + try { + const sessionPath = getSDKSessionPath(vaultPath, sessionId, context); + if (!existsSync(sessionPath)) { + return { messages: [], skippedLines: 0 }; + } + return readSDKSessionFile(sessionPath); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + return { messages: [], skippedLines: 0, error: errorMsg }; + } +} + +export async function readSDKSessionFile(sessionPath: string): Promise { + try { + const content = await fs.readFile(sessionPath, 'utf-8'); + const lines = content.split('\n').filter(line => line.trim()); + const messages: SDKNativeMessage[] = []; + let skippedLines = 0; + + for (const line of lines) { + try { + messages.push(JSON.parse(line) as SDKNativeMessage); + } catch { + skippedLines++; + } + } + + return { messages, skippedLines }; + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + return { messages: [], skippedLines: 0, error: errorMsg }; + } +} diff --git a/src/providers/claude/history/sdkSubagentSidecar.ts b/src/providers/claude/history/sdkSubagentSidecar.ts new file mode 100644 index 0000000..57c0b89 --- /dev/null +++ b/src/providers/claude/history/sdkSubagentSidecar.ts @@ -0,0 +1,281 @@ +import { existsSync } from 'fs'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import type { ProviderHistoryPathContext } from '../../../core/providers/types'; +import type { ToolCallInfo } from '../../../core/types'; +import { extractFinalResultFromSubagentJsonl } from '../../../utils/subagentJsonl'; +import { extractToolResultContent } from '../sdk/toolResultContent'; +import type { SDKNativeMessage } from './sdkHistoryTypes'; +import { + encodeVaultPathForSDK, + getSDKProjectsPath, + isPathSafeId, + isValidSessionId, +} from './sdkSessionPaths'; + +export function isValidAgentId(agentId: string): boolean { + return isPathSafeId(agentId); +} + +type SubagentToolEvent = + | { + type: 'tool_use'; + toolUseId: string; + toolName: string; + toolInput: Record; + timestamp: number; + } + | { + type: 'tool_result'; + toolUseId: string; + content: string; + isError: boolean; + timestamp: number; + }; + +function parseTimestampMs(raw: unknown): number { + if (typeof raw !== 'string') { + return Date.now(); + } + + const parsed = Date.parse(raw); + return Number.isNaN(parsed) ? Date.now() : parsed; +} + +function parseSubagentEvents(entry: unknown): SubagentToolEvent[] { + if (!entry || typeof entry !== 'object') { + return []; + } + + const record = entry as SDKNativeMessage; + const content = record.message?.content; + if (!Array.isArray(content)) { + return []; + } + + const timestamp = parseTimestampMs(record.timestamp); + const events: SubagentToolEvent[] = []; + + for (const blockRaw of content) { + if (!blockRaw || typeof blockRaw !== 'object') { + continue; + } + + const block = blockRaw as { + type?: unknown; + id?: unknown; + name?: unknown; + input?: unknown; + tool_use_id?: unknown; + content?: unknown; + is_error?: unknown; + }; + + if (block.type === 'tool_use') { + if (typeof block.id !== 'string' || typeof block.name !== 'string') { + continue; + } + + events.push({ + type: 'tool_use', + toolUseId: block.id, + toolName: block.name, + toolInput: block.input && typeof block.input === 'object' + ? (block.input as Record) + : {}, + timestamp, + }); + continue; + } + + if (block.type === 'tool_result') { + if (typeof block.tool_use_id !== 'string') { + continue; + } + + events.push({ + type: 'tool_result', + toolUseId: block.tool_use_id, + content: extractToolResultContent(block.content), + isError: block.is_error === true, + timestamp, + }); + } + } + + return events; +} + +function buildToolCallsFromSubagentEvents(events: SubagentToolEvent[]): ToolCallInfo[] { + const toolsById = new Map< + string, + { + toolCall: ToolCallInfo; + hasToolUse: boolean; + hasToolResult: boolean; + timestamp: number; + } + >(); + + for (const event of events) { + const existing = toolsById.get(event.toolUseId); + + if (event.type === 'tool_use') { + if (!existing) { + toolsById.set(event.toolUseId, { + toolCall: { + id: event.toolUseId, + name: event.toolName, + input: { ...event.toolInput }, + status: 'running', + isExpanded: false, + }, + hasToolUse: true, + hasToolResult: false, + timestamp: event.timestamp, + }); + } else { + existing.toolCall.name = event.toolName; + existing.toolCall.input = { ...event.toolInput }; + existing.hasToolUse = true; + existing.timestamp = event.timestamp; + } + continue; + } + + if (!existing) { + toolsById.set(event.toolUseId, { + toolCall: { + id: event.toolUseId, + name: 'Unknown', + input: {}, + status: event.isError ? 'error' : 'completed', + result: event.content, + isExpanded: false, + }, + hasToolUse: false, + hasToolResult: true, + timestamp: event.timestamp, + }); + continue; + } + + existing.toolCall.status = event.isError ? 'error' : 'completed'; + existing.toolCall.result = event.content; + existing.hasToolResult = true; + } + + return Array.from(toolsById.values()) + .filter(entry => entry.hasToolUse) + .sort((a, b) => a.timestamp - b.timestamp) + .map(entry => entry.toolCall); +} + +function getSubagentSidecarPath( + vaultPath: string, + sessionId: string, + agentId: string, + sessionPath?: string, + pathContext?: ProviderHistoryPathContext, +): string | null { + if (!isValidSessionId(sessionId) || !isValidAgentId(agentId)) { + return null; + } + + const projectPath = sessionPath + ? path.dirname(sessionPath) + : path.join(getSDKProjectsPath(pathContext), encodeVaultPathForSDK(vaultPath)); + return path.join( + projectPath, + sessionId, + 'subagents', + `agent-${agentId}.jsonl`, + ); +} + +export async function loadSubagentToolCalls( + vaultPath: string, + sessionId: string, + agentId: string, + sessionPath?: string, + pathContext?: ProviderHistoryPathContext, +): Promise { + const subagentFilePath = getSubagentSidecarPath( + vaultPath, + sessionId, + agentId, + sessionPath, + pathContext, + ); + if (!subagentFilePath) { + return []; + } + + try { + if (!existsSync(subagentFilePath)) { + return []; + } + + const content = await fs.readFile(subagentFilePath, 'utf-8'); + const lines = content.split('\n').filter(line => line.trim()); + const events: SubagentToolEvent[] = []; + const seen = new Set(); + + for (const line of lines) { + let raw: unknown; + try { + raw = JSON.parse(line); + } catch { + continue; + } + + for (const event of parseSubagentEvents(raw)) { + const key = `${event.type}:${event.toolUseId}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + events.push(event); + } + } + + if (events.length === 0) { + return []; + } + + return buildToolCallsFromSubagentEvents(events); + } catch { + return []; + } +} + +export async function loadSubagentFinalResult( + vaultPath: string, + sessionId: string, + agentId: string, + sessionPath?: string, + pathContext?: ProviderHistoryPathContext, +): Promise { + const subagentFilePath = getSubagentSidecarPath( + vaultPath, + sessionId, + agentId, + sessionPath, + pathContext, + ); + if (!subagentFilePath) { + return null; + } + + try { + if (!existsSync(subagentFilePath)) { + return null; + } + + const content = await fs.readFile(subagentFilePath, 'utf-8'); + return extractFinalResultFromSubagentJsonl(content); + } catch { + return null; + } +} diff --git a/src/providers/claude/hooks/SubagentHooks.ts b/src/providers/claude/hooks/SubagentHooks.ts new file mode 100644 index 0000000..3433eae --- /dev/null +++ b/src/providers/claude/hooks/SubagentHooks.ts @@ -0,0 +1,31 @@ +import type { HookCallbackMatcher } from '@anthropic-ai/claude-agent-sdk'; + +import type { SubagentRuntimeState } from '../../../core/runtime/types'; + +export type SubagentHookState = SubagentRuntimeState; + +const STOP_BLOCK_REASON = 'Background subagents are still running. Use `TaskOutput task_id="..." block=true` to wait for their results before ending your turn.'; + +export function createStopSubagentHook( + getState: () => SubagentHookState +): HookCallbackMatcher { + return { + hooks: [ + async () => { + let hasRunning: boolean; + try { + hasRunning = getState().hasRunning; + } catch { + // Provider failed — assume subagents are running to be safe + hasRunning = true; + } + + if (hasRunning) { + return { decision: 'block' as const, reason: STOP_BLOCK_REASON }; + } + + return {}; + }, + ], + }; +} diff --git a/src/providers/claude/modelLabels.ts b/src/providers/claude/modelLabels.ts new file mode 100644 index 0000000..130e140 --- /dev/null +++ b/src/providers/claude/modelLabels.ts @@ -0,0 +1,77 @@ +function getFamilyDisplayName(family: string): string { + return family.charAt(0).toUpperCase() + family.slice(1).toLowerCase(); +} + +function formatClaudeModelDateTag(date: string | undefined): string | null { + if (!date || date.length < 6) { + return null; + } + + return `(${date.slice(2, 6)})`; +} + +function getCustomModelLabelSource(modelId: string): string { + if (!modelId.includes('/')) { + return modelId; + } + + return modelId.split('/').pop() || modelId; +} + +function formatGenericCustomModelLabel(labelSource: string): string { + return labelSource + .replace(/-/g, ' ') + .replace(/\b\w/g, letter => letter.toUpperCase()); +} + +/** + * Formats a custom model ID for display. Prefers the Claude-aware label + * (e.g. `Sonnet 4.5`); falls back to the slug tail for namespaced IDs + * (`vendor/model`) or a Title Cased version of the raw ID. + */ +export function formatCustomModelLabel(modelId: string): string { + const labelSource = getCustomModelLabelSource(modelId); + const claudeLabel = formatClaudeCustomModelLabel(labelSource); + if (claudeLabel) { + return claudeLabel; + } + return modelId.includes('/') ? labelSource : formatGenericCustomModelLabel(labelSource); +} + +function formatClaudeCustomModelLabel(labelSource: string): string | null { + const trimmed = labelSource.trim(); + if (!trimmed) { + return null; + } + + const is1M = trimmed.toLowerCase().endsWith('[1m]'); + const without1M = is1M ? trimmed.slice(0, -4) : trimmed; + const claudePrefixIndex = without1M.toLowerCase().indexOf('claude-'); + const candidate = claudePrefixIndex >= 0 ? without1M.slice(claudePrefixIndex) : without1M; + + const versionedMatch = candidate.match( + /^claude-(haiku|sonnet|opus|fable)-(\d+)-(\d+)(?:-(\d{8}))?(?:-v\d+:\d+)?$/i, + ); + if (versionedMatch) { + const [, family, major, minor, date] = versionedMatch; + const suffixes = [ + formatClaudeModelDateTag(date), + is1M ? '(1M)' : null, + ].filter(Boolean).join(' '); + return `${getFamilyDisplayName(family)} ${major}.${minor}${suffixes ? ` ${suffixes}` : ''}`; + } + + const majorOnlyMatch = candidate.match( + /^claude-(haiku|sonnet|opus|fable)-(\d+)(?:-(\d{8}))?(?:-v\d+:\d+)?$/i, + ); + if (majorOnlyMatch) { + const [, family, major, date] = majorOnlyMatch; + const suffixes = [ + formatClaudeModelDateTag(date), + is1M ? '(1M)' : null, + ].filter(Boolean).join(' '); + return `${getFamilyDisplayName(family)} ${major}${suffixes ? ` ${suffixes}` : ''}`; + } + + return null; +} diff --git a/src/providers/claude/modelOptions.ts b/src/providers/claude/modelOptions.ts new file mode 100644 index 0000000..7ecd6b1 --- /dev/null +++ b/src/providers/claude/modelOptions.ts @@ -0,0 +1,109 @@ +import { getRuntimeEnvironmentVariables } from '../../core/providers/providerEnvironment'; +import type { ProviderUIOption } from '../../core/providers/types'; +import { getModelsFromEnvironment } from './env/claudeModelEnv'; +import { formatCustomModelLabel } from './modelLabels'; +import { encodeClaudeModelSelectionId, toClaudeRuntimeModelId } from './modelSelection'; +import { getClaudeProviderSettings } from './settings'; +import { DEFAULT_CLAUDE_MODELS, normalizeLegacy1MModelAlias } from './types/models'; + +function parseConfiguredCustomModelIds(value: string): string[] { + const modelIds: string[] = []; + const seen = new Set(); + + for (const line of value.split(/\r?\n/)) { + const modelId = line.trim(); + if (!modelId || seen.has(modelId)) { + continue; + } + seen.add(modelId); + modelIds.push(modelId); + } + + return modelIds; +} + +function normalizeCustomModelAliases(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const aliases: Record = {}; + for (const [rawModelId, rawAlias] of Object.entries(value)) { + if (typeof rawAlias !== 'string') { + continue; + } + + const modelId = rawModelId.trim(); + const alias = rawAlias.trim(); + if (modelId && alias) { + aliases[modelId] = alias; + } + } + + return aliases; +} + +export function getClaudeModelOptions(settings: Record): ProviderUIOption[] { + const customModelAliases = normalizeCustomModelAliases(settings.customModelAliases); + const customModels = getModelsFromEnvironment( + getRuntimeEnvironmentVariables(settings, 'claude'), + customModelAliases, + ); + if (customModels.length > 0) { + return customModels.map((model) => ({ + ...model, + value: encodeClaudeModelSelectionId(model.value), + })); + } + + const claudeSettings = getClaudeProviderSettings(settings); + const models = [...DEFAULT_CLAUDE_MODELS]; + + const seenModelIds = new Set(models.map(model => toClaudeRuntimeModelId(model.value))); + for (const configuredModelId of parseConfiguredCustomModelIds(claudeSettings.customModels)) { + const modelId = toClaudeRuntimeModelId(configuredModelId); + if (seenModelIds.has(modelId)) { + continue; + } + + seenModelIds.add(modelId); + models.push({ + value: encodeClaudeModelSelectionId(modelId), + label: customModelAliases[modelId] ?? formatCustomModelLabel(modelId), + description: 'Custom model', + }); + } + + return models; +} + +export function resolveClaudeModelSelection( + settings: Record, + currentModel: string, +): string | null { + const modelOptions = getClaudeModelOptions(settings); + if (currentModel) { + const currentRuntimeModel = normalizeLegacy1MModelAlias(toClaudeRuntimeModelId(currentModel)); + const currentOption = modelOptions.find(option => + option.value === currentModel + || toClaudeRuntimeModelId(option.value) === currentRuntimeModel + ); + if (currentOption) { + return currentOption.value; + } + } + + const lastModel = getClaudeProviderSettings(settings).lastModel; + if (lastModel) { + const lastRuntimeModel = normalizeLegacy1MModelAlias(toClaudeRuntimeModelId(lastModel)); + const lastOption = modelOptions.find(option => + option.value === lastModel + || toClaudeRuntimeModelId(option.value) === lastRuntimeModel + ); + if (lastOption) { + return lastOption.value; + } + } + + return modelOptions[0]?.value ?? null; +} diff --git a/src/providers/claude/modelSelection.ts b/src/providers/claude/modelSelection.ts new file mode 100644 index 0000000..c962ebc --- /dev/null +++ b/src/providers/claude/modelSelection.ts @@ -0,0 +1,17 @@ +import { + encodeProviderModelSelectionId, + isProviderModelSelectionId, + toProviderRuntimeModelId, +} from '../../core/providers/modelSelection'; + +export function encodeClaudeModelSelectionId(modelId: string): string { + return encodeProviderModelSelectionId('claude', modelId); +} + +export function isClaudeModelSelectionId(modelId: string): boolean { + return isProviderModelSelectionId('claude', modelId); +} + +export function toClaudeRuntimeModelId(modelId: string): string { + return toProviderRuntimeModelId('claude', modelId); +} diff --git a/src/providers/claude/plugins/PluginManager.ts b/src/providers/claude/plugins/PluginManager.ts new file mode 100644 index 0000000..9f91af5 --- /dev/null +++ b/src/providers/claude/plugins/PluginManager.ts @@ -0,0 +1,206 @@ +/** + * PluginManager - Discover and manage Claude Code plugins. + * + * Plugins are discovered from two sources: + * - {CLAUDE_CONFIG_DIR}/plugins/installed_plugins.json: install paths for scanning agents + * - settings.json: enabled state (project overrides global) + */ + +import * as fs from 'fs'; +import { Notice } from 'obsidian'; +import * as path from 'path'; + +import type { PluginInfo, PluginScope } from '../../../core/types'; +import { resolveClaudeConfigDir } from '../config/ClaudeConfigDir'; +import type { CCSettingsStorage } from '../storage/CCSettingsStorage'; +import type { InstalledPluginEntry, InstalledPluginsFile } from '../types/plugins'; + +interface SettingsFile { + enabledPlugins?: Record; +} + +function readJsonFile(filePath: string): T | null { + try { + if (!fs.existsSync(filePath)) { + return null; + } + const content = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(content) as T; + } catch { + return null; + } +} + +function normalizePathForComparison(p: string): string { + try { + const resolved = fs.realpathSync(p); + if (typeof resolved === 'string' && resolved.length > 0) { + return resolved; + } + } catch { + // ignore + } + + return path.resolve(p); +} + +function selectInstalledPluginEntry( + entries: InstalledPluginEntry[], + normalizedVaultPath: string +): InstalledPluginEntry | null { + for (const entry of entries) { + if (entry.scope !== 'project') continue; + if (!entry.projectPath) continue; + if (normalizePathForComparison(entry.projectPath) === normalizedVaultPath) { + return entry; + } + } + + return entries.find(e => e.scope === 'user') ?? null; +} + +function extractPluginName(pluginId: string): string { + const atIndex = pluginId.indexOf('@'); + if (atIndex > 0) { + return pluginId.substring(0, atIndex); + } + return pluginId; +} + +export class PluginManager { + private ccSettingsStorage: CCSettingsStorage; + private vaultPath: string; + private resolveConfigDir: () => string; + private plugins: PluginInfo[] = []; + + constructor( + vaultPath: string, + ccSettingsStorage: CCSettingsStorage, + configDir: string | (() => string) = () => resolveClaudeConfigDir(), + ) { + this.vaultPath = vaultPath; + this.ccSettingsStorage = ccSettingsStorage; + this.resolveConfigDir = typeof configDir === 'function' ? configDir : () => configDir; + } + + async loadPlugins(): Promise { + const configDir = this.resolveConfigDir(); + const installedPlugins = readJsonFile( + path.join(configDir, 'plugins', 'installed_plugins.json'), + ); + const globalSettings = readJsonFile(path.join(configDir, 'settings.json')); + const projectSettings = await this.loadProjectSettings(); + + const globalEnabled = globalSettings?.enabledPlugins ?? {}; + const projectEnabled = projectSettings?.enabledPlugins ?? {}; + + const plugins: PluginInfo[] = []; + const normalizedVaultPath = normalizePathForComparison(this.vaultPath); + + if (installedPlugins?.plugins) { + for (const [pluginId, entries] of Object.entries(installedPlugins.plugins)) { + if (!entries || entries.length === 0) continue; + + const entriesArray = Array.isArray(entries) ? entries : [entries]; + if (!Array.isArray(entries)) { + new Notice(`Claudian: plugin "${pluginId}" has malformed entry in installed_plugins.json (expected array, got ${typeof entries})`); + } + const entry = selectInstalledPluginEntry(entriesArray, normalizedVaultPath); + if (!entry) continue; + + const scope: PluginScope = entry.scope === 'project' ? 'project' : 'user'; + + // Project setting takes precedence, then global, then default enabled + const enabled = projectEnabled[pluginId] ?? globalEnabled[pluginId] ?? true; + + plugins.push({ + id: pluginId, + name: extractPluginName(pluginId), + enabled, + scope, + installPath: entry.installPath, + }); + } + } + + this.plugins = plugins.sort((a, b) => { + if (a.scope !== b.scope) { + return a.scope === 'project' ? -1 : 1; + } + return a.id.localeCompare(b.id); + }); + } + + private async loadProjectSettings(): Promise { + const projectSettingsPath = path.join(this.vaultPath, '.claude', 'settings.json'); + return readJsonFile(projectSettingsPath); + } + + getPlugins(): PluginInfo[] { + return [...this.plugins]; + } + + hasPlugins(): boolean { + return this.plugins.length > 0; + } + + hasEnabledPlugins(): boolean { + return this.plugins.some((p) => p.enabled); + } + + getEnabledCount(): number { + return this.plugins.filter((p) => p.enabled).length; + } + + /** Used to detect changes that require restarting the persistent query. */ + getPluginsKey(): string { + const enabledPlugins = this.plugins + .filter((p) => p.enabled) + .sort((a, b) => a.id.localeCompare(b.id)); + + if (enabledPlugins.length === 0) { + return ''; + } + + return enabledPlugins.map((p) => `${p.id}:${p.installPath}`).join('|'); + } + + /** Writes to project .claude/settings.json so CLI respects the state. */ + async togglePlugin(pluginId: string): Promise { + const plugin = this.plugins.find((p) => p.id === pluginId); + if (!plugin) { + return; + } + + await this.persistEnabledState(plugin, !plugin.enabled); + } + + async enablePlugin(pluginId: string): Promise { + const plugin = this.plugins.find((p) => p.id === pluginId); + if (!plugin || plugin.enabled) { + return; + } + + await this.persistEnabledState(plugin, true); + } + + async disablePlugin(pluginId: string): Promise { + const plugin = this.plugins.find((p) => p.id === pluginId); + if (!plugin || !plugin.enabled) { + return; + } + + await this.persistEnabledState(plugin, false); + } + + private async persistEnabledState(plugin: PluginInfo, enabled: boolean): Promise { + const previous = plugin.enabled; + plugin.enabled = enabled; + try { + await this.ccSettingsStorage.setPluginEnabled(plugin.id, enabled); + } catch (error) { + plugin.enabled = previous; + throw error; + } + } +} diff --git a/src/providers/claude/prompt/ClaudeTurnEncoder.ts b/src/providers/claude/prompt/ClaudeTurnEncoder.ts new file mode 100644 index 0000000..d85c8bb --- /dev/null +++ b/src/providers/claude/prompt/ClaudeTurnEncoder.ts @@ -0,0 +1,46 @@ +import type { McpServerManager } from '../../../core/mcp/McpServerManager'; +import type { ChatTurnRequest, PreparedChatTurn } from '../../../core/runtime/types'; +import { appendBrowserContext } from '../../../utils/browser'; +import { appendCanvasContext } from '../../../utils/canvas'; +import { appendCurrentNote } from '../../../utils/context'; +import { appendEditorContext } from '../../../utils/editor'; + +function isCompactCommand(text: string): boolean { + return /^\/compact(\s|$)/i.test(text); +} + +export function encodeClaudeTurn( + request: ChatTurnRequest, + mcpManager: Pick, +): PreparedChatTurn { + const isCompact = isCompactCommand(request.text); + + let persistedContent = request.text; + if (!isCompact) { + if (request.currentNotePath) { + persistedContent = appendCurrentNote(persistedContent, request.currentNotePath); + } + + if (request.editorSelection) { + persistedContent = appendEditorContext(persistedContent, request.editorSelection); + } + + if (request.browserSelection) { + persistedContent = appendBrowserContext(persistedContent, request.browserSelection); + } + + if (request.canvasSelection) { + persistedContent = appendCanvasContext(persistedContent, request.canvasSelection); + } + } + + const mcpMentions = mcpManager.extractMentions(persistedContent); + + return { + request, + persistedContent, + prompt: mcpManager.transformMentions(persistedContent), + isCompact, + mcpMentions, + }; +} diff --git a/src/providers/claude/registration.ts b/src/providers/claude/registration.ts new file mode 100644 index 0000000..7b1faa4 --- /dev/null +++ b/src/providers/claude/registration.ts @@ -0,0 +1,69 @@ +import { getProviderConfig } from '../../core/providers/providerConfig'; +import type { ProviderModule } from '../../core/providers/types'; +import { + claudeWorkspaceRegistration, + getClaudeWorkspaceServices, +} from './app/ClaudeWorkspaceServices'; +import { InlineEditService as ClaudeInlineEditService } from './auxiliary/ClaudeInlineEditService'; +import { InstructionRefineService as ClaudeInstructionRefineService } from './auxiliary/ClaudeInstructionRefineService'; +import { TitleGenerationService as ClaudeTitleGenerationService } from './auxiliary/ClaudeTitleGenerationService'; +import { CLAUDE_PROVIDER_CAPABILITIES } from './capabilities'; +import { claudeSettingsReconciler } from './env/ClaudeSettingsReconciler'; +import { ClaudeConversationHistoryService } from './history/ClaudeConversationHistoryService'; +import { ClaudianService as ClaudeChatRuntime } from './runtime/ClaudeChatRuntime'; +import { ClaudeTaskResultInterpreter } from './runtime/ClaudeTaskResultInterpreter'; +import { getClaudeProviderSettings, updateClaudeProviderSettings } from './settings'; +import { claudeChatUIConfig } from './ui/ClaudeChatUIConfig'; + +const LEGACY_CLAUDE_1M_SETTINGS = ['enableOpus1M', 'enableSonnet1M'] as const; + +export const claudeProviderRegistration: ProviderModule = { + id: 'claude', + displayName: 'Claude', + blankTabOrder: 20, + isEnabled: () => true, + capabilities: CLAUDE_PROVIDER_CAPABILITIES, + environmentKeyPatterns: [/^ANTHROPIC_/i, /^CLAUDE_/i], + chatUIConfig: claudeChatUIConfig, + settingsReconciler: claudeSettingsReconciler, + settingsStorage: { + hostScopedFields: ['cliPathsByHost'], + legacyTopLevelFields: [ + 'claudeSafeMode', + 'claudeCliPath', + 'claudeCliPathsByHost', + 'loadUserClaudeSettings', + 'lastClaudeModel', + 'enableChrome', + 'enableBangBash', + ...LEGACY_CLAUDE_1M_SETTINGS, + 'environmentVariables', + 'lastEnvHash', + ], + normalizeStored(target, stored) { + const storedConfig = getProviderConfig(stored, 'claude'); + const removedLegacy1MSettings = LEGACY_CLAUDE_1M_SETTINGS.some(key => key in storedConfig); + updateClaudeProviderSettings(target, getClaudeProviderSettings(stored)); + return removedLegacy1MSettings; + }, + }, + createRuntime: ({ plugin }) => { + const workspace = getClaudeWorkspaceServices(); + const resolvedMcpManager = workspace?.mcpManager; + if (!resolvedMcpManager) { + throw new Error('Claude workspace services are not initialized.'); + } + + return new ClaudeChatRuntime(plugin, { + mcpManager: resolvedMcpManager, + pluginManager: workspace?.pluginManager, + agentManager: workspace?.agentManager, + }); + }, + createTitleGenerationService: (plugin) => new ClaudeTitleGenerationService(plugin), + createInstructionRefineService: (plugin) => new ClaudeInstructionRefineService(plugin), + createInlineEditService: (plugin) => new ClaudeInlineEditService(plugin), + historyService: new ClaudeConversationHistoryService(), + taskResultInterpreter: new ClaudeTaskResultInterpreter(), + workspace: claudeWorkspaceRegistration, +}; diff --git a/src/providers/claude/runtime/ClaudeApprovalHandler.ts b/src/providers/claude/runtime/ClaudeApprovalHandler.ts new file mode 100644 index 0000000..4f0efca --- /dev/null +++ b/src/providers/claude/runtime/ClaudeApprovalHandler.ts @@ -0,0 +1,158 @@ +import type { + CanUseTool, + PermissionMode as SDKPermissionMode, + PermissionResult, +} from '@anthropic-ai/claude-agent-sdk'; + +import type { + ApprovalCallback, + AskUserQuestionCallback, +} from '../../../core/runtime/types'; +import { getActionDescription } from '../../../core/security/ApprovalManager'; +import { + TOOL_ASK_USER_QUESTION, + TOOL_EXIT_PLAN_MODE, + TOOL_SKILL, +} from '../../../core/tools/toolNames'; +import type { + ApprovalDecision, + ExitPlanModeCallback, + ExitPlanModeDecision, +} from '../../../core/types'; +import type { PermissionMode } from '../../../core/types/settings'; +import { buildPermissionUpdates } from '../security/ClaudePermissionUpdates'; + +export interface ClaudeApprovalHandlerDeps { + getAllowedTools: () => string[] | null; + getApprovalCallback: () => ApprovalCallback | null; + getAskUserQuestionCallback: () => AskUserQuestionCallback | null; + getExitPlanModeCallback: () => ExitPlanModeCallback | null; + getPermissionMode: () => PermissionMode; + resolveSDKPermissionMode: (mode: PermissionMode) => SDKPermissionMode; + syncPermissionMode: (mode: PermissionMode, sdkMode: SDKPermissionMode) => void; + notifyAlwaysAppliedOnce: () => void; +} + +export function createClaudeApprovalCallback( + deps: ClaudeApprovalHandlerDeps, +): CanUseTool { + return async (toolName, input, options): Promise => { + const currentAllowedTools = deps.getAllowedTools(); + if (currentAllowedTools !== null) { + if (!currentAllowedTools.includes(toolName) && toolName !== TOOL_SKILL) { + const allowedList = currentAllowedTools.length > 0 + ? ` Allowed tools: ${currentAllowedTools.join(', ')}.` + : ' No tools are allowed for this query type.'; + return { + behavior: 'deny', + message: `Tool "${toolName}" is not allowed for this query.${allowedList}`, + }; + } + } + + const exitPlanModeCallback = deps.getExitPlanModeCallback(); + if (toolName === TOOL_EXIT_PLAN_MODE && exitPlanModeCallback) { + try { + const decision: ExitPlanModeDecision | null = await exitPlanModeCallback(input, options.signal); + if (decision === null) { + return { behavior: 'deny', message: 'User cancelled.', interrupt: true }; + } + if (decision.type === 'feedback') { + return { behavior: 'deny', message: decision.text, interrupt: false }; + } + + const permissionMode = deps.getPermissionMode(); + const sdkMode = deps.resolveSDKPermissionMode(permissionMode); + deps.syncPermissionMode(permissionMode, sdkMode); + return { + behavior: 'allow', + updatedInput: input, + updatedPermissions: [ + { type: 'setMode', mode: sdkMode, destination: 'session' }, + ], + }; + } catch (error) { + return { + behavior: 'deny', + message: `Failed to handle plan mode exit: ${error instanceof Error ? error.message : 'Unknown error'}`, + interrupt: true, + }; + } + } + + const askUserQuestionCallback = deps.getAskUserQuestionCallback(); + if (toolName === TOOL_ASK_USER_QUESTION && askUserQuestionCallback) { + try { + // The SDK's JSDoc says "Other will be provided automatically" but + // the SDK doesn't inject isOther into the canUseTool input. Claudian + // intercepts at canUseTool and renders its own UI, so we must inject + // isOther here to match the Claude Code CLI's built-in behavior. + const questions = input.questions; + if (Array.isArray(questions)) { + for (const q of questions) { + if (isObjectRecord(q) && !('isOther' in q)) { + q.isOther = true; + } + } + } + const answers = await askUserQuestionCallback(input, options.signal); + if (answers === null) { + return { behavior: 'deny', message: 'User declined to answer.', interrupt: true }; + } + return { behavior: 'allow', updatedInput: { ...input, answers } }; + } catch (error) { + return { + behavior: 'deny', + message: `Failed to get user answers: ${error instanceof Error ? error.message : 'Unknown error'}`, + interrupt: true, + }; + } + } + + const approvalCallback = deps.getApprovalCallback(); + if (!approvalCallback) { + return { behavior: 'deny', message: 'No approval handler available.' }; + } + + try { + const { decisionReason, blockedPath, agentID } = options; + const description = getActionDescription(toolName, input); + const decision: ApprovalDecision = await approvalCallback( + toolName, + input, + description, + { decisionReason, blockedPath, agentID }, + ); + + if (decision === 'cancel') { + return { behavior: 'deny', message: 'User interrupted.', interrupt: true }; + } + + if (decision === 'allow' || decision === 'allow-always') { + const updatedPermissions = buildPermissionUpdates( + toolName, + input, + decision, + options.suggestions, + ); + if (decision === 'allow-always' && updatedPermissions.length === 0) { + deps.notifyAlwaysAppliedOnce(); + return { behavior: 'allow', updatedInput: input }; + } + return { behavior: 'allow', updatedInput: input, updatedPermissions }; + } + + return { behavior: 'deny', message: 'User denied this action.', interrupt: false }; + } catch (error) { + return { + behavior: 'deny', + message: `Approval request failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + interrupt: false, + }; + } + }; +} + +function isObjectRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/providers/claude/runtime/ClaudeChatRuntime.ts b/src/providers/claude/runtime/ClaudeChatRuntime.ts new file mode 100644 index 0000000..1898a7f --- /dev/null +++ b/src/providers/claude/runtime/ClaudeChatRuntime.ts @@ -0,0 +1,1985 @@ +/** + * Claudian - Claude Agent SDK wrapper + * + * Handles communication with Claude via the Agent SDK. Manages streaming, + * session persistence, permission modes, and security hooks. + * + * Architecture: + * - Persistent query for active chat conversation (eliminates cold-start latency) + * - Cold-start queries for inline edit, title generation + * - MessageChannel for message queueing and turn management + * - Dynamic updates (model, effort level, permission mode, MCP servers) + */ + +import type { + CanUseTool, + Options, + PermissionMode as SDKPermissionMode, + Query, + RewindFilesResult, + SDKMessage, + SDKUserMessage, + SlashCommand as SDKSlashCommand, +} from '@anthropic-ai/claude-agent-sdk'; +import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk'; +import { Notice } from 'obsidian'; + +import type { McpServerManager } from '../../../core/mcp/McpServerManager'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import type { + AppAgentManager, + AppPluginManager, + ProviderHistoryPathContext, +} from '../../../core/providers/types'; +import type { ChatRuntime } from '../../../core/runtime/ChatRuntime'; +import type { + ApprovalCallback, + AskUserQuestionCallback, + AutoTurnCallback, + ChatRewindMode, + ChatRewindResult, + ChatRuntimeConversationState, + ChatRuntimeQueryOptions, + ChatTurnMetadata, + ChatTurnRequest, + PreparedChatTurn, + SessionUpdateResult, +} from '../../../core/runtime/types'; +import { TOOL_ENTER_PLAN_MODE, TOOL_SKILL } from '../../../core/tools/toolNames'; +import type { + ApprovalDecision, + ChatMessage, + Conversation, + ExitPlanModeCallback, + ImageAttachment, + SlashCommand, + StreamChunk, + ToolCallInfo, +} from '../../../core/types'; +import type { ClaudianSettings, PermissionMode } from '../../../core/types/settings'; +import { stripCurrentNoteContext } from '../../../utils/context'; +import { getEnhancedPath, getMissingNodeError, parseEnvironmentVariables } from '../../../utils/env'; +import { getVaultPath } from '../../../utils/path'; +import { + buildContextFromHistory, + buildPromptWithHistoryContext, + getLastUserMessage, + getMissingSessionId, + isSessionExpiredError, + isSessionMissingError, +} from '../../../utils/session'; +import { CLAUDE_PROVIDER_CAPABILITIES } from '../capabilities'; +import { loadSubagentFinalResult, loadSubagentToolCalls } from '../history/ClaudeHistoryStore'; +import { createStopSubagentHook, type SubagentHookState } from '../hooks/SubagentHooks'; +import { toClaudeRuntimeModelId } from '../modelSelection'; +import { encodeClaudeTurn } from '../prompt/ClaudeTurnEncoder'; +import { isContextWindowEvent, isSessionInitEvent, isStreamChunk } from '../sdk/typeGuards'; +import type { TransformEvent } from '../sdk/types'; +import { getClaudeProviderSettings } from '../settings'; +import { + createTransformStreamState, + createTransformUsageState, + transformSDKMessage, +} from '../stream/transformClaudeMessage'; +import { type ClaudeProviderState, getClaudeState } from '../types/providerState'; +import { createClaudeApprovalCallback } from './ClaudeApprovalHandler'; +import { applyClaudeDynamicUpdates } from './ClaudeDynamicUpdates'; +import { MessageChannel } from './ClaudeMessageChannel'; +import { + type ColdStartQueryContext, + type PersistentQueryContext, + QueryOptionsBuilder, + type QueryOptionsContext, +} from './ClaudeQueryOptionsBuilder'; +import { executeClaudeRewind } from './ClaudeRewindService'; +import { SessionManager } from './ClaudeSessionManager'; +import { + buildClaudePromptWithImages, + buildClaudeSDKUserMessage, +} from './ClaudeUserMessageFactory'; +import { + type ClaudeEnsureReadyOptions, + type ClosePersistentQueryOptions, + createResponseHandler, + isTurnCompleteMessage, + type PersistentQueryConfig, + type ResponseHandler, +} from './types'; + +export type { ApprovalDecision }; +export type { + ApprovalCallback, + ApprovalCallbackOptions, + AskUserQuestionCallback, +} from '../../../core/runtime/types'; + +export interface ClaudeRuntimeServices { + mcpManager: McpServerManager; + pluginManager: AppPluginManager; + agentManager: Pick; +} + +type QueryOptions = ChatRuntimeQueryOptions; + +function isChatMessageArray(value: unknown): value is ChatMessage[] { + return Array.isArray(value) && value.length > 0 && + !!value[0] && typeof value[0] === 'object' && 'role' in value[0] && 'content' in value[0]; +} + +function isImageAttachmentArray(value: unknown): value is ImageAttachment[] { + return Array.isArray(value) && value.length > 0 && + !!value[0] && typeof value[0] === 'object' && 'mediaType' in value[0] && 'data' in value[0]; +} + +export class ClaudianService implements ChatRuntime { + readonly providerId = CLAUDE_PROVIDER_CAPABILITIES.providerId; + private plugin: ProviderHost; + private agentManager: Pick | null; + private pluginManager: AppPluginManager | null; + private abortController: AbortController | null = null; + private approvalCallback: ApprovalCallback | null = null; + private approvalDismisser: (() => void) | null = null; + private askUserQuestionCallback: AskUserQuestionCallback | null = null; + private exitPlanModeCallback: ExitPlanModeCallback | null = null; + private permissionModeSyncCallback: ((sdkMode: string) => void) | null = null; + private vaultPath: string | null = null; + private currentExternalContextPaths: string[] = []; + private currentConversationModel: string | null = null; + private readyStateListeners = new Set<(ready: boolean) => void>(); + + // Modular components + private sessionManager = new SessionManager(); + private mcpManager: McpServerManager; + + private persistentQuery: Query | null = null; + private messageChannel: MessageChannel | null = null; + private queryAbortController: AbortController | null = null; + private responseHandlers: ResponseHandler[] = []; + private responseConsumerRunning = false; + private responseConsumerPromise: Promise | null = null; + private shuttingDown = false; + + // Tracked configuration for detecting changes that require restart + private currentConfig: PersistentQueryConfig | null = null; + private authoritativeContextWindow: { + query: Query; + model: string; + contextWindow: number; + } | null = null; + private contextWindowDiscovery: { + query: Query; + model: string; + promise: Promise; + } | null = null; + + // Current allowed tools for canUseTool enforcement (null = no restriction) + private currentAllowedTools: string[] | null = null; + + private pendingResumeAt?: string; + private pendingForkSession = false; + + // Last sent message for crash recovery (Phase 1.3) + private lastSentMessage: SDKUserMessage | null = null; + private lastSentQueryOptions: QueryOptions | null = null; + private crashRecoveryAttempted = false; + private coldStartInProgress = false; // Prevent consumer error restarts during cold-start + + // SDK command cache — populated on system/init, cleared on persistent query close + private cachedSdkCommands: SlashCommand[] = []; + + // Subagent hook state provider (set from feature layer to avoid core→feature dependency) + private _subagentStateProvider: (() => SubagentHookState) | null = null; + + // Auto-triggered turn handling (e.g., task-notification delivery by the SDK) + private _autoTurnBuffer: StreamChunk[] = []; + private _autoTurnSawStreamText = false; + private _autoTurnSawStreamThinking = false; + private _autoTurnCallback: AutoTurnCallback | null = null; + private turnMetadata: ChatTurnMetadata = {}; + private bufferedUsageChunk: StreamChunk & { type: 'usage' } | null = null; + private streamTransformState = createTransformStreamState(); + private usageTransformState = createTransformUsageState(); + + private toProviderSessionMissingChunk(error: unknown): (StreamChunk & { type: 'error' }) | null { + if (!isSessionMissingError(error, this.sessionManager.getSessionId() ?? undefined)) { + return null; + } + + this.sessionManager.invalidateSession(); + return { + type: 'error', + content: error instanceof Error ? error.message : 'Provider session not found', + code: 'provider_session_missing', + providerSessionId: getMissingSessionId(error) ?? undefined, + }; + } + + private getLegacyPluginDeps(): ProviderHost & { + agentManager?: Pick; + pluginManager?: AppPluginManager; + } { + return this.plugin; + } + + constructor(plugin: ProviderHost, services: ClaudeRuntimeServices | McpServerManager) { + this.plugin = plugin; + const legacyPlugin = this.getLegacyPluginDeps(); + + if ('mcpManager' in services) { + this.mcpManager = services.mcpManager; + this.pluginManager = services.pluginManager ?? legacyPlugin.pluginManager ?? null; + this.agentManager = services.agentManager ?? legacyPlugin.agentManager ?? null; + return; + } + + this.mcpManager = services; + this.pluginManager = legacyPlugin.pluginManager ?? null; + this.agentManager = legacyPlugin.agentManager ?? null; + } + + getCapabilities() { + return CLAUDE_PROVIDER_CAPABILITIES; + } + + prepareTurn(request: ChatTurnRequest): PreparedChatTurn { + return encodeClaudeTurn(request, this.mcpManager); + } + + consumeTurnMetadata(): ChatTurnMetadata { + const metadata = { ...this.turnMetadata }; + this.turnMetadata = {}; + this.bufferedUsageChunk = null; + return metadata; + } + + getAuxiliaryModel(): string | null { + return this.currentConversationModel; + } + + onReadyStateChange(listener: (ready: boolean) => void): () => void { + this.readyStateListeners.add(listener); + try { + listener(this.isReady()); + } catch { + // Ignore listener errors + } + return () => { + this.readyStateListeners.delete(listener); + }; + } + + private notifyReadyStateChange(): void { + if (this.readyStateListeners.size === 0) { + return; + } + + const isReady = this.isReady(); + for (const listener of this.readyStateListeners) { + try { + listener(isReady); + } catch { + // Ignore listener errors + } + } + } + + private resetTurnMetadata(): void { + this.turnMetadata = {}; + this.bufferedUsageChunk = null; + this.usageTransformState.clear(); + } + + private recordTurnMetadata(update: Partial): void { + this.turnMetadata = { + ...this.turnMetadata, + ...update, + }; + } + + private bufferUsageChunk(chunk: Extract): Extract { + this.bufferedUsageChunk = chunk; + return chunk; + } + + private updateBufferedUsageContextWindow(contextWindow: number): Extract | null { + if (!this.bufferedUsageChunk || contextWindow <= 0) { + return null; + } + + const usage = this.bufferedUsageChunk.usage; + const percentage = Math.min( + 100, + Math.max(0, Math.round((usage.contextTokens / contextWindow) * 100)), + ); + const nextChunk: Extract = { + ...this.bufferedUsageChunk, + usage: { + ...usage, + contextWindow, + contextWindowIsAuthoritative: true, + percentage, + }, + }; + this.bufferedUsageChunk = nextChunk; + return nextChunk; + } + + private refreshAuthoritativeContextWindow(): Promise { + const query = this.persistentQuery; + const model = this.currentConfig?.model; + if (!query || !model || typeof query.getContextUsage !== 'function') { + return Promise.resolve(); + } + + if ( + this.authoritativeContextWindow?.query === query + && this.authoritativeContextWindow.model === model + ) { + return Promise.resolve(); + } + + if ( + this.contextWindowDiscovery?.query === query + && this.contextWindowDiscovery.model === model + ) { + return this.contextWindowDiscovery.promise; + } + + let request: ReturnType; + try { + request = query.getContextUsage(); + } catch { + return Promise.resolve(); + } + + const promise = request + .then((contextUsage) => { + if (this.persistentQuery !== query || this.currentConfig?.model !== model) { + return; + } + + const contextWindow = contextUsage.rawMaxTokens; + if (typeof contextWindow !== 'number' || contextWindow <= 0 || !Number.isFinite(contextWindow)) { + return; + } + + this.authoritativeContextWindow = { query, model, contextWindow }; + }) + .catch(() => { + // Runtime context discovery is an optimization; stream/result data remains the fallback. + }) + .finally(() => { + if (this.contextWindowDiscovery?.promise === promise) { + this.contextWindowDiscovery = null; + } + }); + + this.contextWindowDiscovery = { query, model, promise }; + return promise; + } + + private rememberResultContextWindow(contextWindow: number): void { + const query = this.persistentQuery; + const model = this.currentConfig?.model; + if (!query || !model || contextWindow <= 0 || !Number.isFinite(contextWindow)) { + return; + } + this.authoritativeContextWindow = { query, model, contextWindow }; + } + + private setCurrentConversationModel(model: unknown): void { + const selectedModel = typeof model === 'string' ? model.trim() : ''; + this.currentConversationModel = selectedModel || null; + } + + setPendingResumeAt(uuid: string | undefined): void { + this.pendingResumeAt = uuid; + } + + setResumeCheckpoint(checkpointId: string | undefined): void { + this.setPendingResumeAt(checkpointId); + } + + /** One-shot: consumed on the next query, then cleared by routeMessage on session init. */ + private applyForkState(conv: ChatRuntimeConversationState): string | null { + const state = getClaudeState(conv.providerState); + const isPending = !conv.sessionId && !state.providerSessionId && !!state.forkSource; + this.pendingForkSession = isPending; + if (isPending) { + this.pendingResumeAt = state.forkSource!.resumeAt; + } else { + this.pendingResumeAt = undefined; + } + return conv.sessionId ?? state.forkSource?.sessionId ?? null; + } + + syncConversationState( + conversation: ChatRuntimeConversationState | null, + externalContextPaths?: string[], + ): void { + if (!conversation) { + this.currentConversationModel = null; + this.pendingForkSession = false; + this.pendingResumeAt = undefined; + this.setSessionId(null, externalContextPaths); + return; + } + + this.setCurrentConversationModel(conversation.selectedModel); + const resolvedSessionId = this.applyForkState(conversation); + this.setSessionId(resolvedSessionId, externalContextPaths); + } + + buildSessionUpdates({ conversation, sessionInvalidated }: { + conversation: Conversation | null; + sessionInvalidated: boolean; + }): SessionUpdateResult { + const sessionId = this.getSessionId(); + const existingState = getClaudeState(conversation?.providerState); + + const oldSdkSessionId = existingState.providerSessionId; + const sessionChanged = sessionId && oldSdkSessionId && sessionId !== oldSdkSessionId; + const previousProviderSessionIds = sessionChanged + ? [...new Set([...(existingState.previousProviderSessionIds || []), oldSdkSessionId])] + : existingState.previousProviderSessionIds; + + const isForkSourceOnly = !!existingState.forkSource && + !existingState.providerSessionId && + sessionId === existingState.forkSource.sessionId; + + let resolvedSessionId: string | null; + if (sessionInvalidated) { + resolvedSessionId = null; + } else if (isForkSourceOnly) { + resolvedSessionId = conversation?.sessionId ?? null; + } else { + resolvedSessionId = sessionId ?? conversation?.sessionId ?? null; + } + + const newProviderState: ClaudeProviderState = { + ...existingState, + providerSessionId: sessionId && !isForkSourceOnly ? sessionId : existingState.providerSessionId, + previousProviderSessionIds, + }; + + if (existingState.forkSource && sessionId && sessionId !== existingState.forkSource.sessionId) { + delete newProviderState.forkSource; + } + + return { + updates: { + sessionId: resolvedSessionId, + providerState: newProviderState as Record, + }, + }; + } + + resolveSessionIdForFork(conversation: Conversation | null): string | null { + const sessionId = this.getSessionId(); + if (sessionId) return sessionId; + if (!conversation) return null; + const state = getClaudeState(conversation.providerState); + return state.providerSessionId ?? conversation.sessionId ?? state.forkSource?.sessionId ?? null; + } + + async loadSubagentToolCalls(agentId: string): Promise { + const sessionId = this.getSessionId(); + const vaultPath = getVaultPath(this.plugin.app); + if (!sessionId || !vaultPath) return []; + return loadSubagentToolCalls( + vaultPath, + sessionId, + agentId, + undefined, + this.buildHistoryPathContext(vaultPath), + ); + } + + async loadSubagentFinalResult(agentId: string): Promise { + const sessionId = this.getSessionId(); + const vaultPath = getVaultPath(this.plugin.app); + if (!sessionId || !vaultPath) return null; + return loadSubagentFinalResult( + vaultPath, + sessionId, + agentId, + undefined, + this.buildHistoryPathContext(vaultPath), + ); + } + + async reloadMcpServers(): Promise { + await this.mcpManager.loadServers(); + } + + /** + * Ensures the persistent query is running with current configuration. + * Unified API that replaces preWarm() and restartPersistentQuery(). + * + * Behavior: + * - If not running → start (if paths available) + * - If running and force=true → close and restart + * - If running and config changed → close and restart + * - If running and config unchanged → no-op + * + * Note: When restart is needed, the query is closed BEFORE checking if we can + * start a new one. This ensures fallback to cold-start if CLI becomes unavailable. + * + * @returns true if the query was (re)started, false otherwise + */ + async ensureReady(options?: ClaudeEnsureReadyOptions): Promise { + const vaultPath = getVaultPath(this.plugin.app); + + // Track external context paths for dynamic updates (empty list clears) + if (options && options.externalContextPaths !== undefined) { + this.currentExternalContextPaths = options.externalContextPaths; + } + + // Auto-resolve session ID from sessionManager if not explicitly provided + const effectiveSessionId = options?.sessionId ?? this.sessionManager.getSessionId() ?? undefined; + const externalContextPaths = options?.externalContextPaths ?? this.currentExternalContextPaths; + + // Case 1: Not running → try to start + if (!this.persistentQuery) { + if (!vaultPath) return false; + const cliPath = this.plugin.getResolvedProviderCliPath('claude'); + if (!cliPath) return false; + await this.startPersistentQuery(vaultPath, cliPath, effectiveSessionId, externalContextPaths); + return true; + } + + // Case 2: Force restart (session switch, crash recovery) + // Close FIRST, then try to start new one (allows fallback if CLI unavailable) + if (options?.force) { + this.closePersistentQuery('forced restart', { preserveHandlers: options.preserveHandlers }); + if (!vaultPath) return false; + const cliPath = this.plugin.getResolvedProviderCliPath('claude'); + if (!cliPath) return false; + await this.startPersistentQuery(vaultPath, cliPath, effectiveSessionId, externalContextPaths); + return true; + } + + // Case 3: Check if config changed → restart if needed + // We need vaultPath and cliPath to build config for comparison + if (!vaultPath) return false; + const cliPath = this.plugin.getResolvedProviderCliPath('claude'); + if (!cliPath) return false; + + const newConfig = this.buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths); + if (this.needsRestart(newConfig)) { + // Close FIRST, then try to start new one (allows fallback if CLI unavailable) + this.closePersistentQuery('config changed', { preserveHandlers: options?.preserveHandlers }); + // Re-check CLI path as it might have changed during close + const cliPathAfterClose = this.plugin.getResolvedProviderCliPath('claude'); + if (cliPathAfterClose) { + await this.startPersistentQuery(vaultPath, cliPathAfterClose, effectiveSessionId, externalContextPaths); + return true; + } + // CLI unavailable after close - query is closed, will fallback to cold-start + return false; + } + + // Case 4: Running and config unchanged → no-op + return false; + } + + /** + * Starts the persistent query for the active chat conversation. + */ + private async startPersistentQuery( + vaultPath: string, + cliPath: string, + resumeSessionId?: string, + externalContextPaths?: string[] + ): Promise { + if (this.persistentQuery) { + return; + } + + this.shuttingDown = false; + this.vaultPath = vaultPath; + + this.messageChannel = new MessageChannel(); + + if (resumeSessionId) { + this.messageChannel.setSessionId(resumeSessionId); + this.sessionManager.setSessionId(resumeSessionId, this.getScopedSettings().model); + } + + this.queryAbortController = new AbortController(); + + const config = this.buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths); + this.currentConfig = config; + + const resumeAtMessageId = this.pendingResumeAt; + const options = this.buildPersistentQueryOptions( + vaultPath, + cliPath, + resumeSessionId, + resumeAtMessageId, + externalContextPaths + ); + + this.persistentQuery = agentQuery({ + prompt: this.messageChannel, + options, + }); + + if (this.pendingResumeAt === resumeAtMessageId) { + this.pendingResumeAt = undefined; + } + this.attachPersistentQueryStdinErrorHandler(this.persistentQuery); + + this.startResponseConsumer(); + this.notifyReadyStateChange(); + } + + private attachPersistentQueryStdinErrorHandler(query: Query): void { + const stdin = (query as { transport?: { processStdin?: NodeJS.WritableStream } }).transport?.processStdin; + if (!stdin || typeof stdin.on !== 'function' || typeof stdin.once !== 'function') { + return; + } + + const handler = (error: NodeJS.ErrnoException) => { + if (this.shuttingDown || this.isPipeError(error)) { + return; + } + this.closePersistentQuery('stdin error'); + }; + + stdin.on('error', handler); + stdin.once('close', () => { + stdin.removeListener('error', handler); + }); + } + + private isPipeError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const e = error as { code?: string; message?: string }; + return e.code === 'EPIPE' || (typeof e.message === 'string' && e.message.includes('EPIPE')); + } + + /** + * Closes the persistent query and cleans up resources. + */ + closePersistentQuery(_reason?: string, options?: ClosePersistentQueryOptions): void { + if (!this.persistentQuery) { + return; + } + + const preserveHandlers = options?.preserveHandlers ?? false; + + this.shuttingDown = true; + + // Close the message channel (ends the async iterable) + this.messageChannel?.close(); + + // Interrupt the query + void this.persistentQuery.interrupt().catch(() => { + // Silence abort/interrupt errors during shutdown + }); + + // Abort as backup + this.queryAbortController?.abort(); + + if (!preserveHandlers) { + // Notify all handlers before clearing so generators don't hang forever. + // This ensures queryViaPersistent() exits its while(!state.done) loop. + for (const handler of this.responseHandlers) { + handler.onDone(); + } + } + + // Reset shuttingDown synchronously. The consumer loop sees shuttingDown=true + // on its next iteration check (line 549) and breaks. The messageChannel.close() + // above also terminates the for-await loop. Resetting here allows new queries + // to proceed immediately without waiting for consumer loop teardown. + this.shuttingDown = false; + this.notifyReadyStateChange(); + + // Clear state + this.persistentQuery = null; + this.messageChannel = null; + this.queryAbortController = null; + this.responseConsumerRunning = false; + this.responseConsumerPromise = null; + this.currentConfig = null; + this.authoritativeContextWindow = null; + this.contextWindowDiscovery = null; + this.cachedSdkCommands = []; + this.streamTransformState.clearAll(); + this.usageTransformState.clear(); + this._autoTurnBuffer = []; + this._autoTurnSawStreamText = false; + this._autoTurnSawStreamThinking = false; + if (!preserveHandlers) { + this.responseHandlers = []; + this.currentAllowedTools = null; + } + + // NOTE: Do NOT reset crashRecoveryAttempted here. + // It's reset in queryViaPersistent after a successful message send, + // or in resetSession/setSessionId when switching sessions. + // Resetting it here would cause infinite restart loops on persistent errors. + } + + /** + * Checks if the persistent query needs to be restarted based on configuration changes. + */ + private needsRestart(newConfig: PersistentQueryConfig): boolean { + return QueryOptionsBuilder.needsRestart(this.currentConfig, newConfig); + } + + /** + * Builds configuration object for tracking changes. + */ + private buildPersistentQueryConfig( + vaultPath: string, + cliPath: string, + externalContextPaths?: string[] + ): PersistentQueryConfig { + return QueryOptionsBuilder.buildPersistentQueryConfig( + this.buildQueryOptionsContext(vaultPath, cliPath), + externalContextPaths + ); + } + + /** + * Builds the base query options context from current state. + */ + private getScopedSettings(): ClaudianSettings { + const settings = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + this.plugin.settings, + this.providerId, + ); + if (this.currentConversationModel) { + settings.model = this.currentConversationModel; + } + return settings; + } + + private buildQueryOptionsContext(vaultPath: string, cliPath: string): QueryOptionsContext { + const customEnv = parseEnvironmentVariables(this.plugin.getActiveEnvironmentVariables(this.providerId)); + const enhancedPath = getEnhancedPath(customEnv.PATH, cliPath); + + return { + vaultPath, + cliPath, + settings: this.getScopedSettings(), + customEnv, + enhancedPath, + mcpManager: this.mcpManager, + pluginManager: this.requirePluginManager(), + }; + } + + private buildHistoryPathContext(vaultPath: string): ProviderHistoryPathContext { + const customEnv = parseEnvironmentVariables( + this.plugin.getActiveEnvironmentVariables(this.providerId), + ); + return { + environment: { ...process.env, ...customEnv }, + hostPlatform: process.platform, + settings: this.getScopedSettings(), + vaultPath, + }; + } + + private requirePluginManager(): AppPluginManager { + const pluginManager = this.pluginManager ?? this.getLegacyPluginDeps().pluginManager ?? null; + if (!pluginManager) { + throw new Error('Claude plugin manager is unavailable.'); + } + return pluginManager; + } + + private getAgentManager(): Pick | null { + return this.agentManager ?? this.getLegacyPluginDeps().agentManager ?? null; + } + + /** + * Builds SDK options for the persistent query. + */ + private buildPersistentQueryOptions( + vaultPath: string, + cliPath: string, + resumeSessionId?: string, + resumeAtMessageId?: string, + externalContextPaths?: string[] + ): Options { + const baseContext = this.buildQueryOptionsContext(vaultPath, cliPath); + const hooks = this.buildHooks(); + + const ctx: PersistentQueryContext = { + ...baseContext, + abortController: this.queryAbortController ?? undefined, + resume: resumeSessionId + ? { sessionId: resumeSessionId, sessionAt: resumeAtMessageId, fork: this.pendingForkSession || undefined } + : undefined, + canUseTool: this.createApprovalCallback(), + hooks, + externalContextPaths, + }; + + return QueryOptionsBuilder.buildPersistentQueryOptions(ctx); + } + + /** + * Builds the hooks for SDK options. + * Hooks need access to `this` for dynamic settings, so they're built here. + */ + private buildHooks() { + const hooks: Options['hooks'] = {}; + + // Always register subagent hooks — closures resolve provider at execution time + // so hooks work even when provider is set after the persistent query starts. + hooks.Stop = [createStopSubagentHook( + () => this._subagentStateProvider?.() ?? { hasRunning: false } + )]; + + return hooks; + } + + /** + * Starts the background consumer loop that routes chunks to handlers. + */ + private startResponseConsumer(): void { + if (this.responseConsumerRunning) { + return; + } + + this.responseConsumerRunning = true; + + // Track which query this consumer is for, to detect if we were replaced + const queryForThisConsumer = this.persistentQuery; + + this.responseConsumerPromise = (async () => { + if (!this.persistentQuery) return; + + try { + for await (const message of this.persistentQuery) { + if (this.shuttingDown) break; + + await this.routeMessage(message); + } + } catch (error) { + // Skip error handling if this consumer was replaced by a new one. + // This prevents race conditions where the OLD consumer's error handler + // interferes with the NEW handler after a restart (e.g., from applyDynamicUpdates). + if (this.persistentQuery !== queryForThisConsumer && this.persistentQuery !== null) { + return; + } + + // Skip restart if cold-start is in progress (it will handle session capture) + if (!this.shuttingDown && !this.coldStartInProgress) { + const handler = this.responseHandlers[this.responseHandlers.length - 1]; + const errorInstance = error instanceof Error ? error : new Error(String(error)); + const messageToReplay = this.lastSentMessage; + + const missingSessionChunk = this.toProviderSessionMissingChunk(errorInstance); + if (missingSessionChunk) { + handler?.onError(errorInstance); + this.closePersistentQuery('provider session missing', { preserveHandlers: true }); + return; + } + + if (!this.crashRecoveryAttempted && messageToReplay && handler && !handler.sawAnyChunk) { + this.crashRecoveryAttempted = true; + try { + await this.ensureReady({ force: true, preserveHandlers: true }); + if (!this.messageChannel) { + throw new Error('Persistent query restart did not create message channel', { + cause: error, + }); + } + await this.applyDynamicUpdates(this.lastSentQueryOptions ?? undefined, { preserveHandlers: true }); + this.messageChannel.enqueue(messageToReplay); + return; + } catch (restartError) { + // If restart failed due to session expiration, invalidate session + // so next query triggers noSessionButHasHistory → history rebuild + if (isSessionExpiredError(restartError)) { + this.sessionManager.invalidateSession(); + } + handler.onError(errorInstance); + return; + } + } + + // Notify active handler of error + if (handler) { + handler.onError(errorInstance); + } + + // Crash recovery: restart persistent query to prepare for next user message. + if (!this.crashRecoveryAttempted) { + this.crashRecoveryAttempted = true; + try { + await this.ensureReady({ force: true }); + } catch (restartError) { + // If restart failed due to session expiration, invalidate session + // so next query triggers noSessionButHasHistory → history rebuild + if (isSessionExpiredError(restartError)) { + this.sessionManager.invalidateSession(); + } + // Restart failed - next query will start fresh. + } + } + } + } finally { + // Only clear the flag if this consumer wasn't replaced by a new one (e.g., after restart) + // If ensureReady() restarted, it starts a new consumer which sets the flag true, + // so we shouldn't clear it here. + if (this.persistentQuery === queryForThisConsumer || this.persistentQuery === null) { + this.responseConsumerRunning = false; + } + } + })(); + } + + /** @param modelOverride - Optional model override for cold-start queries */ + private getTransformOptions( + modelOverride?: string, + streamState = this.streamTransformState, + usageState = this.usageTransformState, + ) { + const settings = this.getScopedSettings(); + const intendedModel = toClaudeRuntimeModelId(modelOverride ?? settings.model); + const authoritativeContextWindow = this.authoritativeContextWindow?.query === this.persistentQuery + && this.authoritativeContextWindow.model === intendedModel + ? this.authoritativeContextWindow.contextWindow + : undefined; + return { + intendedModel, + customContextLimits: settings.customContextLimits, + authoritativeContextWindow, + streamState, + usageState, + }; + } + + /** + * Routes an SDK message to the active response handler. + * + * Design: Only one handler exists at a time because MessageChannel enforces + * single-turn processing. When a turn is active, new messages are queued/merged. + * The next message only dequeues after onTurnComplete(), which calls onDone() + * on the current handler. A new handler is registered only when the next query starts. + */ + private async routeMessage(message: SDKMessage): Promise { + // Note: Session expiration errors are handled in catch blocks (queryViaSDK, handleAbort) + // The SDK throws errors as exceptions, not as message types + + // Safe to use last handler - design guarantees single handler at a time + const handler = this.responseHandlers[this.responseHandlers.length - 1]; + const autoTurnBufferStartLength = this._autoTurnBuffer.length; + + // Transform SDK message to StreamChunks + for (const event of transformSDKMessage(message, this.getTransformOptions())) { + this.noteVisibleStreamContent(message, event, { + onText: () => { + if (handler) { + handler.markStreamTextSeen(); + } else { + this._autoTurnSawStreamText = true; + } + }, + onThinking: () => { + if (handler) { + handler.markStreamThinkingSeen(); + } else { + this._autoTurnSawStreamThinking = true; + } + }, + }); + + if (isSessionInitEvent(event)) { + // Fork: suppress needsHistoryRebuild since SDK returns a different session ID by design + const wasFork = this.pendingForkSession; + this.sessionManager.captureSession(event.sessionId); + if (wasFork) { + this.sessionManager.clearHistoryRebuild(); + this.pendingForkSession = false; + } + this.messageChannel?.setSessionId(event.sessionId); + if (event.agents) { + try { this.getAgentManager()?.setBuiltinAgentNames(event.agents); } catch { /* non-critical */ } + } + if (event.permissionMode && this.permissionModeSyncCallback) { + try { this.permissionModeSyncCallback(event.permissionMode); } catch { /* non-critical */ } + } + // Cache SDK commands on init (SDK already scans the vault). + // Pass the current query instance so late completions from a dead query + // cannot overwrite the active cache after a restart or shutdown. + void this.fetchAndCacheCommands(this.persistentQuery); + } else if (isContextWindowEvent(event)) { + this.rememberResultContextWindow(event.contextWindow); + const usageChunk = this.updateBufferedUsageContextWindow(event.contextWindow); + if (!usageChunk) { + continue; + } + if (handler) { + handler.onChunk(usageChunk); + } else { + this._autoTurnBuffer.push(usageChunk); + } + } else if (isStreamChunk(event)) { + // Dedup: SDK delivers text via stream_events (incremental) AND the assistant message + // (complete). Skip the assistant message text if stream text was already seen. + if (message.type === 'assistant' && event.type === 'text') { + if (handler?.sawStreamText || (!handler && this._autoTurnSawStreamText)) { + continue; + } + } + if (message.type === 'assistant' && event.type === 'thinking') { + if (handler?.sawStreamThinking || (!handler && this._autoTurnSawStreamThinking)) { + continue; + } + } + + // SDK auto-approves EnterPlanMode (checkPermissions → allow), + // so canUseTool is never called. Detect the tool_use in the stream + // and fire the sync callback to update the UI. + if (event.type === 'tool_use' && event.name === TOOL_ENTER_PLAN_MODE) { + if (this.currentConfig) { + this.currentConfig.permissionMode = 'plan'; + this.currentConfig.sdkPermissionMode = 'plan'; + } + if (this.permissionModeSyncCallback) { + try { this.permissionModeSyncCallback('plan'); } catch { /* non-critical */ } + } + } + + const normalizedChunk = event.type === 'usage' + ? this.bufferUsageChunk({ ...event, sessionId: this.sessionManager.getSessionId() }) + : event; + + if (handler) { + handler.onChunk(normalizedChunk); + } else { + // No handler — buffer for auto-triggered turn (e.g., task-notification delivery) + this._autoTurnBuffer.push(normalizedChunk); + } + } + } + + if ( + !handler + && message.type === 'system' + && message.subtype === 'task_notification' + && this._autoTurnBuffer.length > autoTurnBufferStartLength + ) { + await this.flushAutoTurnBuffer(); + } + + if (message.type === 'assistant' && message.uuid) { + this.recordTurnMetadata({ assistantMessageId: message.uuid }); + } + + // Check for turn completion + if (isTurnCompleteMessage(message)) { + // Signal turn complete to message channel + this.messageChannel?.onTurnComplete(); + + // Notify handler + if (handler) { + handler.resetStreamText(); + handler.resetStreamThinking(); + handler.onDone(); + } else { + await this.flushAutoTurnBuffer(); + } + } + } + + private async flushAutoTurnBuffer(): Promise { + this._autoTurnSawStreamText = false; + this._autoTurnSawStreamThinking = false; + if (this._autoTurnBuffer.length === 0) { + return; + } + + // Flush buffered chunks from auto-triggered turn (no handler was registered) + const chunks = [...this._autoTurnBuffer]; + const metadata = this.consumeTurnMetadata(); + this._autoTurnBuffer = []; + try { + await this._autoTurnCallback?.({ chunks, metadata }); + } catch { + new Notice('Background task completed, but the result could not be rendered.'); + } + } + + private registerResponseHandler(handler: ResponseHandler): void { + this.responseHandlers.push(handler); + } + + private unregisterResponseHandler(handlerId: string): void { + const idx = this.responseHandlers.findIndex(h => h.id === handlerId); + if (idx >= 0) { + this.responseHandlers.splice(idx, 1); + } + } + + private buildLegacyTurnRequest( + prompt: string, + images?: ImageAttachment[], + queryOptions?: QueryOptions, + ): ChatTurnRequest { + return { + text: prompt, + images, + externalContextPaths: queryOptions?.externalContextPaths, + enabledMcpServers: queryOptions?.enabledMcpServers, + }; + } + + private buildQueryOptionsFromTurnRequest( + request: ChatTurnRequest, + encodedTurn: PreparedChatTurn, + legacyQueryOptions?: QueryOptions, + ): QueryOptions | undefined { + const mcpMentions = legacyQueryOptions?.mcpMentions + ? new Set([...legacyQueryOptions.mcpMentions, ...encodedTurn.mcpMentions]) + : encodedTurn.mcpMentions; + + const effectiveQueryOptions: QueryOptions = { + allowedTools: legacyQueryOptions?.allowedTools, + model: legacyQueryOptions?.model, + mcpMentions, + enabledMcpServers: request.enabledMcpServers ?? legacyQueryOptions?.enabledMcpServers, + forceColdStart: legacyQueryOptions?.forceColdStart, + externalContextPaths: request.externalContextPaths ?? legacyQueryOptions?.externalContextPaths, + }; + + if ( + effectiveQueryOptions.allowedTools === undefined && + effectiveQueryOptions.model === undefined && + effectiveQueryOptions.enabledMcpServers === undefined && + effectiveQueryOptions.forceColdStart === undefined && + effectiveQueryOptions.externalContextPaths === undefined && + (effectiveQueryOptions.mcpMentions?.size ?? 0) === 0 + ) { + return undefined; + } + + return effectiveQueryOptions; + } + + private normalizeTurnInvocation( + turnOrPrompt: PreparedChatTurn | string, + imagesOrHistory?: ImageAttachment[] | ChatMessage[], + conversationHistoryOrQueryOptions?: ChatMessage[] | QueryOptions, + legacyQueryOptions?: QueryOptions, + ): { + request: ChatTurnRequest; + encodedTurn: PreparedChatTurn; + conversationHistory?: ChatMessage[]; + queryOptions?: QueryOptions; + } { + if (typeof turnOrPrompt !== 'string') { + const turn = turnOrPrompt; + const conversationHistory = isChatMessageArray(imagesOrHistory) + ? imagesOrHistory + : undefined; + const explicitQueryOptions = isChatMessageArray(conversationHistoryOrQueryOptions) + ? undefined + : conversationHistoryOrQueryOptions; + return { + request: turn.request, + encodedTurn: turn, + conversationHistory, + queryOptions: this.buildQueryOptionsFromTurnRequest(turn.request, turn, explicitQueryOptions), + }; + } + + const images = isImageAttachmentArray(imagesOrHistory) ? imagesOrHistory : undefined; + const conversationHistory = isChatMessageArray(conversationHistoryOrQueryOptions) + ? conversationHistoryOrQueryOptions + : undefined; + const queryOptions = isChatMessageArray(conversationHistoryOrQueryOptions) + ? legacyQueryOptions + : conversationHistoryOrQueryOptions ?? legacyQueryOptions; + const request = this.buildLegacyTurnRequest(turnOrPrompt, images, queryOptions); + const encodedTurn = this.prepareTurn(request); + + return { + request, + encodedTurn, + conversationHistory, + queryOptions: this.buildQueryOptionsFromTurnRequest(request, encodedTurn, queryOptions), + }; + } + + isPersistentQueryActive(): boolean { + return this.persistentQuery !== null && !this.shuttingDown; + } + + /** + * Sends a query to Claude and streams the response. + * + * Query selection: + * - Persistent query: default chat conversation + * - Cold-start query: only when forceColdStart is set + */ + query( + turn: PreparedChatTurn, + conversationHistory?: ChatMessage[], + queryOptions?: QueryOptions, + ): AsyncGenerator; + query( + prompt: string, + images?: ImageAttachment[], + conversationHistory?: ChatMessage[], + queryOptions?: QueryOptions, + ): AsyncGenerator; + async *query( + turnOrPrompt: PreparedChatTurn | string, + imagesOrHistory?: ImageAttachment[] | ChatMessage[], + conversationHistoryOrQueryOptions?: ChatMessage[] | QueryOptions, + legacyQueryOptions?: QueryOptions, + ): AsyncGenerator { + const normalized = this.normalizeTurnInvocation( + turnOrPrompt, + imagesOrHistory, + conversationHistoryOrQueryOptions, + legacyQueryOptions, + ); + const prompt = normalized.encodedTurn.prompt; + const images = normalized.request.images; + const conversationHistory = normalized.conversationHistory; + const queryOptions = normalized.queryOptions; + if (queryOptions?.model) { + this.setCurrentConversationModel(queryOptions.model); + } + + const vaultPath = getVaultPath(this.plugin.app); + if (!vaultPath) { + yield { type: 'error', content: 'Could not determine vault path' }; + return; + } + + const resolvedClaudePath = this.plugin.getResolvedProviderCliPath('claude'); + if (!resolvedClaudePath) { + yield { type: 'error', content: 'Claude CLI not found. Please install Claude Code CLI.' }; + return; + } + + const customEnv = parseEnvironmentVariables(this.plugin.getActiveEnvironmentVariables(this.providerId)); + const enhancedPath = getEnhancedPath(customEnv.PATH, resolvedClaudePath); + const missingNodeError = getMissingNodeError(resolvedClaudePath, enhancedPath); + if (missingNodeError) { + yield { type: 'error', content: missingNodeError }; + return; + } + + // Rebuild history if needed before choosing persistent vs cold-start + let promptToSend = prompt; + let forceColdStart = false; + + // Clear interrupted flag - persistent query handles interruption gracefully, + // no need to force cold-start just because user cancelled previous response + if (this.sessionManager.wasInterrupted()) { + this.sessionManager.clearInterrupted(); + } + + // Session mismatch recovery: SDK returned a different session ID (context lost) + // Inject history to restore context without forcing cold-start + if (this.sessionManager.needsHistoryRebuild() && conversationHistory && conversationHistory.length > 0) { + const historyContext = buildContextFromHistory(conversationHistory); + const actualPrompt = stripCurrentNoteContext(prompt); + promptToSend = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, conversationHistory); + this.sessionManager.clearHistoryRebuild(); + } + + const noSessionButHasHistory = !this.sessionManager.getSessionId() && + conversationHistory && conversationHistory.length > 0; + + if (noSessionButHasHistory) { + const historyContext = buildContextFromHistory(conversationHistory); + const actualPrompt = stripCurrentNoteContext(prompt); + promptToSend = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, conversationHistory); + + // Note: Do NOT call invalidateSession() here. The cold-start will capture + // a new session ID anyway, and invalidating would break any persistent query + // restart that happens during the cold-start (causing SESSION MISMATCH). + forceColdStart = true; + } + + const effectiveQueryOptions = forceColdStart + ? { ...queryOptions, forceColdStart: true } + : queryOptions; + + if (forceColdStart) { + // Set flag BEFORE closing to prevent consumer error from triggering restart + this.coldStartInProgress = true; + this.closePersistentQuery('session invalidated'); + } + + // Determine query path: persistent vs cold-start + const shouldUsePersistent = !effectiveQueryOptions?.forceColdStart; + + if (shouldUsePersistent) { + // Start persistent query if not running + if (!this.persistentQuery && !this.shuttingDown) { + await this.startPersistentQuery( + vaultPath, + resolvedClaudePath, + this.sessionManager.getSessionId() ?? undefined + ); + } + + if (this.persistentQuery && !this.shuttingDown) { + // Use persistent query path + try { + yield* this.queryViaPersistent(promptToSend, images, vaultPath, resolvedClaudePath, effectiveQueryOptions); + return; + } catch (error) { + if (isSessionExpiredError(error) && conversationHistory && conversationHistory.length > 0) { + this.sessionManager.invalidateSession(); + const retryRequest = this.buildHistoryRebuildRequest(prompt, conversationHistory); + + this.coldStartInProgress = true; + this.abortController = new AbortController(); + + try { + yield* this.queryViaSDK( + retryRequest.prompt, + vaultPath, + resolvedClaudePath, + // Use current message's images, fallback to history images + images ?? retryRequest.images, + effectiveQueryOptions + ); + } catch (retryError) { + const missingSessionChunk = this.toProviderSessionMissingChunk(retryError); + yield missingSessionChunk ?? { + type: 'error', + content: retryError instanceof Error ? retryError.message : 'Unknown error', + }; + } finally { + this.coldStartInProgress = false; + this.abortController = null; + } + return; + } + + const missingSessionChunk = this.toProviderSessionMissingChunk(error); + if (missingSessionChunk) { + yield missingSessionChunk; + return; + } + + throw error; + } + } + } + + // Cold-start path (existing logic) + // Set flag to prevent consumer error restarts from interfering + this.coldStartInProgress = true; + this.abortController = new AbortController(); + + try { + yield* this.queryViaSDK(promptToSend, vaultPath, resolvedClaudePath, images, effectiveQueryOptions); + } catch (error) { + if (isSessionExpiredError(error) && conversationHistory && conversationHistory.length > 0) { + this.sessionManager.invalidateSession(); + const retryRequest = this.buildHistoryRebuildRequest(prompt, conversationHistory); + + try { + yield* this.queryViaSDK( + retryRequest.prompt, + vaultPath, + resolvedClaudePath, + // Use current message's images, fallback to history images + images ?? retryRequest.images, + effectiveQueryOptions + ); + } catch (retryError) { + const missingSessionChunk = this.toProviderSessionMissingChunk(retryError); + yield missingSessionChunk ?? { + type: 'error', + content: retryError instanceof Error ? retryError.message : 'Unknown error', + }; + } + return; + } + + const missingSessionChunk = this.toProviderSessionMissingChunk(error); + if (missingSessionChunk) { + yield missingSessionChunk; + return; + } + + const msg = error instanceof Error ? error.message : 'Unknown error'; + yield { type: 'error', content: msg }; + } finally { + this.coldStartInProgress = false; + this.abortController = null; + } + } + + private buildHistoryRebuildRequest( + prompt: string, + conversationHistory: ChatMessage[] + ): { prompt: string; images?: ImageAttachment[] } { + const historyContext = buildContextFromHistory(conversationHistory); + const actualPrompt = stripCurrentNoteContext(prompt); + const fullPrompt = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, conversationHistory); + const lastUserMessage = getLastUserMessage(conversationHistory); + + return { + prompt: fullPrompt, + images: lastUserMessage?.images, + }; + } + + /** + * Query via persistent query (Phase 1.5). + * Uses the message channel to send messages without cold-start latency. + */ + private async *queryViaPersistent( + prompt: string, + images: ImageAttachment[] | undefined, + vaultPath: string, + cliPath: string, + queryOptions?: QueryOptions + ): AsyncGenerator { + this.resetTurnMetadata(); + + if (!this.persistentQuery || !this.messageChannel) { + // Fallback to cold-start if persistent query not available + yield* this.queryViaSDK(prompt, vaultPath, cliPath, images, queryOptions); + return; + } + + // Set allowed tools for canUseTool enforcement + // undefined = no restriction, [] = no tools, [...] = restricted + if (queryOptions?.allowedTools !== undefined) { + this.currentAllowedTools = queryOptions.allowedTools.length > 0 + ? [...queryOptions.allowedTools, TOOL_SKILL] + : []; + } else { + this.currentAllowedTools = null; + } + + // Save allowedTools before applyDynamicUpdates - restart would clear it + const savedAllowedTools = this.currentAllowedTools; + + // Apply dynamic updates before sending (Phase 1.6) + await this.applyDynamicUpdates(queryOptions); + + // Restore allowedTools in case restart cleared it + this.currentAllowedTools = savedAllowedTools; + + // Check if applyDynamicUpdates triggered a restart that failed + // (e.g., CLI path not found, vault path missing) + if (!this.persistentQuery || !this.messageChannel) { + yield* this.queryViaSDK(prompt, vaultPath, cliPath, images, queryOptions); + return; + } + if (!this.responseConsumerRunning) { + yield* this.queryViaSDK(prompt, vaultPath, cliPath, images, queryOptions); + return; + } + + void this.refreshAuthoritativeContextWindow(); + + const message = this.buildSDKUserMessage(prompt, images); + + // Create a promise-based handler to yield chunks + // Use a mutable state object to work around TypeScript's control flow analysis + const state = { + chunks: [] as StreamChunk[], + resolveChunk: null as ((chunk: StreamChunk | null) => void) | null, + done: false, + error: null as Error | null, + }; + + const handlerId = `handler-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const handler = createResponseHandler({ + id: handlerId, + onChunk: (chunk) => { + handler.markChunkSeen(); + if (state.resolveChunk) { + state.resolveChunk(chunk); + state.resolveChunk = null; + } else { + state.chunks.push(chunk); + } + }, + onDone: () => { + state.done = true; + if (state.resolveChunk) { + state.resolveChunk(null); + state.resolveChunk = null; + } + }, + onError: (err) => { + state.error = err; + state.done = true; + if (state.resolveChunk) { + state.resolveChunk(null); + state.resolveChunk = null; + } + }, + }); + + this.registerResponseHandler(handler); + + try { + // Track message for crash recovery (Phase 1.3) + this.lastSentMessage = message; + this.lastSentQueryOptions = queryOptions ?? null; + this.crashRecoveryAttempted = false; + + // Enqueue the message with race condition protection + // The channel could close between our null check above and this call + try { + this.messageChannel.enqueue(message); + } catch (error) { + if (error instanceof Error && error.message.includes('closed')) { + yield* this.queryViaSDK(prompt, vaultPath, cliPath, images, queryOptions); + return; + } + throw error; + } + this.recordTurnMetadata({ + userMessageId: message.uuid ?? undefined, + wasSent: true, + }); + + // Yield chunks as they arrive + while (!state.done) { + if (state.chunks.length > 0) { + yield state.chunks.shift()!; + } else { + const chunk = await new Promise((resolve) => { + state.resolveChunk = resolve; + }); + if (chunk) { + yield chunk; + } + } + } + + // Yield any remaining chunks + while (state.chunks.length > 0) { + yield state.chunks.shift()!; + } + + // Check if an error occurred (assigned in onError callback) + if (state.error) { + // Re-throw session expired errors for outer retry logic to handle + if (isSessionExpiredError(state.error)) { + throw state.error; + } + yield { type: 'error', content: state.error.message }; + } + + // Clear message tracking after completion + this.lastSentMessage = null; + this.lastSentQueryOptions = null; + + yield { type: 'done' }; + } finally { + this.unregisterResponseHandler(handlerId); + this.currentAllowedTools = null; + } + } + + private buildSDKUserMessage(prompt: string, images?: ImageAttachment[]): SDKUserMessage { + return buildClaudeSDKUserMessage( + prompt, + this.sessionManager.getSessionId() || '', + images, + ); + } + + /** + * Apply dynamic updates to the persistent query before sending a message (Phase 1.6). + */ + private async applyDynamicUpdates( + queryOptions?: QueryOptions, + restartOptions?: ClosePersistentQueryOptions, + allowRestart = true + ): Promise { + await applyClaudeDynamicUpdates( + { + getPersistentQuery: () => this.persistentQuery, + getCurrentConfig: () => this.currentConfig, + mutateCurrentConfig: (mutate) => { + if (this.currentConfig) { + mutate(this.currentConfig); + } + }, + getVaultPath: () => this.vaultPath, + getCliPath: () => this.plugin.getResolvedProviderCliPath('claude'), + getScopedSettings: () => this.getScopedSettings(), + getPermissionMode: () => this.plugin.settings.permissionMode, + resolveSDKPermissionMode: (mode) => this.resolveSDKPermissionMode(mode), + mcpManager: this.mcpManager, + buildPersistentQueryConfig: (vaultPath, cliPath, externalContextPaths) => + this.buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths), + needsRestart: (newConfig) => this.needsRestart(newConfig), + ensureReady: (options) => this.ensureReady(options), + setCurrentExternalContextPaths: (paths) => { + this.currentExternalContextPaths = paths; + }, + notifyFailure: (message) => { + new Notice(message); + }, + }, + queryOptions, + restartOptions, + allowRestart, + ); + } + + private noteVisibleStreamContent( + message: SDKMessage, + event: TransformEvent, + callbacks: { onText: () => void; onThinking: () => void }, + ): void { + // Drive dedup off transformed chunks rather than raw SDK message shapes. + // transformSDKMessage already filters out empty payloads and subagent-only + // stream events, so these callbacks only fire for content the user can see. + if (message.type !== 'stream_event') { + return; + } + + if (event.type === 'text') { + callbacks.onText(); + } else if (event.type === 'thinking') { + callbacks.onThinking(); + } + } + + private buildPromptWithImages(prompt: string, images?: ImageAttachment[]): ReturnType { + return buildClaudePromptWithImages(prompt, images); + } + + private async *queryViaSDK( + prompt: string, + cwd: string, + cliPath: string, + images?: ImageAttachment[], + queryOptions?: QueryOptions + ): AsyncGenerator { + this.resetTurnMetadata(); + const selectedModel = toClaudeRuntimeModelId(queryOptions?.model || this.getScopedSettings().model); + + this.sessionManager.setPendingModel(selectedModel); + this.vaultPath = cwd; + + const queryPrompt = this.buildPromptWithImages(prompt, images); + const baseContext = this.buildQueryOptionsContext(cwd, cliPath); + const externalContextPaths = queryOptions?.externalContextPaths || []; + const hooks = this.buildHooks(); + const hasEditorContext = prompt.includes(' 0) { + const toolSet = new Set([...queryOptions.allowedTools, TOOL_SKILL]); + allowedTools = [...toolSet]; + } + + const ctx: ColdStartQueryContext = { + ...baseContext, + abortController: this.abortController ?? undefined, + sessionId: this.sessionManager.getSessionId() ?? undefined, + modelOverride: queryOptions?.model, + canUseTool: this.createApprovalCallback(), + hooks, + mcpMentions: queryOptions?.mcpMentions, + enabledMcpServers: queryOptions?.enabledMcpServers, + allowedTools, + hasEditorContext, + externalContextPaths, + }; + + const options = QueryOptionsBuilder.buildColdStartQueryOptions(ctx); + + let sawStreamText = false; + let sawStreamThinking = false; + const streamState = createTransformStreamState(); + const usageState = createTransformUsageState(); + try { + const response = agentQuery({ prompt: queryPrompt, options }); + this.recordTurnMetadata({ wasSent: true }); + let streamSessionId: string | null = this.sessionManager.getSessionId(); + + for await (const message of response) { + if (this.abortController?.signal.aborted) { + await response.interrupt(); + break; + } + + for (const event of transformSDKMessage(message, this.getTransformOptions(selectedModel, streamState, usageState))) { + this.noteVisibleStreamContent(message, event, { + onText: () => { + sawStreamText = true; + }, + onThinking: () => { + sawStreamThinking = true; + }, + }); + + if (isSessionInitEvent(event)) { + this.sessionManager.captureSession(event.sessionId); + streamSessionId = event.sessionId; + } else if (isContextWindowEvent(event)) { + const usageChunk = this.updateBufferedUsageContextWindow(event.contextWindow); + if (usageChunk) { + yield usageChunk; + } + } else if (isStreamChunk(event)) { + if (message.type === 'assistant' && sawStreamText && event.type === 'text') { + continue; + } + if (message.type === 'assistant' && sawStreamThinking && event.type === 'thinking') { + continue; + } + if (event.type === 'usage') { + yield this.bufferUsageChunk({ ...event, sessionId: streamSessionId }); + } else { + yield event; + } + } + } + + if (message.type === 'assistant' && message.uuid) { + this.recordTurnMetadata({ assistantMessageId: message.uuid }); + } + + if (message.type === 'result') { + sawStreamText = false; + sawStreamThinking = false; + } + } + } catch (error) { + // Re-throw session expired errors for outer retry logic to handle + if (isSessionExpiredError(error)) { + throw error; + } + const msg = error instanceof Error ? error.message : 'Unknown error'; + yield { type: 'error', content: msg }; + } finally { + this.sessionManager.clearPendingModel(); + this.currentAllowedTools = null; // Clear tool restriction after query + } + + yield { type: 'done' }; + } + + cancel() { + this.approvalDismisser?.(); + + if (this.abortController) { + this.abortController.abort(); + this.sessionManager.markInterrupted(); + } + + // Interrupt persistent query (Phase 1.9) + if (this.persistentQuery && !this.shuttingDown) { + void this.persistentQuery.interrupt().catch(() => { + // Silence abort/interrupt errors + }); + } + } + + /** + * Reset the conversation session. + * Closes the persistent query since session is changing. + */ + resetSession() { + // Close persistent query (new session will use cold-start resume) + this.closePersistentQuery('session reset'); + + // Reset crash recovery for fresh start + this.crashRecoveryAttempted = false; + + this.sessionManager.reset(); + } + + getSessionId(): string | null { + return this.sessionManager.getSessionId(); + } + + /** Consume session invalidation flag for persistence updates. */ + consumeSessionInvalidation(): boolean { + return this.sessionManager.consumeInvalidation(); + } + + /** + * Check if the service is ready (persistent query is active). + * Used to determine if SDK skills are available. + */ + isReady(): boolean { + return this.isPersistentQueryActive(); + } + + /** + * Get supported commands (SDK skills). + * Returns cached commands populated on system/init. Falls back to a fresh + * supportedCommands() call if the cache is empty (e.g., dropdown opened + * before the first init event). + */ + async getSupportedCommands(): Promise { + if (this.cachedSdkCommands.length > 0) { + return this.cachedSdkCommands; + } + if (!this.persistentQuery) { + return []; + } + return this.fetchAndCacheCommands(this.persistentQuery); + } + + /** + * Fetches commands from the SDK and caches them. Called on system/init + * (fire-and-forget) and as a fallback from getSupportedCommands(). + */ + private async fetchAndCacheCommands(query: Query | null): Promise { + if (!query) return []; + try { + const sdkCommands: SDKSlashCommand[] = await query.supportedCommands(); + const mappedCommands = sdkCommands.map((cmd) => ({ + id: `sdk:${cmd.name}`, + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + content: '', + source: 'sdk' as const, + })); + if (this.persistentQuery !== query) { + return this.cachedSdkCommands; + } + this.cachedSdkCommands = mappedCommands; + return this.cachedSdkCommands; + } catch { + return []; + } + } + + /** + * Set the session ID (for restoring from saved conversation). + * Closes persistent query synchronously if session is changing, then ensures query is ready. + * + * @param id - Session ID to restore, or null for new session + * @param externalContextPaths - External context paths for the session (prevents stale contexts) + */ + setSessionId(id: string | null, externalContextPaths?: string[]): void { + const currentId = this.sessionManager.getSessionId(); + const sessionChanged = currentId !== id; + + // Close synchronously when session changes + if (sessionChanged) { + this.closePersistentQuery('session switch'); + this.crashRecoveryAttempted = false; + } + + this.sessionManager.setSessionId(id, this.getScopedSettings().model); + + // Track external context paths for when the runtime starts on demand + if (externalContextPaths !== undefined) { + this.currentExternalContextPaths = externalContextPaths; + } + + // Passive: do NOT call ensureReady() here. + // Runtime starts on demand when query() is called. + } + + /** + * Cleanup resources (Phase 5). + * Called on plugin unload to close persistent query and abort any cold-start query. + */ + cleanup() { + // Close persistent query + this.closePersistentQuery('plugin cleanup'); + + // Cancel any in-flight cold-start query + this.cancel(); + this.resetSession(); + } + + async rewindFiles(userMessageId: string, dryRun?: boolean): Promise { + if (!this.persistentQuery) throw new Error('No active query'); + if (this.shuttingDown) throw new Error('Service is shutting down'); + return this.persistentQuery.rewindFiles(userMessageId, { dryRun }); + } + + async rewind( + userMessageId: string, + assistantMessageId: string | undefined, + mode: ChatRewindMode = 'code-and-conversation', + ): Promise { + return executeClaudeRewind(userMessageId, { + assistantMessageId, + mode, + rewindFiles: (id, dryRun) => this.rewindFiles(id, dryRun), + closePersistentQuery: (reason) => this.closePersistentQuery(reason), + setPendingResumeAt: (resumeAt) => { + this.pendingResumeAt = resumeAt; + }, + resetSession: () => this.resetSession(), + vaultPath: this.vaultPath, + }); + } + + setApprovalCallback(callback: ApprovalCallback | null) { + this.approvalCallback = callback; + } + + setApprovalDismisser(dismisser: (() => void) | null) { + this.approvalDismisser = dismisser; + } + + setAskUserQuestionCallback(callback: AskUserQuestionCallback | null) { + this.askUserQuestionCallback = callback; + } + + setExitPlanModeCallback(callback: ExitPlanModeCallback | null): void { + this.exitPlanModeCallback = callback; + } + + setPermissionModeSyncCallback(callback: ((sdkMode: string) => void) | null): void { + this.permissionModeSyncCallback = callback; + } + + setSubagentHookProvider(getState: () => SubagentHookState): void { + this._subagentStateProvider = getState; + } + + setAutoTurnCallback(callback: AutoTurnCallback | null): void { + this._autoTurnCallback = callback; + } + + private createApprovalCallback(): CanUseTool { + return createClaudeApprovalCallback({ + getAllowedTools: () => this.currentAllowedTools, + getApprovalCallback: () => this.approvalCallback, + getAskUserQuestionCallback: () => this.askUserQuestionCallback, + getExitPlanModeCallback: () => this.exitPlanModeCallback, + getPermissionMode: () => this.plugin.settings.permissionMode, + resolveSDKPermissionMode: (mode) => this.resolveSDKPermissionMode(mode), + syncPermissionMode: (mode, sdkMode) => { + if (this.currentConfig) { + this.currentConfig.permissionMode = mode; + this.currentConfig.sdkPermissionMode = sdkMode; + } + }, + notifyAlwaysAppliedOnce: () => { + new Notice('Always approval could only be applied once because no permission scope was available.'); + }, + }); + } + + private resolveSDKPermissionMode(mode: PermissionMode): SDKPermissionMode { + return QueryOptionsBuilder.resolveClaudeSdkPermissionMode( + mode, + getClaudeProviderSettings(this.plugin.settings).safeMode, + ); + } +} diff --git a/src/providers/claude/runtime/ClaudeCliResolver.ts b/src/providers/claude/runtime/ClaudeCliResolver.ts new file mode 100644 index 0000000..b568db1 --- /dev/null +++ b/src/providers/claude/runtime/ClaudeCliResolver.ts @@ -0,0 +1,94 @@ +import * as fs from 'fs'; + +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import type { HostnameCliPaths } from '../../../core/types/settings'; +import { getHostnameKey, parseEnvironmentVariables } from '../../../utils/env'; +import { expandHomePath } from '../../../utils/path'; +import { findClaudeCLIPath } from '../cli/findClaudeCLIPath'; +import { getClaudeProviderSettings } from '../settings'; + +export class ClaudeCliResolver { + private resolvedPath: string | null = null; + private lastHostnamePath = ''; + private lastLegacyPath = ''; + private lastEnvText = ''; + private readonly cachedHostname = getHostnameKey(); + + /** + * Resolves CLI path with priority: device-specific -> legacy -> auto-detect. + * @param settings Full app settings bag + */ + resolveFromSettings(settings: Record): string | null { + const hostnameKey = this.cachedHostname; + const claudeSettings = getClaudeProviderSettings(settings); + + const hostnamePath = (claudeSettings.cliPathsByHost[hostnameKey] ?? '').trim(); + const normalizedLegacy = claudeSettings.cliPath.trim(); + const normalizedEnv = getRuntimeEnvironmentText(settings, 'claude'); + + if ( + this.resolvedPath && + hostnamePath === this.lastHostnamePath && + normalizedLegacy === this.lastLegacyPath && + normalizedEnv === this.lastEnvText + ) { + return this.resolvedPath; + } + + this.lastHostnamePath = hostnamePath; + this.lastLegacyPath = normalizedLegacy; + this.lastEnvText = normalizedEnv; + + this.resolvedPath = resolveClaudeCliPath(hostnamePath, normalizedLegacy, normalizedEnv); + return this.resolvedPath; + } + + resolve( + hostnamePaths: HostnameCliPaths | undefined, + legacyPath: string | undefined, + envText: string, + ): string | null { + return this.resolveFromSettings({ + sharedEnvironmentVariables: envText, + providerConfigs: { + claude: { + cliPath: legacyPath ?? '', + cliPathsByHost: hostnamePaths ?? {}, + }, + }, + }); + } + + reset(): void { + this.resolvedPath = null; + this.lastHostnamePath = ''; + this.lastLegacyPath = ''; + this.lastEnvText = ''; + } +} + +function resolveConfiguredPath(rawPath: string | undefined): string | null { + const trimmed = (rawPath ?? '').trim(); + if (!trimmed) return null; + try { + const expanded = expandHomePath(trimmed); + if (fs.existsSync(expanded) && fs.statSync(expanded).isFile()) { + return expanded; + } + } catch { + // Fall through + } + return null; +} + +export function resolveClaudeCliPath( + hostnamePath: string | undefined, + legacyPath: string | undefined, + envText: string, +): string | null { + return ( + resolveConfiguredPath(hostnamePath) ?? + resolveConfiguredPath(legacyPath) ?? + findClaudeCLIPath(parseEnvironmentVariables(envText || '').PATH) + ); +} diff --git a/src/providers/claude/runtime/ClaudeDynamicUpdates.ts b/src/providers/claude/runtime/ClaudeDynamicUpdates.ts new file mode 100644 index 0000000..d6913d9 --- /dev/null +++ b/src/providers/claude/runtime/ClaudeDynamicUpdates.ts @@ -0,0 +1,164 @@ +import type { + McpServerConfig, + PermissionMode as SDKPermissionMode, + Query, +} from '@anthropic-ai/claude-agent-sdk'; + +import type { McpServerManager } from '../../../core/mcp/McpServerManager'; +import type { + ChatRuntimeQueryOptions, +} from '../../../core/runtime/types'; +import type { ClaudianSettings, PermissionMode } from '../../../core/types/settings'; +import { toClaudeRuntimeModelId } from '../modelSelection'; +import { + resolveEffortLevel, +} from '../types/models'; +import type { + ClaudeEnsureReadyOptions, + ClosePersistentQueryOptions, + PersistentQueryConfig, +} from './types'; + +export interface ClaudeDynamicUpdateDeps { + getPersistentQuery: () => Query | null; + getCurrentConfig: () => PersistentQueryConfig | null; + mutateCurrentConfig: (mutate: (config: PersistentQueryConfig) => void) => void; + getVaultPath: () => string | null; + getCliPath: () => string | null; + getScopedSettings: () => ClaudianSettings; + getPermissionMode: () => PermissionMode; + resolveSDKPermissionMode: (mode: PermissionMode) => SDKPermissionMode; + mcpManager: McpServerManager; + buildPersistentQueryConfig: ( + vaultPath: string, + cliPath: string, + externalContextPaths?: string[], + ) => PersistentQueryConfig; + needsRestart: (newConfig: PersistentQueryConfig) => boolean; + ensureReady: (options: ClaudeEnsureReadyOptions) => Promise; + setCurrentExternalContextPaths: (paths: string[]) => void; + notifyFailure: (message: string) => void; +} + +export async function applyClaudeDynamicUpdates( + deps: ClaudeDynamicUpdateDeps, + queryOptions?: ChatRuntimeQueryOptions, + restartOptions?: ClosePersistentQueryOptions, + allowRestart = true, +): Promise { + const persistentQuery = deps.getPersistentQuery(); + if (!persistentQuery) { + return; + } + + const vaultPath = deps.getVaultPath(); + if (!vaultPath) { + return; + } + + const cliPath = deps.getCliPath(); + if (!cliPath) { + return; + } + + const settings = deps.getScopedSettings(); + const selectedModel = toClaudeRuntimeModelId(queryOptions?.model || settings.model); + const permissionMode = deps.getPermissionMode(); + + const currentConfig = deps.getCurrentConfig(); + if (currentConfig && selectedModel !== currentConfig.model) { + try { + await persistentQuery.setModel(selectedModel); + deps.mutateCurrentConfig(config => { + config.model = selectedModel; + }); + } catch { + deps.notifyFailure('Failed to update model'); + } + } + + const effortLevel = resolveEffortLevel(selectedModel, settings.effortLevel); + const currentEffort = deps.getCurrentConfig()?.effortLevel ?? null; + if (effortLevel !== currentEffort) { + try { + // SDK runtime accepts `max`, but the current type definition for + // Settings.effortLevel has not caught up yet. + await persistentQuery.applyFlagSettings({ effortLevel } as unknown as Parameters[0]); + deps.mutateCurrentConfig(config => { + config.effortLevel = effortLevel; + }); + } catch { + deps.notifyFailure('Failed to update effort level'); + } + } + + const configBeforePermissionUpdate = deps.getCurrentConfig(); + if (configBeforePermissionUpdate) { + const sdkMode = deps.resolveSDKPermissionMode(permissionMode); + const currentSdkMode = configBeforePermissionUpdate.sdkPermissionMode ?? null; + const requiresAutoModeRestart = sdkMode === 'auto' && !configBeforePermissionUpdate.enableAutoMode; + if (requiresAutoModeRestart) { + // The Claude Code auto-mode opt-in is a startup flag. The restart path below + // will rebuild the query with that capability before auto becomes active. + } else if (sdkMode !== currentSdkMode) { + try { + await persistentQuery.setPermissionMode(sdkMode); + deps.mutateCurrentConfig(config => { + config.permissionMode = permissionMode; + config.sdkPermissionMode = sdkMode; + }); + } catch { + deps.notifyFailure('Failed to update permission mode'); + } + } else { + deps.mutateCurrentConfig(config => { + config.permissionMode = permissionMode; + config.sdkPermissionMode = sdkMode; + }); + } + } + + const mcpMentions = queryOptions?.mcpMentions || new Set(); + const uiEnabledServers = queryOptions?.enabledMcpServers || new Set(); + const combinedMentions = new Set([...mcpMentions, ...uiEnabledServers]); + const mcpServers = deps.mcpManager.getActiveServers(combinedMentions); + const mcpServersKey = JSON.stringify(mcpServers); + + if (deps.getCurrentConfig() && mcpServersKey !== deps.getCurrentConfig()!.mcpServersKey) { + const serverConfigs: Record = {}; + for (const [name, config] of Object.entries(mcpServers)) { + serverConfigs[name] = config; + } + + try { + await persistentQuery.setMcpServers(serverConfigs); + deps.mutateCurrentConfig(config => { + config.mcpServersKey = mcpServersKey; + }); + } catch { + deps.notifyFailure('Failed to update MCP servers'); + } + } + + const newExternalContextPaths = queryOptions?.externalContextPaths || []; + deps.setCurrentExternalContextPaths(newExternalContextPaths); + + if (!allowRestart) { + return; + } + + const newConfig = deps.buildPersistentQueryConfig(vaultPath, cliPath, newExternalContextPaths); + if (!deps.needsRestart(newConfig)) { + return; + } + + const restarted = await deps.ensureReady({ + externalContextPaths: newExternalContextPaths, + preserveHandlers: restartOptions?.preserveHandlers, + force: true, + }); + + if (restarted && deps.getPersistentQuery()) { + await applyClaudeDynamicUpdates(deps, queryOptions, restartOptions, false); + } +} diff --git a/src/providers/claude/runtime/ClaudeMessageChannel.ts b/src/providers/claude/runtime/ClaudeMessageChannel.ts new file mode 100644 index 0000000..2f97247 --- /dev/null +++ b/src/providers/claude/runtime/ClaudeMessageChannel.ts @@ -0,0 +1,209 @@ +/** + * Message Channel + * + * Queue-based async iterable for persistent queries. + * Handles message queuing, turn management, and text merging. + */ + +import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; + +import { + MESSAGE_CHANNEL_CONFIG, + type PendingMessage, + type PendingTextMessage, +} from './types'; + +/** + * MessageChannel - Queue-based async iterable for persistent queries. + * + * Rules: + * - Single in-flight turn at a time + * - Text-only messages merge with \n\n while a turn is active + * - Attachment messages (with images) queue one at a time; newer replaces older while turn is active + * - Overflow policy: drop newest and warn + */ +export class MessageChannel implements AsyncIterable { + private queue: PendingMessage[] = []; + private turnActive = false; + private closed = false; + private resolveNext: ((value: IteratorResult) => void) | null = null; + private currentSessionId: string | null = null; + private onWarning: (message: string) => void; + + constructor(onWarning: (message: string) => void = () => {}) { + this.onWarning = onWarning; + } + + setSessionId(sessionId: string): void { + this.currentSessionId = sessionId; + } + + isTurnActive(): boolean { + return this.turnActive; + } + + isClosed(): boolean { + return this.closed; + } + + /** + * Enqueue a message. If a turn is active: + * - Text-only: merge with queued text (up to MAX_MERGED_CHARS) + * - With attachments: replace any existing queued attachment (one at a time) + */ + enqueue(message: SDKUserMessage): void { + if (this.closed) { + throw new Error('MessageChannel is closed'); + } + + const hasAttachments = this.messageHasAttachments(message); + + if (!this.turnActive) { + if (this.resolveNext) { + // Consumer is waiting - deliver immediately and mark turn active + this.turnActive = true; + const resolve = this.resolveNext; + this.resolveNext = null; + resolve({ value: message, done: false }); + } else { + // No consumer waiting yet - queue for later pickup by next() + // Don't set turnActive here; next() will set it when it dequeues + if (this.queue.length >= MESSAGE_CHANNEL_CONFIG.MAX_QUEUED_MESSAGES) { + this.onWarning(`[MessageChannel] Queue full (${MESSAGE_CHANNEL_CONFIG.MAX_QUEUED_MESSAGES}), dropping newest`); + return; + } + if (hasAttachments) { + this.queue.push({ type: 'attachment', message }); + } else { + this.queue.push({ type: 'text', content: this.extractTextContent(message) }); + } + } + return; + } + + // Turn is active - queue the message + if (hasAttachments) { + // Non-text messages are deferred as-is (one at a time) + // Find existing attachment message or add new one + const existingIdx = this.queue.findIndex(m => m.type === 'attachment'); + if (existingIdx >= 0) { + // Replace existing (newer takes precedence for attachments) + this.queue[existingIdx] = { type: 'attachment', message }; + this.onWarning('[MessageChannel] Attachment message replaced (only one can be queued)'); + } else { + this.queue.push({ type: 'attachment', message }); + } + return; + } + + // Text-only - merge with existing text in queue + const textContent = this.extractTextContent(message); + const existingTextIdx = this.queue.findIndex(m => m.type === 'text'); + + if (existingTextIdx >= 0) { + const existing = this.queue[existingTextIdx] as PendingTextMessage; + const mergedContent = existing.content + '\n\n' + textContent; + + // Check merged size + if (mergedContent.length > MESSAGE_CHANNEL_CONFIG.MAX_MERGED_CHARS) { + this.onWarning(`[MessageChannel] Merged content exceeds ${MESSAGE_CHANNEL_CONFIG.MAX_MERGED_CHARS} chars, dropping newest`); + return; + } + + existing.content = mergedContent; + } else { + // No existing text - add new + if (this.queue.length >= MESSAGE_CHANNEL_CONFIG.MAX_QUEUED_MESSAGES) { + this.onWarning(`[MessageChannel] Queue full (${MESSAGE_CHANNEL_CONFIG.MAX_QUEUED_MESSAGES}), dropping newest`); + return; + } + this.queue.push({ type: 'text', content: textContent }); + } + } + + onTurnComplete(): void { + this.turnActive = false; + + if (this.queue.length > 0 && this.resolveNext) { + const pending = this.queue.shift()!; + this.turnActive = true; + const resolve = this.resolveNext; + this.resolveNext = null; + resolve({ value: this.pendingToMessage(pending), done: false }); + } + } + + close(): void { + this.closed = true; + this.queue = []; + if (this.resolveNext) { + const resolve = this.resolveNext; + this.resolveNext = null; + resolve({ value: undefined, done: true } as IteratorResult); + } + } + + reset(): void { + this.queue = []; + this.turnActive = false; + this.closed = false; + this.resolveNext = null; + } + + getQueueLength(): number { + return this.queue.length; + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: (): Promise> => { + if (this.closed) { + return Promise.resolve({ value: undefined, done: true } as IteratorResult); + } + + // If there's a queued message and no active turn, return it + if (this.queue.length > 0 && !this.turnActive) { + const pending = this.queue.shift()!; + this.turnActive = true; + return Promise.resolve({ value: this.pendingToMessage(pending), done: false }); + } + + // Wait for next message + return new Promise((resolve) => { + this.resolveNext = resolve; + }); + }, + }; + } + + private messageHasAttachments(message: SDKUserMessage): boolean { + if (!message.message?.content) return false; + if (typeof message.message.content === 'string') return false; + return message.message.content.some((block: { type: string }) => block.type === 'image'); + } + + private extractTextContent(message: SDKUserMessage): string { + if (!message.message?.content) return ''; + if (typeof message.message.content === 'string') return message.message.content; + return message.message.content + .filter((block: { type: string }): block is { type: 'text'; text: string } => block.type === 'text') + .map((block: { type: 'text'; text: string }) => block.text) + .join('\n\n'); + } + + private pendingToMessage(pending: PendingMessage): SDKUserMessage { + if (pending.type === 'attachment') { + return pending.message; + } + + return { + type: 'user', + message: { + role: 'user', + content: pending.content, + }, + parent_tool_use_id: null, + session_id: this.currentSessionId || '', + }; + } +} diff --git a/src/providers/claude/runtime/ClaudeQueryOptionsBuilder.ts b/src/providers/claude/runtime/ClaudeQueryOptionsBuilder.ts new file mode 100644 index 0000000..ac83194 --- /dev/null +++ b/src/providers/claude/runtime/ClaudeQueryOptionsBuilder.ts @@ -0,0 +1,315 @@ +import type { + CanUseTool, + Options, + PermissionMode as SDKPermissionMode, +} from '@anthropic-ai/claude-agent-sdk'; + +import type { McpServerManager } from '../../../core/mcp/McpServerManager'; +import { + buildSystemPrompt, + computeSystemPromptKey, + type SystemPromptSettings, +} from '../../../core/prompt/mainAgent'; +import type { AppPluginManager } from '../../../core/providers/types'; +import type { ClaudianSettings, PermissionMode } from '../../../core/types/settings'; +import { toClaudeRuntimeModelId } from '../modelSelection'; +import { + type ClaudeSafeMode, + getClaudeProviderSettings, + resolveClaudeSettingSources, +} from '../settings'; +import { + resolveEffortLevel, +} from '../types/models'; +import { createCustomSpawnFunction } from './customSpawn'; +import { + DISABLED_BUILTIN_SUBAGENTS, + type PersistentQueryConfig, + UNSUPPORTED_SDK_TOOLS, +} from './types'; + +export interface QueryOptionsContext { + vaultPath: string; + cliPath: string; + settings: ClaudianSettings; + customEnv: Record; + enhancedPath: string; + mcpManager: McpServerManager; + pluginManager: AppPluginManager; +} + +export interface PersistentQueryContext extends QueryOptionsContext { + abortController?: AbortController; + resume?: { + sessionId: string; + sessionAt?: string; + fork?: boolean; + }; + canUseTool?: CanUseTool; + hooks: Options['hooks']; + externalContextPaths?: string[]; +} + +export interface ColdStartQueryContext extends QueryOptionsContext { + abortController?: AbortController; + sessionId?: string; + modelOverride?: string; + canUseTool?: CanUseTool; + hooks: Options['hooks']; + mcpMentions?: Set; + enabledMcpServers?: Set; + allowedTools?: string[]; + hasEditorContext: boolean; + externalContextPaths?: string[]; +} + +export class QueryOptionsBuilder { + static needsRestart( + currentConfig: PersistentQueryConfig | null, + newConfig: PersistentQueryConfig + ): boolean { + if (!currentConfig) return true; + + // These require restart (cannot be updated dynamically) + if (currentConfig.systemPromptKey !== newConfig.systemPromptKey) return true; + if (currentConfig.disallowedToolsKey !== newConfig.disallowedToolsKey) return true; + if (currentConfig.pluginsKey !== newConfig.pluginsKey) return true; + if (currentConfig.settingSources !== newConfig.settingSources) return true; + if (currentConfig.claudeCliPath !== newConfig.claudeCliPath) return true; + + // Note: Permission mode is handled dynamically via setPermissionMode() in ClaudianService. + // Since allowDangerouslySkipPermissions is always true, both directions work without restart. + + if (currentConfig.enableChrome !== newConfig.enableChrome) return true; + if (currentConfig.enableAutoMode !== newConfig.enableAutoMode) return true; + + // External context paths require restart (additionalDirectories can't be updated dynamically) + if (QueryOptionsBuilder.pathsChanged(currentConfig.externalContextPaths, newConfig.externalContextPaths)) { + return true; + } + + return false; + } + + static buildPersistentQueryConfig( + ctx: QueryOptionsContext, + externalContextPaths?: string[] + ): PersistentQueryConfig { + const claudeSettings = getClaudeProviderSettings(ctx.settings); + const systemPromptSettings: SystemPromptSettings = { + mediaFolder: ctx.settings.mediaFolder, + customPrompt: ctx.settings.systemPrompt, + vaultPath: ctx.vaultPath, + userName: ctx.settings.userName, + }; + + const sdkPermissionMode = QueryOptionsBuilder.resolveClaudeSdkPermissionMode( + ctx.settings.permissionMode, + claudeSettings.safeMode, + ); + + const disallowedToolsKey = ctx.mcpManager.getAllDisallowedMcpTools().join('|'); + const pluginsKey = ctx.pluginManager.getPluginsKey(); + + const settingSources = resolveClaudeSettingSources(claudeSettings.loadUserSettings); + const runtimeModel = toClaudeRuntimeModelId(ctx.settings.model); + + return { + model: runtimeModel, + effortLevel: resolveEffortLevel(runtimeModel, ctx.settings.effortLevel), + permissionMode: ctx.settings.permissionMode, + sdkPermissionMode, + systemPromptKey: computeSystemPromptKey(systemPromptSettings), + disallowedToolsKey, + mcpServersKey: '', // Dynamic via setMcpServers, not tracked for restart + pluginsKey, + externalContextPaths: externalContextPaths || [], + settingSources: settingSources.join(','), + claudeCliPath: ctx.cliPath, + enableChrome: claudeSettings.enableChrome, + enableAutoMode: claudeSettings.safeMode === 'auto', + }; + } + + static buildPersistentQueryOptions(ctx: PersistentQueryContext): Options { + const runtimeModel = toClaudeRuntimeModelId(ctx.settings.model); + const { options, claudeSettings } = QueryOptionsBuilder.buildBaseOptions( + ctx, + runtimeModel, + ctx.abortController, + ); + + options.disallowedTools = [ + ...ctx.mcpManager.getAllDisallowedMcpTools(), + ...UNSUPPORTED_SDK_TOOLS, + ...DISABLED_BUILTIN_SUBAGENTS, + ]; + + QueryOptionsBuilder.applyPermissionMode( + options, + ctx.settings.permissionMode, + claudeSettings.safeMode, + ctx.canUseTool, + ); + QueryOptionsBuilder.applyThinking(options, ctx.settings, runtimeModel); + options.hooks = ctx.hooks; + + options.enableFileCheckpointing = true; + + if (ctx.resume) { + options.resume = ctx.resume.sessionId; + if (ctx.resume.sessionAt) { + options.resumeSessionAt = ctx.resume.sessionAt; + } + if (ctx.resume.fork) { + options.forkSession = true; + } + } + + if (ctx.externalContextPaths && ctx.externalContextPaths.length > 0) { + options.additionalDirectories = ctx.externalContextPaths; + } + + return options; + } + + static buildColdStartQueryOptions(ctx: ColdStartQueryContext): Options { + const selectedModel = toClaudeRuntimeModelId(ctx.modelOverride ?? ctx.settings.model); + const { options, claudeSettings } = QueryOptionsBuilder.buildBaseOptions( + ctx, + selectedModel, + ctx.abortController, + ); + + const mcpMentions = ctx.mcpMentions || new Set(); + const uiEnabledServers = ctx.enabledMcpServers || new Set(); + const combinedMentions = new Set([...mcpMentions, ...uiEnabledServers]); + const mcpServers = ctx.mcpManager.getActiveServers(combinedMentions); + + if (Object.keys(mcpServers).length > 0) { + options.mcpServers = mcpServers; + } + + const disallowedMcpTools = ctx.mcpManager.getDisallowedMcpTools(combinedMentions); + options.disallowedTools = [ + ...disallowedMcpTools, + ...UNSUPPORTED_SDK_TOOLS, + ...DISABLED_BUILTIN_SUBAGENTS, + ]; + + QueryOptionsBuilder.applyPermissionMode( + options, + ctx.settings.permissionMode, + claudeSettings.safeMode, + ctx.canUseTool, + ); + options.hooks = ctx.hooks; + QueryOptionsBuilder.applyThinking(options, ctx.settings, selectedModel); + + if (ctx.allowedTools !== undefined && ctx.allowedTools.length > 0) { + options.tools = ctx.allowedTools; + } + + if (ctx.sessionId) { + options.resume = ctx.sessionId; + } + + if (ctx.externalContextPaths && ctx.externalContextPaths.length > 0) { + options.additionalDirectories = ctx.externalContextPaths; + } + + return options; + } + + static resolveClaudeSdkPermissionMode( + permissionMode: PermissionMode, + claudeSafeMode: ClaudeSafeMode = 'acceptEdits', + ): SDKPermissionMode { + if (permissionMode === 'yolo') return 'bypassPermissions'; + if (permissionMode === 'plan') return 'plan'; + return claudeSafeMode; + } + + private static applyPermissionMode( + options: Options, + permissionMode: PermissionMode, + claudeSafeMode: ClaudeSafeMode, + canUseTool?: CanUseTool + ): void { + options.allowDangerouslySkipPermissions = true; + + if (canUseTool) { + options.canUseTool = canUseTool; + } + + options.permissionMode = QueryOptionsBuilder.resolveClaudeSdkPermissionMode( + permissionMode, + claudeSafeMode, + ); + } + + private static applyExtraArgs( + options: Options, + settings: { enableChrome: boolean; safeMode: ClaudeSafeMode }, + ): void { + if (settings.safeMode === 'auto') { + options.extraArgs = { ...options.extraArgs, 'enable-auto-mode': null }; + } + + if (settings.enableChrome) { + options.extraArgs = { ...options.extraArgs, chrome: null }; + } + } + + private static buildBaseOptions( + ctx: QueryOptionsContext, + model: string, + abortController?: AbortController, + ): { options: Options; claudeSettings: ReturnType } { + const claudeSettings = getClaudeProviderSettings(ctx.settings); + const systemPromptSettings: SystemPromptSettings = { + mediaFolder: ctx.settings.mediaFolder, + customPrompt: ctx.settings.systemPrompt, + vaultPath: ctx.vaultPath, + userName: ctx.settings.userName, + }; + const options: Options = { + cwd: ctx.vaultPath, + systemPrompt: buildSystemPrompt(systemPromptSettings), + model, + abortController, + pathToClaudeCodeExecutable: ctx.cliPath, + settingSources: resolveClaudeSettingSources(claudeSettings.loadUserSettings), + env: { + ...process.env, + ...ctx.customEnv, + PATH: ctx.enhancedPath, + }, + includePartialMessages: true, + }; + + QueryOptionsBuilder.applyExtraArgs(options, claudeSettings); + options.spawnClaudeCodeProcess = createCustomSpawnFunction(ctx.enhancedPath); + + return { options, claudeSettings }; + } + + private static applyThinking( + options: Options, + settings: ClaudianSettings, + model: string + ): void { + const effortLevel = resolveEffortLevel(model, settings.effortLevel); + options.thinking = { type: 'adaptive' }; + // SDK runtime accepts `xhigh` on Opus 4.7+, Sonnet 5+, and Fable, and silently + // falls back to `high` elsewhere, but its type definition lags our local EffortLevel. + options.effort = effortLevel; + } + + private static pathsChanged(a?: string[], b?: string[]): boolean { + const aKey = [...(a || [])].sort().join('|'); + const bKey = [...(b || [])].sort().join('|'); + return aKey !== bKey; + } + +} diff --git a/src/providers/claude/runtime/ClaudeRewindService.ts b/src/providers/claude/runtime/ClaudeRewindService.ts new file mode 100644 index 0000000..8a96562 --- /dev/null +++ b/src/providers/claude/runtime/ClaudeRewindService.ts @@ -0,0 +1,229 @@ +import type { RewindFilesResult } from '@anthropic-ai/claude-agent-sdk'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import type { ChatRewindMode, ChatRewindResult } from '../../../core/runtime/types'; + +interface BackupEntryFile { + originalPath: string; + existedBefore: true; + kind: 'file' | 'dir'; + backupPath: string; +} + +interface BackupEntrySymlink { + originalPath: string; + existedBefore: true; + kind: 'symlink'; + symlinkTarget: string; +} + +interface BackupEntryMissing { + originalPath: string; + existedBefore: false; +} + +type BackupEntry = BackupEntryFile | BackupEntrySymlink | BackupEntryMissing; + +export interface ClaudeRewindBackup { + restore: () => Promise; + cleanup: () => Promise; +} + +export interface ExecuteClaudeRewindDeps { + assistantMessageId: string | undefined; + mode: ChatRewindMode; + rewindFiles: (userMessageId: string, dryRun?: boolean) => Promise; + closePersistentQuery: (reason: string) => void; + setPendingResumeAt: (assistantMessageId: string) => void; + resetSession: () => void; + vaultPath: string | null; +} + +function resolveRewindFilePath(filePath: string, vaultPath: string | null): string { + if (path.isAbsolute(filePath)) { + return filePath; + } + if (vaultPath) { + return path.join(vaultPath, filePath); + } + return filePath; +} + +async function copyDir(from: string, to: string): Promise { + await fs.mkdir(to, { recursive: true }); + const dirents = await fs.readdir(from, { withFileTypes: true }); + for (const dirent of dirents) { + const srcPath = path.join(from, dirent.name); + const destPath = path.join(to, dirent.name); + + if (dirent.isDirectory()) { + await copyDir(srcPath, destPath); + continue; + } + + if (dirent.isSymbolicLink()) { + const target = await fs.readlink(srcPath); + await fs.symlink(target, destPath); + continue; + } + + if (dirent.isFile()) { + await fs.copyFile(srcPath, destPath); + } + } +} + +export async function createClaudeRewindBackup( + filesChanged: string[] | undefined, + vaultPath: string | null, +): Promise { + if (!filesChanged || filesChanged.length === 0) { + return null; + } + + const backupRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'claudian-rewind-')); + const entries: BackupEntry[] = []; + const backupPathForIndex = (index: number) => path.join(backupRoot, String(index)); + + for (let i = 0; i < filesChanged.length; i++) { + const originalPath = resolveRewindFilePath(filesChanged[i], vaultPath); + + try { + const stats = await fs.lstat(originalPath); + + if (stats.isSymbolicLink()) { + const target = await fs.readlink(originalPath); + entries.push({ originalPath, existedBefore: true, kind: 'symlink', symlinkTarget: target }); + continue; + } + + const backupPath = backupPathForIndex(i); + if (stats.isDirectory()) { + await copyDir(originalPath, backupPath); + entries.push({ originalPath, existedBefore: true, kind: 'dir', backupPath }); + continue; + } + + if (stats.isFile()) { + await fs.copyFile(originalPath, backupPath); + entries.push({ originalPath, existedBefore: true, kind: 'file', backupPath }); + continue; + } + + entries.push({ originalPath, existedBefore: false }); + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') { + entries.push({ originalPath, existedBefore: false }); + continue; + } + + await fs.rm(backupRoot, { recursive: true, force: true }); + throw error; + } + } + + const restore = async () => { + const errors: unknown[] = []; + + for (const entry of entries) { + try { + if (!entry.existedBefore) { + await fs.rm(entry.originalPath, { recursive: true, force: true }); + continue; + } + + await fs.rm(entry.originalPath, { recursive: true, force: true }); + await fs.mkdir(path.dirname(entry.originalPath), { recursive: true }); + + if (entry.kind === 'symlink') { + await fs.symlink(entry.symlinkTarget, entry.originalPath); + continue; + } + + if (entry.kind === 'dir') { + await copyDir(entry.backupPath, entry.originalPath); + continue; + } + + await fs.copyFile(entry.backupPath, entry.originalPath); + } catch (error) { + errors.push(error); + } + } + + if (errors.length > 0) { + throw new Error(`Failed to restore ${errors.length} file(s) after rewind failure.`); + } + }; + + const cleanup = async () => { + await fs.rm(backupRoot, { recursive: true, force: true }); + }; + + return { restore, cleanup }; +} + +export async function executeClaudeRewind( + userMessageId: string, + deps: ExecuteClaudeRewindDeps, +): Promise { + if (deps.mode === 'conversation') { + if (deps.assistantMessageId) { + deps.setPendingResumeAt(deps.assistantMessageId); + deps.closePersistentQuery('conversation rewind'); + } else { + deps.resetSession(); + } + return { canRewind: true, filesChanged: [] }; + } + + const preview = await deps.rewindFiles(userMessageId, true); + if (!preview.canRewind) { + return preview; + } + + const backup = await createClaudeRewindBackup(preview.filesChanged, deps.vaultPath); + + try { + const result = await deps.rewindFiles(userMessageId); + if (!result.canRewind) { + await backup?.restore(); + deps.closePersistentQuery('rewind failed'); + return result; + } + + if (deps.assistantMessageId) { + deps.setPendingResumeAt(deps.assistantMessageId); + deps.closePersistentQuery('rewind'); + } else { + deps.resetSession(); + } + return { + ...result, + filesChanged: preview.filesChanged, + insertions: preview.insertions, + deletions: preview.deletions, + }; + } catch (error) { + try { + await backup?.restore(); + } catch (rollbackError) { + deps.closePersistentQuery('rewind failed'); + throw new Error( + `Rewind failed and files could not be fully restored: ${rollbackError instanceof Error ? rollbackError.message : 'Unknown error'}`, + { cause: rollbackError }, + ); + } + + deps.closePersistentQuery('rewind failed'); + throw new Error( + `Rewind failed but files were restored: ${error instanceof Error ? error.message : 'Unknown error'}`, + { cause: error }, + ); + } finally { + await backup?.cleanup(); + } +} diff --git a/src/providers/claude/runtime/ClaudeSessionManager.ts b/src/providers/claude/runtime/ClaudeSessionManager.ts new file mode 100644 index 0000000..06ac5f4 --- /dev/null +++ b/src/providers/claude/runtime/ClaudeSessionManager.ts @@ -0,0 +1,92 @@ +import type { ClaudeModel } from '../types/models'; +import type { SessionState } from './types'; + +export class SessionManager { + private state: SessionState = { + sessionId: null, + sessionModel: null, + pendingSessionModel: null, + wasInterrupted: false, + needsHistoryRebuild: false, + sessionInvalidated: false, + }; + + getSessionId(): string | null { + return this.state.sessionId; + } + + setSessionId(id: string | null, defaultModel?: ClaudeModel): void { + this.state.sessionId = id; + this.state.sessionModel = id ? (defaultModel ?? null) : null; + // Clear rebuild flag when switching sessions to prevent carrying over to different conversation + this.state.needsHistoryRebuild = false; + // Clear invalidation flag when explicitly setting session + this.state.sessionInvalidated = false; + } + + wasInterrupted(): boolean { + return this.state.wasInterrupted; + } + + markInterrupted(): void { + this.state.wasInterrupted = true; + } + + clearInterrupted(): void { + this.state.wasInterrupted = false; + } + + setPendingModel(model: ClaudeModel): void { + this.state.pendingSessionModel = model; + } + + clearPendingModel(): void { + this.state.pendingSessionModel = null; + } + + captureSession(sessionId: string): void { + const hadSession = this.state.sessionId !== null; + const isDifferent = this.state.sessionId !== sessionId; + if (hadSession && isDifferent) { + // SDK lost our session context - need to rebuild history on next message + this.state.needsHistoryRebuild = true; + } + + this.state.sessionId = sessionId; + this.state.sessionModel = this.state.pendingSessionModel; + this.state.pendingSessionModel = null; + this.state.sessionInvalidated = false; + } + + needsHistoryRebuild(): boolean { + return this.state.needsHistoryRebuild; + } + + clearHistoryRebuild(): void { + this.state.needsHistoryRebuild = false; + } + + invalidateSession(): void { + this.state.sessionId = null; + this.state.sessionModel = null; + this.state.sessionInvalidated = true; + } + + /** Consume the invalidation flag (returns true once). */ + consumeInvalidation(): boolean { + const wasInvalidated = this.state.sessionInvalidated; + this.state.sessionInvalidated = false; + return wasInvalidated; + } + + reset(): void { + this.state = { + sessionId: null, + sessionModel: null, + pendingSessionModel: null, + wasInterrupted: false, + needsHistoryRebuild: false, + sessionInvalidated: false, + }; + } +} diff --git a/src/providers/claude/runtime/ClaudeTaskResultInterpreter.ts b/src/providers/claude/runtime/ClaudeTaskResultInterpreter.ts new file mode 100644 index 0000000..83afb6f --- /dev/null +++ b/src/providers/claude/runtime/ClaudeTaskResultInterpreter.ts @@ -0,0 +1,172 @@ +import type { + ProviderTaskResultInterpreter, + ProviderTaskTerminalStatus, +} from '../../../core/providers/types'; +import { + extractAgentIdFromToolUseResult, + extractXmlTag, + resolveToolUseResultStatus, +} from '../history/ClaudeHistoryStore'; + +function extractAgentIdFromString(value: string): string | null { + const regexPatterns = [ + /"agent_id"\s*:\s*"([^"]+)"/, + /"agentId"\s*:\s*"([^"]+)"/, + /agent_id[=:]\s*"?([a-zA-Z0-9_-]+)"?/i, + /agentId[=:]\s*"?([a-zA-Z0-9_-]+)"?/i, + ]; + + for (const pattern of regexPatterns) { + const match = value.match(pattern); + if (match && match[1]) { + return match[1]; + } + } + + return null; +} + +function extractResultFromTaskObject(task: unknown): string | null { + if (!task || typeof task !== 'object') { + return null; + } + + const record = task as Record; + const result = typeof record.result === 'string' ? record.result.trim() : ''; + if (result.length > 0) { + return result; + } + + const output = typeof record.output === 'string' ? record.output.trim() : ''; + return output.length > 0 ? output : null; +} + +function extractTextFromContentBlocks(content: unknown): string | null { + if (!Array.isArray(content)) { + return null; + } + + const firstTextBlock = (content as Array>) + .find(block => block && typeof block === 'object' && block.type === 'text' && typeof block.text === 'string'); + if (!firstTextBlock || typeof firstTextBlock.text !== 'string') { + return null; + } + + const text = firstTextBlock.text.trim(); + return text.length > 0 ? text : null; +} + +export class ClaudeTaskResultInterpreter implements ProviderTaskResultInterpreter { + hasAsyncLaunchMarker(toolUseResult: unknown): boolean { + if (!toolUseResult || typeof toolUseResult !== 'object') { + return false; + } + + const record = toolUseResult as Record; + if (record.isAsync === true) { + return true; + } + + const rawStatus = record.retrieval_status ?? record.status; + if (typeof rawStatus === 'string' && rawStatus.toLowerCase() === 'async_launched') { + return true; + } + + // Sync Task results can still carry agentId metadata, so only treat + // output files as async when an explicit async marker is otherwise absent. + return typeof record.outputFile === 'string' && record.outputFile.length > 0; + } + + extractAgentId(toolUseResult: unknown): string | null { + const directId = extractAgentIdFromToolUseResult(toolUseResult); + if (directId) { + return directId; + } + + if (!toolUseResult || typeof toolUseResult !== 'object') { + return null; + } + + const record = toolUseResult as Record; + if (Array.isArray(record.content)) { + for (const block of record.content) { + if (typeof block === 'string') { + const extracted = extractAgentIdFromString(block); + if (extracted) { + return extracted; + } + continue; + } + + if (!block || typeof block !== 'object') { + continue; + } + + const text = (block as Record).text; + if (typeof text !== 'string') { + continue; + } + + const extracted = extractAgentIdFromString(text); + if (extracted) { + return extracted; + } + } + } + + if (typeof record.content === 'string') { + return extractAgentIdFromString(record.content); + } + + return null; + } + + extractStructuredResult(toolUseResult: unknown): string | null { + if (!toolUseResult || typeof toolUseResult !== 'object') { + return null; + } + + const record = toolUseResult as Record; + if (record.retrieval_status === 'error') { + const errorMsg = typeof record.error === 'string' ? record.error : 'Task retrieval failed'; + return `Error: ${errorMsg}`; + } + + const taskResult = extractResultFromTaskObject(record.task); + if (taskResult) { + return taskResult; + } + + const result = typeof record.result === 'string' ? record.result.trim() : ''; + if (result.length > 0) { + return result; + } + + const output = typeof record.output === 'string' ? record.output.trim() : ''; + if (output.length > 0) { + return output; + } + + return extractTextFromContentBlocks(record.content); + } + + resolveTerminalStatus( + toolUseResult: unknown, + fallbackStatus: ProviderTaskTerminalStatus, + ): ProviderTaskTerminalStatus { + const resolved = resolveToolUseResultStatus(toolUseResult, fallbackStatus); + if (resolved === 'error') { + return 'error'; + } + + if (resolved === 'completed') { + return 'completed'; + } + + return fallbackStatus; + } + + extractTagValue(payload: string, tagName: string): string | null { + return extractXmlTag(payload, tagName); + } +} diff --git a/src/providers/claude/runtime/ClaudeUserMessageFactory.ts b/src/providers/claude/runtime/ClaudeUserMessageFactory.ts new file mode 100644 index 0000000..8ff873a --- /dev/null +++ b/src/providers/claude/runtime/ClaudeUserMessageFactory.ts @@ -0,0 +1,83 @@ +import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; +import { randomUUID } from 'crypto'; + +import type { ImageAttachment } from '../../../core/types'; +import type { UserContentBlock } from './types'; + +function buildUserContentBlocks(prompt: string, images?: ImageAttachment[]): UserContentBlock[] { + const content: UserContentBlock[] = []; + + for (const image of images ?? []) { + content.push({ + type: 'image', + source: { + type: 'base64', + media_type: image.mediaType, + data: image.data, + }, + }); + } + + if (prompt.trim()) { + content.push({ + type: 'text', + text: prompt, + }); + } + + return content; +} + +export function buildClaudeSDKUserMessage( + prompt: string, + sessionId: string, + images?: ImageAttachment[], +): SDKUserMessage { + if (!images || images.length === 0) { + return { + type: 'user', + message: { + role: 'user', + content: prompt, + }, + parent_tool_use_id: null, + session_id: sessionId, + uuid: randomUUID(), + }; + } + + return { + type: 'user', + message: { + role: 'user', + content: buildUserContentBlocks(prompt, images), + }, + parent_tool_use_id: null, + session_id: sessionId, + uuid: randomUUID(), + }; +} + +export function buildClaudePromptWithImages( + prompt: string, + images?: ImageAttachment[], +): string | AsyncGenerator { + if (!images || images.length === 0) { + return prompt; + } + + const content = buildUserContentBlocks(prompt, images); + + async function* messageGenerator() { + yield { + type: 'user' as const, + message: { + role: 'user' as const, + content, + }, + parent_tool_use_id: null, + }; + } + + return messageGenerator(); +} diff --git a/src/providers/claude/runtime/claudeColdStartQuery.ts b/src/providers/claude/runtime/claudeColdStartQuery.ts new file mode 100644 index 0000000..9a9c997 --- /dev/null +++ b/src/providers/claude/runtime/claudeColdStartQuery.ts @@ -0,0 +1,152 @@ +import type { Options } from '@anthropic-ai/claude-agent-sdk'; +import { query as agentQuery } from '@anthropic-ai/claude-agent-sdk'; + +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import { getEnhancedPath, getMissingNodeError, parseEnvironmentVariables } from '../../../utils/env'; +import { getVaultPath } from '../../../utils/path'; +import { extractAssistantText } from '../auxiliary/extractAssistantText'; +import { toClaudeRuntimeModelId } from '../modelSelection'; +import { + getClaudeProviderSettings, + resolveClaudeSettingSources, +} from '../settings'; +import { + resolveEffortLevel, +} from '../types/models'; +import { createCustomSpawnFunction } from './customSpawn'; + +export interface ColdStartQueryConfig { + plugin: ProviderHost; + systemPrompt: string; + /** Tools available to the model. Omit for SDK default (all tools). */ + tools?: string[]; + hooks?: Options['hooks']; + /** Override model. Default: provider setting. */ + model?: string; + /** + * Thinking configuration override: + * - undefined: use provider settings (adaptive or budget-based) + * - { disabled: true }: skip all thinking configuration + */ + thinking?: { disabled: true }; + /** Default: SDK default (true). */ + persistSession?: boolean; + resumeSessionId?: string; + abortController?: AbortController; + /** Pre-fetched provider settings snapshot. Avoids a redundant fetch when the caller already has one. */ + providerSettings?: Record; + /** Called with accumulated text after each chunk. */ + onTextChunk?: (accumulatedText: string) => void; +} + +export interface ColdStartQueryResult { + text: string; + sessionId: string | null; +} + +export async function runColdStartQuery( + config: ColdStartQueryConfig, + prompt: string, +): Promise { + const vaultPath = getVaultPath(config.plugin.app); + if (!vaultPath) { + throw new Error('Could not determine vault path'); + } + + const resolvedClaudePath = config.plugin.getResolvedProviderCliPath('claude'); + if (!resolvedClaudePath) { + throw new Error('Claude CLI not found'); + } + + const customEnv = parseEnvironmentVariables( + config.plugin.getActiveEnvironmentVariables('claude') + ); + const enhancedPath = getEnhancedPath(customEnv.PATH, resolvedClaudePath); + + const missingNodeError = getMissingNodeError(resolvedClaudePath, enhancedPath); + if (missingNodeError) { + throw new Error(missingNodeError); + } + + const settings = config.providerSettings + ?? ProviderSettingsCoordinator.getProviderSettingsSnapshot( + config.plugin.settings, + 'claude', + ); + const claudeSettings = getClaudeProviderSettings(settings); + + const selectedModel = toClaudeRuntimeModelId(config.model ?? (settings.model as string)); + + const options: Options = { + cwd: vaultPath, + systemPrompt: config.systemPrompt, + model: selectedModel, + abortController: config.abortController, + pathToClaudeCodeExecutable: resolvedClaudePath, + env: { + ...process.env, + ...customEnv, + PATH: enhancedPath, + }, + permissionMode: 'bypassPermissions', + allowDangerouslySkipPermissions: true, + settingSources: resolveClaudeSettingSources(claudeSettings.loadUserSettings), + spawnClaudeCodeProcess: createCustomSpawnFunction(enhancedPath), + }; + + if (config.tools !== undefined) { + options.tools = config.tools; + } + + if (config.hooks) { + options.hooks = config.hooks; + } + + if (config.persistSession === false) { + options.persistSession = false; + } + + if (config.resumeSessionId) { + options.resume = config.resumeSessionId; + } + + if (claudeSettings.safeMode === 'auto') { + options.extraArgs = { ...options.extraArgs, 'enable-auto-mode': null }; + } + + if (!config.thinking?.disabled) { + const effortLevel = resolveEffortLevel(selectedModel, settings.effortLevel); + options.thinking = { type: 'adaptive' }; + // SDK runtime accepts `xhigh` on Opus 4.7+, Sonnet 5+, and Fable, and silently + // falls back to `high` elsewhere, but its type definition lags our local EffortLevel. + options.effort = effortLevel; + } + + const response = agentQuery({ prompt, options }); + let responseText = ''; + let sessionId: string | null = null; + + for await (const message of response) { + if (config.abortController?.signal.aborted) { + await response.interrupt(); + throw new Error('Cancelled'); + } + + if ( + message.type === 'system' && + message.subtype === 'init' && + message.session_id + ) { + sessionId = message.session_id; + } + + const text = extractAssistantText(message); + if (text) { + responseText += text; + config.onTextChunk?.(responseText); + } + } + + return { text: responseText, sessionId }; +} diff --git a/src/providers/claude/runtime/customSpawn.ts b/src/providers/claude/runtime/customSpawn.ts new file mode 100644 index 0000000..04649e1 --- /dev/null +++ b/src/providers/claude/runtime/customSpawn.ts @@ -0,0 +1,89 @@ +import type { SpawnedProcess, SpawnOptions } from '@anthropic-ai/claude-agent-sdk'; +import { type ChildProcess, spawn } from 'child_process'; + +import { cliPathRequiresNode, findNodeExecutable } from '../../../utils/env'; +import { + resolveWindowsCmdShimSpawnSpec, + terminateSpawnedProcess, + type WindowsCmdShimSpawnSpec, +} from '../../../utils/windowsCmdShim'; + +export function createCustomSpawnFunction( + enhancedPath: string +): (options: SpawnOptions) => SpawnedProcess { + return (options: SpawnOptions): SpawnedProcess => { + let { command } = options; + let { args } = options; + const { cwd, env, signal } = options; + const shouldPipeStderr = !!env?.DEBUG_CLAUDE_AGENT_SDK; + + // The SDK only routes some script extensions through `node`; normalize the + // remaining Node-backed paths here before Electron spawns with shell=false. + if (command === 'node' || cliPathRequiresNode(command)) { + const nodeFullPath = findNodeExecutable(enhancedPath); + if (command === 'node') { + if (nodeFullPath) { + command = nodeFullPath; + } + } else { + args = [command, ...args]; + command = nodeFullPath ?? 'node'; + } + } + + const resolvedSpawnSpec = resolveWindowsCmdShimSpawnSpec({ args, command }); + + // Do not pass `signal` directly to spawn() — Obsidian's Electron runtime + // uses a different realm for AbortSignal, causing `instanceof EventTarget` + // checks inside Node's internals to fail. Handle abort manually instead. + const child = spawn(resolvedSpawnSpec.command, resolvedSpawnSpec.args, { + cwd, + env: env, + stdio: ['pipe', 'pipe', shouldPipeStderr ? 'pipe' : 'ignore'], + windowsHide: true, + ...(resolvedSpawnSpec.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + }); + installTreeAwareKill(child, resolvedSpawnSpec); + + if (signal) { + const killChild = (): void => { + child.kill('SIGTERM'); + }; + if (signal.aborted) { + killChild(); + } else { + signal.addEventListener('abort', killChild, { once: true }); + } + } + + if (shouldPipeStderr && child.stderr && typeof child.stderr.on === 'function') { + child.stderr.on('data', () => {}); + } + + if (!child.stdin || !child.stdout) { + throw new Error('Failed to create process streams'); + } + + return child as unknown as SpawnedProcess; + }; +} + +function installTreeAwareKill(child: ChildProcess, spawnSpec: WindowsCmdShimSpawnSpec): void { + if (!spawnSpec.killProcessTree) { + return; + } + + const originalKill = child.kill.bind(child); + const callOriginalKill = (signal?: NodeJS.Signals | number): boolean => + originalKill(signal); + const killableChild = { + get pid(): number | undefined { + return child.pid; + }, + kill: callOriginalKill, + }; + + child.kill = ((signal?: NodeJS.Signals | number): boolean => + terminateSpawnedProcess(killableChild, signal, spawn, spawnSpec) + ); +} diff --git a/src/providers/claude/runtime/types.ts b/src/providers/claude/runtime/types.ts new file mode 100644 index 0000000..8a73733 --- /dev/null +++ b/src/providers/claude/runtime/types.ts @@ -0,0 +1,134 @@ +import type { + PermissionMode as SDKPermissionMode, + SDKMessage, + SDKUserMessage, +} from '@anthropic-ai/claude-agent-sdk'; + +import type { ChatRuntimeEnsureReadyOptions } from '../../../core/runtime/types'; +import type { ImageAttachment, StreamChunk } from '../../../core/types'; +import type { PermissionMode } from '../../../core/types/settings'; +import type { ClaudeModel, EffortLevel } from '../types/models'; + +export interface TextContentBlock { + type: 'text'; + text: string; +} + +export interface ImageContentBlock { + type: 'image'; + source: { + type: 'base64'; + media_type: ImageAttachment['mediaType']; + data: string; + }; +} + +export type UserContentBlock = TextContentBlock | ImageContentBlock; + +export const MESSAGE_CHANNEL_CONFIG = { + MAX_QUEUED_MESSAGES: 8, // Memory protection from rapid user input + MAX_MERGED_CHARS: 12000, // ~3k tokens — batch size under context limits +} as const; + +export interface PendingTextMessage { + type: 'text'; + content: string; +} + +export interface PendingAttachmentMessage { + type: 'attachment'; + message: SDKUserMessage; +} + +export type PendingMessage = PendingTextMessage | PendingAttachmentMessage; + +export interface ClosePersistentQueryOptions { + preserveHandlers?: boolean; +} + +export interface ClaudeEnsureReadyOptions extends ChatRuntimeEnsureReadyOptions { + externalContextPaths?: string[]; + preserveHandlers?: boolean; + sessionId?: string; +} + +export interface ResponseHandler { + readonly id: string; + onChunk: (chunk: StreamChunk) => void; + onDone: () => void; + onError: (error: Error) => void; + readonly sawStreamText: boolean; + readonly sawStreamThinking: boolean; + readonly sawAnyChunk: boolean; + markStreamTextSeen(): void; + markStreamThinkingSeen(): void; + resetStreamText(): void; + resetStreamThinking(): void; + markChunkSeen(): void; +} + +export interface ResponseHandlerOptions { + id: string; + onChunk: (chunk: StreamChunk) => void; + onDone: () => void; + onError: (error: Error) => void; +} + +export function createResponseHandler(options: ResponseHandlerOptions): ResponseHandler { + let _sawStreamText = false; + let _sawStreamThinking = false; + let _sawAnyChunk = false; + + return { + id: options.id, + onChunk: options.onChunk, + onDone: options.onDone, + onError: options.onError, + get sawStreamText() { return _sawStreamText; }, + get sawStreamThinking() { return _sawStreamThinking; }, + get sawAnyChunk() { return _sawAnyChunk; }, + markStreamTextSeen() { _sawStreamText = true; }, + markStreamThinkingSeen() { _sawStreamThinking = true; }, + resetStreamText() { _sawStreamText = false; }, + resetStreamThinking() { _sawStreamThinking = false; }, + markChunkSeen() { _sawAnyChunk = true; }, + }; +} + +export interface PersistentQueryConfig { + model: string | null; + effortLevel: EffortLevel; + permissionMode: PermissionMode | null; + sdkPermissionMode: SDKPermissionMode | null; + systemPromptKey: string; + disallowedToolsKey: string; + mcpServersKey: string; + pluginsKey: string; + externalContextPaths: string[]; + settingSources: string; + claudeCliPath: string; + enableChrome: boolean; + enableAutoMode: boolean; +} + +export interface SessionState { + sessionId: string | null; + sessionModel: ClaudeModel | null; + pendingSessionModel: ClaudeModel | null; + wasInterrupted: boolean; + /** Set when SDK returns a different session ID than expected (context lost). */ + needsHistoryRebuild: boolean; + /** Set when the current session is invalidated by SDK errors. */ + sessionInvalidated: boolean; +} + +export const UNSUPPORTED_SDK_TOOLS = [] as const; + +/** Built-in subagents that don't apply to Obsidian context. */ +export const DISABLED_BUILTIN_SUBAGENTS = [ + 'Task(statusline-setup)', +] as const; + +export function isTurnCompleteMessage(message: SDKMessage): boolean { + return message.type === 'result'; +} diff --git a/src/providers/claude/sdk/messages.ts b/src/providers/claude/sdk/messages.ts new file mode 100644 index 0000000..d56b57f --- /dev/null +++ b/src/providers/claude/sdk/messages.ts @@ -0,0 +1,17 @@ +import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; + +export type { SDKMessage } from '@anthropic-ai/claude-agent-sdk'; + +export type BlockedUserMessage = SDKUserMessage & { + _blocked: true; + _blockReason: string; +}; + +export function isBlockedMessage(message: { type: string }): message is BlockedUserMessage { + return ( + message.type === 'user' && + '_blocked' in message && + (message as Record)._blocked === true && + '_blockReason' in message + ); +} diff --git a/src/providers/claude/sdk/toolResultContent.ts b/src/providers/claude/sdk/toolResultContent.ts new file mode 100644 index 0000000..5b9ae30 --- /dev/null +++ b/src/providers/claude/sdk/toolResultContent.ts @@ -0,0 +1,4 @@ +export { + extractToolResultContent, + type ToolResultContentOptions, +} from '../../../core/tools/toolResultContent'; diff --git a/src/providers/claude/sdk/typeGuards.ts b/src/providers/claude/sdk/typeGuards.ts new file mode 100644 index 0000000..8518c3d --- /dev/null +++ b/src/providers/claude/sdk/typeGuards.ts @@ -0,0 +1,14 @@ +import type { StreamChunk } from '../../../core/types'; +import type { ContextWindowEvent, SessionInitEvent, TransformEvent } from './types'; + +export function isSessionInitEvent(event: TransformEvent): event is SessionInitEvent { + return event.type === 'session_init'; +} + +export function isContextWindowEvent(event: TransformEvent): event is ContextWindowEvent { + return event.type === 'context_window'; +} + +export function isStreamChunk(event: TransformEvent): event is StreamChunk { + return event.type !== 'session_init' && event.type !== 'context_window'; +} diff --git a/src/providers/claude/sdk/types.ts b/src/providers/claude/sdk/types.ts new file mode 100644 index 0000000..c385b63 --- /dev/null +++ b/src/providers/claude/sdk/types.ts @@ -0,0 +1,15 @@ +import type { StreamChunk } from '../../../core/types'; + +export interface SessionInitEvent { + type: 'session_init'; + sessionId: string; + agents?: string[]; + permissionMode?: string; +} + +export interface ContextWindowEvent { + type: 'context_window'; + contextWindow: number; +} + +export type TransformEvent = StreamChunk | SessionInitEvent | ContextWindowEvent; diff --git a/src/providers/claude/security/ClaudePermissionUpdates.ts b/src/providers/claude/security/ClaudePermissionUpdates.ts new file mode 100644 index 0000000..ce59c8d --- /dev/null +++ b/src/providers/claude/security/ClaudePermissionUpdates.ts @@ -0,0 +1,77 @@ +import type { PermissionUpdate, PermissionUpdateDestination } from '@anthropic-ai/claude-agent-sdk'; + +import { getActionPattern } from '../../../core/security/ApprovalManager'; + +export function buildPermissionUpdates( + toolName: string, + input: Record, + decision: 'allow' | 'allow-always', + suggestions?: PermissionUpdate[] +): PermissionUpdate[] { + const destination: PermissionUpdateDestination = + decision === 'allow-always' ? 'projectSettings' : 'session'; + + const processed: PermissionUpdate[] = []; + let hasRuleUpdate = false; + + if (suggestions) { + for (const suggestion of suggestions) { + if (suggestion.type === 'addRules' || suggestion.type === 'replaceRules') { + if (decision === 'allow-always') { + const scopedRules = suggestion.rules.filter(hasNonEmptyRuleScope); + if (scopedRules.length === 0) { + continue; + } + hasRuleUpdate = true; + processed.push({ + ...suggestion, + rules: scopedRules, + behavior: 'allow', + destination, + }); + } else { + hasRuleUpdate = true; + processed.push({ ...suggestion, behavior: 'allow', destination }); + } + } else { + processed.push(suggestion); + } + } + } + + if (!hasRuleUpdate) { + const pattern = getActionPattern(toolName, input); + if (decision === 'allow-always' && !isNonEmptyDerivedScope(pattern)) { + return []; + } + const ruleValue: { toolName: string; ruleContent?: string } = { toolName }; + if (pattern && !pattern.startsWith('{')) { + ruleValue.ruleContent = pattern; + } + + processed.unshift({ + type: 'addRules', + behavior: 'allow', + rules: [ruleValue], + destination, + }); + } + + return processed; +} + +function hasNonEmptyRuleScope(rule: { toolName?: string; ruleContent?: string }): boolean { + return typeof rule.toolName === 'string' + && rule.toolName.trim().length > 0 + && typeof rule.ruleContent === 'string' + && rule.ruleContent.trim().length > 0; +} + +function isNonEmptyDerivedScope(pattern: string | null): pattern is string { + if (typeof pattern !== 'string') { + return false; + } + + const scope = pattern.trim(); + return scope.length > 0 && !scope.startsWith('{'); +} diff --git a/src/providers/claude/settings.ts b/src/providers/claude/settings.ts new file mode 100644 index 0000000..d0e418a --- /dev/null +++ b/src/providers/claude/settings.ts @@ -0,0 +1,128 @@ +import { getProviderConfig, setProviderConfig } from '../../core/providers/providerConfig'; +import { getProviderEnvironmentVariables } from '../../core/providers/providerEnvironment'; +import type { HostnameCliPaths } from '../../core/types/settings'; +import { + getHostnameKey, + getLegacyHostnameKey, + migrateLegacyHostnameKeyedMap, +} from '../../utils/env'; + +export const CLAUDE_SAFE_MODES = ['acceptEdits', 'auto', 'default'] as const; +export type ClaudeSafeMode = typeof CLAUDE_SAFE_MODES[number]; +export type ClaudeSettingSource = 'user' | 'project' | 'local'; + +export interface ClaudeProviderSettings { + safeMode: ClaudeSafeMode; + cliPath: string; + cliPathsByHost: HostnameCliPaths; + loadUserSettings: boolean; + enableChrome: boolean; + enableBangBash: boolean; + customModels: string; + lastModel: string; + environmentVariables: string; + environmentHash: string; +} + +export const DEFAULT_CLAUDE_PROVIDER_SETTINGS: Readonly = Object.freeze({ + safeMode: 'acceptEdits', + cliPath: '', + cliPathsByHost: {}, + loadUserSettings: true, + enableChrome: false, + enableBangBash: false, + customModels: '', + lastModel: 'haiku', + environmentVariables: '', + environmentHash: '', +}); + +function normalizeHostnameCliPaths(value: unknown): HostnameCliPaths { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const result: HostnameCliPaths = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string' && entry.trim()) { + result[key] = entry.trim(); + } + } + return result; +} + +function normalizeClaudeSafeMode(value: unknown): ClaudeSafeMode | undefined { + return (CLAUDE_SAFE_MODES as readonly unknown[]).includes(value) + ? value as ClaudeSafeMode + : undefined; +} + +export function getClaudeProviderSettings( + settings: Record, +): ClaudeProviderSettings { + const config = getProviderConfig(settings, 'claude'); + const normalizedCliPathsByHost = normalizeHostnameCliPaths( + config.cliPathsByHost ?? settings.claudeCliPathsByHost, + ); + const cliPathsByHost = Object.keys(normalizedCliPathsByHost).length > 0 + ? migrateLegacyHostnameKeyedMap( + normalizedCliPathsByHost, + getHostnameKey(), + getLegacyHostnameKey(), + ) + : normalizedCliPathsByHost; + + return { + safeMode: normalizeClaudeSafeMode(config.safeMode) + ?? normalizeClaudeSafeMode(settings.claudeSafeMode) + ?? DEFAULT_CLAUDE_PROVIDER_SETTINGS.safeMode, + cliPath: (config.cliPath as string | undefined) + ?? (settings.claudeCliPath as string | undefined) + ?? DEFAULT_CLAUDE_PROVIDER_SETTINGS.cliPath, + cliPathsByHost, + loadUserSettings: (config.loadUserSettings as boolean | undefined) + ?? (settings.loadUserClaudeSettings as boolean | undefined) + ?? DEFAULT_CLAUDE_PROVIDER_SETTINGS.loadUserSettings, + enableChrome: (config.enableChrome as boolean | undefined) + ?? (settings.enableChrome as boolean | undefined) + ?? DEFAULT_CLAUDE_PROVIDER_SETTINGS.enableChrome, + enableBangBash: (config.enableBangBash as boolean | undefined) + ?? (settings.enableBangBash as boolean | undefined) + ?? DEFAULT_CLAUDE_PROVIDER_SETTINGS.enableBangBash, + customModels: (config.customModels as string | undefined) + ?? DEFAULT_CLAUDE_PROVIDER_SETTINGS.customModels, + lastModel: (config.lastModel as string | undefined) + ?? (settings.lastClaudeModel as string | undefined) + ?? DEFAULT_CLAUDE_PROVIDER_SETTINGS.lastModel, + environmentVariables: (config.environmentVariables as string | undefined) + ?? getProviderEnvironmentVariables(settings, 'claude') + ?? DEFAULT_CLAUDE_PROVIDER_SETTINGS.environmentVariables, + environmentHash: (config.environmentHash as string | undefined) + ?? (settings.lastEnvHash as string | undefined) + ?? DEFAULT_CLAUDE_PROVIDER_SETTINGS.environmentHash, + }; +} + +export function resolveClaudeSettingSources( + loadUserSettings: boolean, +): ClaudeSettingSource[] { + return loadUserSettings + ? ['user', 'project', 'local'] + : ['project', 'local']; +} + +export function updateClaudeProviderSettings( + settings: Record, + updates: Partial, +): ClaudeProviderSettings { + const current = getClaudeProviderSettings(settings); + const next = { + ...current, + ...updates, + safeMode: 'safeMode' in updates + ? normalizeClaudeSafeMode(updates.safeMode) ?? current.safeMode + : current.safeMode, + }; + setProviderConfig(settings, 'claude', next); + return next; +} diff --git a/src/providers/claude/storage/AgentVaultStorage.ts b/src/providers/claude/storage/AgentVaultStorage.ts new file mode 100644 index 0000000..7e60adc --- /dev/null +++ b/src/providers/claude/storage/AgentVaultStorage.ts @@ -0,0 +1,101 @@ +import type { VaultFileAdapter } from '../../../core/storage/VaultFileAdapter'; +import type { AgentDefinition } from '../../../core/types'; +import { serializeAgent } from '../../../utils/agent'; +import { buildAgentFromFrontmatter, parseAgentFile } from '../agents/AgentStorage'; + +export const AGENTS_PATH = '.claude/agents'; + +export class AgentVaultStorage { + constructor(private adapter: VaultFileAdapter) {} + + async loadAll(): Promise { + const agents: AgentDefinition[] = []; + + try { + const files = await this.adapter.listFiles(AGENTS_PATH); + + for (const filePath of files) { + if (!filePath.endsWith('.md')) continue; + + try { + const content = await this.adapter.read(filePath); + const parsed = parseAgentFile(content); + if (!parsed) continue; + + const { frontmatter, body } = parsed; + + agents.push(buildAgentFromFrontmatter(frontmatter, body, { + id: frontmatter.name, + source: 'vault', + filePath, + })); + } catch { /* Non-critical: skip malformed agent files */ } + } + } catch { /* Non-critical: directory may not exist yet */ } + + return agents; + } + + async load(agent: AgentDefinition): Promise { + const filePath = this.resolvePath(agent); + try { + const content = await this.adapter.read(filePath); + const parsed = parseAgentFile(content); + if (!parsed) return null; + const { frontmatter, body } = parsed; + return buildAgentFromFrontmatter(frontmatter, body, { + id: frontmatter.name, + source: agent.source, + filePath, + }); + } catch (error) { + if (this.isFileNotFoundError(error)) { + return null; + } + throw error; + } + } + + async save(agent: AgentDefinition): Promise { + await this.adapter.write(this.resolvePath(agent), serializeAgent(agent)); + } + + async delete(agent: AgentDefinition): Promise { + await this.adapter.delete(this.resolvePath(agent)); + } + + private resolvePath(agent: AgentDefinition): string { + if (!agent.filePath) { + return `${AGENTS_PATH}/${agent.name}.md`; + } + + const normalized = agent.filePath.replace(/\\/g, '/'); + const idx = normalized.lastIndexOf(`${AGENTS_PATH}/`); + if (idx !== -1) { + return normalized.slice(idx); + } + return `${AGENTS_PATH}/${agent.name}.md`; + } + + private isFileNotFoundError(error: unknown): boolean { + if (!error) return false; + + if (typeof error === 'string') { + return /enoent|not found|no such file/i.test(error); + } + + if (typeof error === 'object') { + const maybeCode = (error as { code?: unknown }).code; + if (typeof maybeCode === 'string' && /enoent|not.?found/i.test(maybeCode)) { + return true; + } + + const maybeMessage = (error as { message?: unknown }).message; + if (typeof maybeMessage === 'string' && /enoent|not found|no such file/i.test(maybeMessage)) { + return true; + } + } + + return false; + } +} diff --git a/src/providers/claude/storage/CCSettingsStorage.ts b/src/providers/claude/storage/CCSettingsStorage.ts new file mode 100644 index 0000000..118ce90 --- /dev/null +++ b/src/providers/claude/storage/CCSettingsStorage.ts @@ -0,0 +1,182 @@ +import { Notice } from 'obsidian'; + +import { NotifiedMutationError } from '../../../core/storage/NotifiedMutationError'; +import type { VaultFileAdapter } from '../../../core/storage/VaultFileAdapter'; +import type { + CCPermissions, + CCSettings, + PermissionRule, +} from '../types/settings'; +import { DEFAULT_CC_PERMISSIONS, DEFAULT_CC_SETTINGS } from '../types/settings'; + +export const CC_SETTINGS_PATH = '.claude/settings.json'; + +const CC_SETTINGS_SCHEMA = 'https://json.schemastore.org/claude-code-settings.json'; +const INVALID_SETTINGS_MESSAGE = + 'Failed to update .claude/settings.json because it contains invalid JSON.'; + +function rejectInvalidSettingsMutation(): never { + new Notice(INVALID_SETTINGS_MESSAGE); + throw new NotifiedMutationError(INVALID_SETTINGS_MESSAGE); +} + +function normalizeRuleList(value: unknown): PermissionRule[] { + if (!Array.isArray(value)) return []; + return value.filter((r): r is string => typeof r === 'string') as PermissionRule[]; +} + +function normalizePermissions(permissions: unknown): CCPermissions { + if (!permissions || typeof permissions !== 'object') { + return { ...DEFAULT_CC_PERMISSIONS }; + } + + const p = permissions as Record; + return { + allow: normalizeRuleList(p.allow), + deny: normalizeRuleList(p.deny), + ask: normalizeRuleList(p.ask), + defaultMode: typeof p.defaultMode === 'string' ? p.defaultMode as CCPermissions['defaultMode'] : undefined, + additionalDirectories: Array.isArray(p.additionalDirectories) + ? p.additionalDirectories.filter((d): d is string => typeof d === 'string') + : undefined, + }; +} + +export class CCSettingsStorage { + constructor(private adapter: VaultFileAdapter) { } + + async load(): Promise { + if (!(await this.adapter.exists(CC_SETTINGS_PATH))) { + return { ...DEFAULT_CC_SETTINGS }; + } + + const content = await this.adapter.read(CC_SETTINGS_PATH); + const stored = JSON.parse(content) as Record; + + return { + $schema: CC_SETTINGS_SCHEMA, + ...stored, + permissions: normalizePermissions(stored.permissions), + }; + } + + async save(settings: CCSettings): Promise { + // Preserve CC-specific fields we don't manage + let existing: Record = {}; + if (await this.adapter.exists(CC_SETTINGS_PATH)) { + const content = await this.adapter.read(CC_SETTINGS_PATH); + try { + existing = JSON.parse(content) as Record; + } catch { + rejectInvalidSettingsMutation(); + } + } + + // Merge: existing CC fields + our updates + const merged: CCSettings = { + ...existing, + $schema: CC_SETTINGS_SCHEMA, + permissions: settings.permissions ?? { ...DEFAULT_CC_PERMISSIONS }, + }; + + if (settings.enabledPlugins !== undefined) { + merged.enabledPlugins = settings.enabledPlugins; + } + + const content = JSON.stringify(merged, null, 2); + await this.adapter.write(CC_SETTINGS_PATH, content); + } + + async exists(): Promise { + return this.adapter.exists(CC_SETTINGS_PATH); + } + + async getPermissions(): Promise { + const settings = await this.load(); + return settings.permissions ?? { ...DEFAULT_CC_PERMISSIONS }; + } + + async updatePermissions(permissions: CCPermissions): Promise { + const settings = await this.loadForMutation(); + settings.permissions = permissions; + await this.save(settings); + } + + async addAllowRule(rule: PermissionRule): Promise { + const settings = await this.loadForMutation(); + const permissions = settings.permissions ?? { ...DEFAULT_CC_PERMISSIONS }; + if (!permissions.allow?.includes(rule)) { + permissions.allow = [...(permissions.allow ?? []), rule]; + settings.permissions = permissions; + await this.save(settings); + } + } + + async addDenyRule(rule: PermissionRule): Promise { + const settings = await this.loadForMutation(); + const permissions = settings.permissions ?? { ...DEFAULT_CC_PERMISSIONS }; + if (!permissions.deny?.includes(rule)) { + permissions.deny = [...(permissions.deny ?? []), rule]; + settings.permissions = permissions; + await this.save(settings); + } + } + + async addAskRule(rule: PermissionRule): Promise { + const settings = await this.loadForMutation(); + const permissions = settings.permissions ?? { ...DEFAULT_CC_PERMISSIONS }; + if (!permissions.ask?.includes(rule)) { + permissions.ask = [...(permissions.ask ?? []), rule]; + settings.permissions = permissions; + await this.save(settings); + } + } + + async removeRule(rule: PermissionRule): Promise { + const settings = await this.loadForMutation(); + const permissions = settings.permissions ?? { ...DEFAULT_CC_PERMISSIONS }; + permissions.allow = permissions.allow?.filter(r => r !== rule); + permissions.deny = permissions.deny?.filter(r => r !== rule); + permissions.ask = permissions.ask?.filter(r => r !== rule); + settings.permissions = permissions; + await this.save(settings); + } + + async getEnabledPlugins(): Promise> { + const settings = await this.load(); + return settings.enabledPlugins ?? {}; + } + + async setPluginEnabled(pluginId: string, enabled: boolean): Promise { + const settings = await this.loadForMutation(); + const enabledPlugins = settings.enabledPlugins ?? {}; + + enabledPlugins[pluginId] = enabled; + settings.enabledPlugins = enabledPlugins; + + await this.save(settings); + } + + async getExplicitlyEnabledPluginIds(): Promise { + const enabledPlugins = await this.getEnabledPlugins(); + return Object.entries(enabledPlugins) + .filter(([, enabled]) => enabled) + .map(([id]) => id); + } + + async isPluginDisabled(pluginId: string): Promise { + const enabledPlugins = await this.getEnabledPlugins(); + return enabledPlugins[pluginId] === false; + } + + private async loadForMutation(): Promise { + try { + return await this.load(); + } catch (error) { + if (error instanceof SyntaxError) { + rejectInvalidSettingsMutation(); + } + throw error; + } + } +} diff --git a/src/providers/claude/storage/ClaudianSettingsStorage.ts b/src/providers/claude/storage/ClaudianSettingsStorage.ts new file mode 100644 index 0000000..da61fc5 --- /dev/null +++ b/src/providers/claude/storage/ClaudianSettingsStorage.ts @@ -0,0 +1,6 @@ +export { + CLAUDIAN_SETTINGS_PATH, + ClaudianSettingsStorage, + LEGACY_CLAUDIAN_SETTINGS_PATH, + type StoredClaudianSettings, +} from '../../../app/settings/ClaudianSettingsStorage'; diff --git a/src/providers/claude/storage/McpStorage.ts b/src/providers/claude/storage/McpStorage.ts new file mode 100644 index 0000000..1b3076f --- /dev/null +++ b/src/providers/claude/storage/McpStorage.ts @@ -0,0 +1,145 @@ +import { Notice } from 'obsidian'; + +import { NotifiedMutationError } from '../../../core/storage/NotifiedMutationError'; +import type { VaultFileAdapter } from '../../../core/storage/VaultFileAdapter'; +import type { + ManagedMcpConfigFile, + ManagedMcpServer, + McpServerConfig, +} from '../../../core/types'; +import { DEFAULT_MCP_SERVER, isValidMcpServerConfig } from '../../../core/types'; + +export const MCP_CONFIG_PATH = '.claude/mcp.json'; +const INVALID_MCP_CONFIG_MESSAGE = + 'Failed to update .claude/mcp.json because it contains invalid JSON.'; + +export class McpStorage { + constructor(private adapter: VaultFileAdapter) {} + + async load(): Promise { + try { + if (!(await this.adapter.exists(MCP_CONFIG_PATH))) { + return []; + } + + const content = await this.adapter.read(MCP_CONFIG_PATH); + const file = JSON.parse(content) as ManagedMcpConfigFile; + + if (!file.mcpServers || typeof file.mcpServers !== 'object') { + return []; + } + + const claudianMeta = file._claudian?.servers ?? {}; + const servers: ManagedMcpServer[] = []; + + for (const [name, config] of Object.entries(file.mcpServers)) { + if (!isValidMcpServerConfig(config)) { + continue; + } + + const meta = claudianMeta[name] ?? {}; + const disabledTools = Array.isArray(meta.disabledTools) + ? meta.disabledTools.filter((tool) => typeof tool === 'string') + : undefined; + const normalizedDisabledTools = + disabledTools && disabledTools.length > 0 ? disabledTools : undefined; + + servers.push({ + name, + config, + enabled: meta.enabled ?? DEFAULT_MCP_SERVER.enabled, + contextSaving: meta.contextSaving ?? DEFAULT_MCP_SERVER.contextSaving, + disabledTools: normalizedDisabledTools, + description: meta.description, + }); + } + + return servers; + } catch { + return []; + } + } + + async save(servers: ManagedMcpServer[]): Promise { + const mcpServers: Record = {}; + const claudianServers: Record< + string, + { enabled?: boolean; contextSaving?: boolean; disabledTools?: string[]; description?: string } + > = {}; + + for (const server of servers) { + mcpServers[server.name] = server.config; + + // Only store Claudian metadata if different from defaults + const meta: { + enabled?: boolean; + contextSaving?: boolean; + disabledTools?: string[]; + description?: string; + } = {}; + + if (server.enabled !== DEFAULT_MCP_SERVER.enabled) { + meta.enabled = server.enabled; + } + if (server.contextSaving !== DEFAULT_MCP_SERVER.contextSaving) { + meta.contextSaving = server.contextSaving; + } + const normalizedDisabledTools = server.disabledTools + ?.map((tool) => tool.trim()) + .filter((tool) => tool.length > 0); + if (normalizedDisabledTools && normalizedDisabledTools.length > 0) { + meta.disabledTools = normalizedDisabledTools; + } + if (server.description) { + meta.description = server.description; + } + + if (Object.keys(meta).length > 0) { + claudianServers[server.name] = meta; + } + } + + let existing: Record | null = null; + if (await this.adapter.exists(MCP_CONFIG_PATH)) { + const raw = await this.adapter.read(MCP_CONFIG_PATH); + try { + const parsed: unknown = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + existing = parsed as Record; + } + } catch { + new Notice(INVALID_MCP_CONFIG_MESSAGE); + throw new NotifiedMutationError(INVALID_MCP_CONFIG_MESSAGE); + } + } + + const file: Record = existing ? { ...existing } : {}; + file.mcpServers = mcpServers; + + const existingClaudian = + existing && typeof existing._claudian === 'object' + ? (existing._claudian as Record) + : null; + + if (Object.keys(claudianServers).length > 0) { + file._claudian = { ...(existingClaudian ?? {}), servers: claudianServers }; + } else if (existingClaudian) { + const rest = { ...existingClaudian }; + delete rest.servers; + if (Object.keys(rest).length > 0) { + file._claudian = rest; + } else { + delete file._claudian; + } + } else { + delete file._claudian; + } + + const content = JSON.stringify(file, null, 2); + await this.adapter.write(MCP_CONFIG_PATH, content); + } + + async exists(): Promise { + return this.adapter.exists(MCP_CONFIG_PATH); + } +} diff --git a/src/providers/claude/storage/SessionStorage.ts b/src/providers/claude/storage/SessionStorage.ts new file mode 100644 index 0000000..0089a59 --- /dev/null +++ b/src/providers/claude/storage/SessionStorage.ts @@ -0,0 +1,5 @@ +export { + LEGACY_SESSIONS_PATH, + SESSIONS_PATH, + SessionStorage, +} from '../../../core/bootstrap/SessionStorage'; diff --git a/src/providers/claude/storage/SkillStorage.ts b/src/providers/claude/storage/SkillStorage.ts new file mode 100644 index 0000000..f0c9ca5 --- /dev/null +++ b/src/providers/claude/storage/SkillStorage.ts @@ -0,0 +1,61 @@ +import type { VaultFileAdapter } from '../../../core/storage/VaultFileAdapter'; +import type { SlashCommand } from '../../../core/types'; +import { parsedToSlashCommand, parseSlashCommandContent, serializeCommand } from '../../../utils/slashCommand'; + +export const SKILLS_PATH = '.claude/skills'; + +export class SkillStorage { + constructor(private adapter: VaultFileAdapter) {} + + async loadAll(): Promise { + const skills: SlashCommand[] = []; + + try { + const folders = await this.adapter.listFolders(SKILLS_PATH); + + for (const folder of folders) { + const skillName = folder.split('/').pop()!; + const skillPath = `${SKILLS_PATH}/${skillName}/SKILL.md`; + + try { + if (!(await this.adapter.exists(skillPath))) continue; + + const content = await this.adapter.read(skillPath); + const parsed = parseSlashCommandContent(content); + + skills.push({ + ...parsedToSlashCommand(parsed, { + id: `skill-${skillName}`, + name: skillName, + source: 'user', + }), + kind: 'skill', + }); + } catch { + // Non-critical: skip malformed skill files + } + } + } catch { + return []; + } + + return skills; + } + + async save(skill: SlashCommand): Promise { + const name = skill.name; + const dirPath = `${SKILLS_PATH}/${name}`; + const filePath = `${dirPath}/SKILL.md`; + + await this.adapter.ensureFolder(dirPath); + await this.adapter.write(filePath, serializeCommand(skill)); + } + + async delete(skillId: string): Promise { + const name = skillId.replace(/^skill-/, ''); + const dirPath = `${SKILLS_PATH}/${name}`; + const filePath = `${dirPath}/SKILL.md`; + await this.adapter.delete(filePath); + await this.adapter.deleteFolder(dirPath); + } +} diff --git a/src/providers/claude/storage/SlashCommandStorage.ts b/src/providers/claude/storage/SlashCommandStorage.ts new file mode 100644 index 0000000..5f79d71 --- /dev/null +++ b/src/providers/claude/storage/SlashCommandStorage.ts @@ -0,0 +1,96 @@ +import type { VaultFileAdapter } from '../../../core/storage/VaultFileAdapter'; +import type { SlashCommand } from '../../../core/types'; +import { parsedToSlashCommand, parseSlashCommandContent, serializeCommand } from '../../../utils/slashCommand'; + +export const COMMANDS_PATH = '.claude/commands'; + +export class SlashCommandStorage { + constructor(private adapter: VaultFileAdapter) {} + + async loadAll(): Promise { + const commands: SlashCommand[] = []; + + try { + const files = await this.adapter.listFilesRecursive(COMMANDS_PATH); + + for (const filePath of files) { + if (!filePath.endsWith('.md')) continue; + + try { + const command = await this.loadFromFile(filePath); + if (command) { + commands.push(command); + } + } catch { + // Non-critical: skip malformed command files + } + } + } catch { + // Non-critical: directory may not exist yet + } + + return commands; + } + + private async loadFromFile(filePath: string): Promise { + const content = await this.adapter.read(filePath); + return this.parseFile(content, filePath); + } + + async save(command: SlashCommand): Promise { + const filePath = this.getFilePath(command); + await this.adapter.write(filePath, serializeCommand(command)); + } + + async delete(commandId: string): Promise { + const files = await this.adapter.listFilesRecursive(COMMANDS_PATH); + + for (const filePath of files) { + if (!filePath.endsWith('.md')) continue; + + const id = this.filePathToId(filePath); + if (id === commandId) { + await this.adapter.delete(filePath); + return; + } + } + } + + getFilePath(command: SlashCommand): string { + const safeName = command.name.replace(/[^a-zA-Z0-9_/-]/g, '-'); + return `${COMMANDS_PATH}/${safeName}.md`; + } + + private parseFile(content: string, filePath: string): SlashCommand { + const parsed = parseSlashCommandContent(content); + return { + ...parsedToSlashCommand(parsed, { + id: this.filePathToId(filePath), + name: this.filePathToName(filePath), + }), + kind: 'command', + }; + } + + private filePathToId(filePath: string): string { + // Encoding: escape `-` as `-_`, then replace `/` with `--` + // This is unambiguous and reversible: + // a/b.md -> cmd-a--b + // a-b.md -> cmd-a-_b + // a--b.md -> cmd-a-_-_b + // a/b-c.md -> cmd-a--b-_c + const relativePath = filePath + .replace(`${COMMANDS_PATH}/`, '') + .replace(/\.md$/, ''); + const escaped = relativePath + .replace(/-/g, '-_') // Escape dashes first + .replace(/\//g, '--'); // Then encode slashes + return `cmd-${escaped}`; + } + + private filePathToName(filePath: string): string { + return filePath + .replace(`${COMMANDS_PATH}/`, '') + .replace(/\.md$/, ''); + } +} diff --git a/src/providers/claude/storage/StorageService.ts b/src/providers/claude/storage/StorageService.ts new file mode 100644 index 0000000..15d8940 --- /dev/null +++ b/src/providers/claude/storage/StorageService.ts @@ -0,0 +1,151 @@ +import type { App } from 'obsidian'; +import { Notice } from 'obsidian'; + +import { ClaudianSettingsStorage, type StoredClaudianSettings } from '../../../app/settings/ClaudianSettingsStorage'; +import { SESSIONS_PATH, SessionStorage } from '../../../core/bootstrap/SessionStorage'; +import { CLAUDIAN_STORAGE_PATH } from '../../../core/bootstrap/StoragePaths'; +import { normalizeTabManagerState } from '../../../core/bootstrap/tabManagerState'; +import type { AppTabManagerState } from '../../../core/providers/types'; +import { VaultFileAdapter } from '../../../core/storage/VaultFileAdapter'; +import type { + SlashCommand, +} from '../../../core/types'; +import { + type CCPermissions, + type CCSettings, + createPermissionRule, +} from '../types/settings'; +import { AGENTS_PATH, AgentVaultStorage } from './AgentVaultStorage'; +import { CCSettingsStorage } from './CCSettingsStorage'; +import { McpStorage } from './McpStorage'; +import { SKILLS_PATH, SkillStorage } from './SkillStorage'; +import { COMMANDS_PATH, SlashCommandStorage } from './SlashCommandStorage'; + +export const CLAUDE_PATH = '.claude'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +export interface CombinedSettings { + cc: CCSettings; + claudian: StoredClaudianSettings; +} + +interface StorageServicePlugin { + readonly app: App; + loadData(): Promise; + saveData(data: unknown): Promise; +} + +export class StorageService { + readonly ccSettings: CCSettingsStorage; + readonly claudianSettings: ClaudianSettingsStorage; + readonly commands: SlashCommandStorage; + readonly skills: SkillStorage; + readonly sessions: SessionStorage; + readonly mcp: McpStorage; + readonly agents: AgentVaultStorage; + + private adapter: VaultFileAdapter; + private plugin: StorageServicePlugin; + private app: App; + + constructor(plugin: StorageServicePlugin, adapter?: VaultFileAdapter) { + this.plugin = plugin; + this.app = plugin.app; + this.adapter = adapter ?? new VaultFileAdapter(this.app); + this.ccSettings = new CCSettingsStorage(this.adapter); + this.claudianSettings = new ClaudianSettingsStorage(this.adapter); + this.commands = new SlashCommandStorage(this.adapter); + this.skills = new SkillStorage(this.adapter); + this.sessions = new SessionStorage(this.adapter); + this.mcp = new McpStorage(this.adapter); + this.agents = new AgentVaultStorage(this.adapter); + } + + async initialize(): Promise { + await this.ensureDirectories(); + + const cc = await this.ccSettings.load(); + const claudian = await this.claudianSettings.load(); + + return { cc, claudian }; + } + + async ensureDirectories(): Promise { + await this.adapter.ensureFolder(CLAUDE_PATH); + await this.adapter.ensureFolder(CLAUDIAN_STORAGE_PATH); + await this.adapter.ensureFolder(COMMANDS_PATH); + await this.adapter.ensureFolder(SKILLS_PATH); + await this.adapter.ensureFolder(SESSIONS_PATH); + await this.adapter.ensureFolder(AGENTS_PATH); + } + + async loadAllSlashCommands(): Promise { + const commands = await this.commands.loadAll(); + const skills = await this.skills.loadAll(); + return [...commands, ...skills]; + } + + getAdapter(): VaultFileAdapter { + return this.adapter; + } + + async getPermissions(): Promise { + return this.ccSettings.getPermissions(); + } + + async updatePermissions(permissions: CCPermissions): Promise { + return this.ccSettings.updatePermissions(permissions); + } + + async addAllowRule(rule: string): Promise { + return this.ccSettings.addAllowRule(createPermissionRule(rule)); + } + + async addDenyRule(rule: string): Promise { + return this.ccSettings.addDenyRule(createPermissionRule(rule)); + } + + async removePermissionRule(rule: string): Promise { + return this.ccSettings.removeRule(createPermissionRule(rule)); + } + + async updateClaudianSettings(updates: Partial): Promise { + return this.claudianSettings.update(updates); + } + + async saveClaudianSettings(settings: StoredClaudianSettings): Promise { + return this.claudianSettings.save(settings); + } + + async loadClaudianSettings(): Promise { + return this.claudianSettings.load(); + } + + async getTabManagerState(): Promise { + try { + const data: unknown = await this.plugin.loadData(); + if (isRecord(data) && data.tabManagerState) { + return normalizeTabManagerState(data.tabManagerState); + } + return null; + } catch { + return null; + } + } + + async setTabManagerState(state: TabManagerPersistedState): Promise { + try { + const loaded: unknown = await this.plugin.loadData(); + const data = isRecord(loaded) ? loaded : {}; + data.tabManagerState = state; + await this.plugin.saveData(data); + } catch { + new Notice('Failed to save tab layout'); + } + } +} + +export type TabManagerPersistedState = AppTabManagerState; diff --git a/src/providers/claude/stream/toolInputStreamState.ts b/src/providers/claude/stream/toolInputStreamState.ts new file mode 100644 index 0000000..e323ebd --- /dev/null +++ b/src/providers/claude/stream/toolInputStreamState.ts @@ -0,0 +1,318 @@ +type JsonTokenType = 'brace' | 'bracket' | 'separator' | 'delimiter' | 'string' | 'number' | 'name'; + +type JsonToken = { + type: JsonTokenType; + value: string; +}; + +type ToolUseSnapshot = { + id: string; + name: string; + input: Record; + partialJson: string; +}; + +type ToolUseFields = { + id: string; + name: string; + input: Record; +}; + +export interface TransformStreamState { + registerToolUse(parentToolUseId: string | null, index: number, toolUse: ToolUseFields): void; + applyInputJsonDelta(parentToolUseId: string | null, index: number, partialJson: string): ToolUseFields | null; + clearContentBlock(parentToolUseId: string | null, index: number): void; + clearParent(parentToolUseId: string | null): void; + clearAll(): void; +} + +const MAIN_AGENT_STREAM = '__main__'; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeToolInput(value: unknown): Record { + return isRecord(value) ? value : {}; +} + +function getContentBlockKey(parentToolUseId: string | null, index: number): string { + return `${parentToolUseId ?? MAIN_AGENT_STREAM}:${index}`; +} + +function getParentPrefix(parentToolUseId: string | null): string { + return `${parentToolUseId ?? MAIN_AGENT_STREAM}:`; +} + +function findClosingTokenIndex(tokens: JsonToken[], value: string): number { + for (let index = tokens.length - 1; index >= 0; index -= 1) { + if (tokens[index]?.value === value) { + return index; + } + } + return -1; +} + +function tokenizePartialJson(input: string): JsonToken[] { + const tokens: JsonToken[] = []; + let index = 0; + + while (index < input.length) { + let char = input[index] ?? ''; + + if (char === '\\') { + index += 1; + continue; + } + + if (char === '{' || char === '}') { + tokens.push({ type: 'brace', value: char }); + index += 1; + continue; + } + + if (char === '[' || char === ']') { + tokens.push({ type: 'bracket', value: char }); + index += 1; + continue; + } + + if (char === ':') { + tokens.push({ type: 'separator', value: char }); + index += 1; + continue; + } + + if (char === ',') { + tokens.push({ type: 'delimiter', value: char }); + index += 1; + continue; + } + + if (char === '"') { + let value = ''; + let isDanglingString = false; + index += 1; + char = input[index] ?? ''; + + while (char !== '"') { + if (index === input.length) { + isDanglingString = true; + break; + } + + if (char === '\\') { + index += 1; + if (index === input.length) { + isDanglingString = true; + break; + } + value += char + (input[index] ?? ''); + index += 1; + char = input[index] ?? ''; + continue; + } + + value += char; + index += 1; + char = input[index] ?? ''; + } + + index += 1; + if (!isDanglingString) { + tokens.push({ type: 'string', value }); + } + continue; + } + + if (/\s/.test(char)) { + index += 1; + continue; + } + + if (/[0-9]/.test(char) || char === '-' || char === '.') { + let value = ''; + + if (char === '-') { + value += char; + index += 1; + char = input[index] ?? ''; + } + + while (/[0-9]/.test(char) || char === '.') { + value += char; + index += 1; + char = input[index] ?? ''; + } + + tokens.push({ type: 'number', value }); + continue; + } + + if (/[a-z]/i.test(char)) { + let value = ''; + + while (/[a-z]/i.test(char)) { + value += char; + index += 1; + char = input[index] ?? ''; + } + + if (value === 'true' || value === 'false' || value === 'null') { + tokens.push({ type: 'name', value }); + } else { + index += 1; + } + continue; + } + + index += 1; + } + + return tokens; +} + +function stripIncompleteTail(tokens: JsonToken[]): JsonToken[] { + if (tokens.length === 0) { + return tokens; + } + + const lastToken = tokens[tokens.length - 1]; + if (!lastToken) { + return tokens; + } + + switch (lastToken.type) { + case 'separator': + case 'delimiter': + return stripIncompleteTail(tokens.slice(0, -1)); + case 'number': { + const lastChar = lastToken.value[lastToken.value.length - 1]; + return lastChar === '.' || lastChar === '-' + ? stripIncompleteTail(tokens.slice(0, -1)) + : tokens; + } + case 'string': { + const previousToken = tokens[tokens.length - 2]; + if (previousToken?.type === 'delimiter') { + return stripIncompleteTail(tokens.slice(0, -1)); + } + if (previousToken?.type === 'brace' && previousToken.value === '{') { + return stripIncompleteTail(tokens.slice(0, -1)); + } + return tokens; + } + default: + return tokens; + } +} + +function closeOpenContainers(tokens: JsonToken[]): JsonToken[] { + const completedTokens = [...tokens]; + const closingTokens: JsonToken[] = []; + + for (const token of completedTokens) { + if (token.type === 'brace') { + if (token.value === '{') { + closingTokens.push({ type: 'brace', value: '}' }); + } else { + const closingIndex = findClosingTokenIndex(closingTokens, '}'); + if (closingIndex >= 0) { + closingTokens.splice(closingIndex, 1); + } + } + continue; + } + + if (token.type === 'bracket') { + if (token.value === '[') { + closingTokens.push({ type: 'bracket', value: ']' }); + } else { + const closingIndex = findClosingTokenIndex(closingTokens, ']'); + if (closingIndex >= 0) { + closingTokens.splice(closingIndex, 1); + } + } + } + } + + for (let index = closingTokens.length - 1; index >= 0; index -= 1) { + const token = closingTokens[index]; + if (token) { + completedTokens.push(token); + } + } + + return completedTokens; +} + +function renderJson(tokens: JsonToken[]): string { + return tokens + .map((token) => token.type === 'string' ? `"${token.value}"` : token.value) + .join(''); +} + +function parsePartialToolInput(input: string): Record | null { + const tokens = tokenizePartialJson(input); + if (tokens.length === 0) { + return {}; + } + + try { + const repairedJson = renderJson(closeOpenContainers(stripIncompleteTail(tokens))); + return normalizeToolInput(JSON.parse(repairedJson)); + } catch { + return null; + } +} + +export function createTransformStreamState(): TransformStreamState { + const activeToolUses = new Map(); + + return { + registerToolUse(parentToolUseId, index, toolUse) { + activeToolUses.set(getContentBlockKey(parentToolUseId, index), { + ...toolUse, + input: { ...toolUse.input }, + partialJson: '', + }); + }, + applyInputJsonDelta(parentToolUseId, index, partialJson) { + const snapshot = activeToolUses.get(getContentBlockKey(parentToolUseId, index)); + if (!snapshot) { + return null; + } + + snapshot.partialJson += partialJson; + const parsedInput = parsePartialToolInput(snapshot.partialJson); + if (parsedInput === null) { + return null; + } + + snapshot.input = { + ...snapshot.input, + ...parsedInput, + }; + + return { + id: snapshot.id, + name: snapshot.name, + input: { ...snapshot.input }, + }; + }, + clearContentBlock(parentToolUseId, index) { + activeToolUses.delete(getContentBlockKey(parentToolUseId, index)); + }, + clearParent(parentToolUseId) { + const parentPrefix = getParentPrefix(parentToolUseId); + for (const key of activeToolUses.keys()) { + if (key.startsWith(parentPrefix)) { + activeToolUses.delete(key); + } + } + }, + clearAll() { + activeToolUses.clear(); + }, + }; +} diff --git a/src/providers/claude/stream/transformClaudeMessage.ts b/src/providers/claude/stream/transformClaudeMessage.ts new file mode 100644 index 0000000..b6cef9d --- /dev/null +++ b/src/providers/claude/stream/transformClaudeMessage.ts @@ -0,0 +1,594 @@ +import type { SDKMessage, SDKResultError } from '@anthropic-ai/claude-agent-sdk'; + +import type { SDKToolUseResult, StreamChunk, UsageInfo } from '../../../core/types'; +import { isBlockedMessage } from '../sdk/messages'; +import { extractToolResultContent } from '../sdk/toolResultContent'; +import type { TransformEvent } from '../sdk/types'; +import { getContextWindowSize, isDefaultClaudeModel } from '../types/models'; +import { createTransformStreamState, type TransformStreamState } from './toolInputStreamState'; + +type ToolUseFields = { id: string; name: string; input: Record }; +type ToolResultFields = { id: string; content: string; isError?: boolean; toolUseResult?: SDKToolUseResult }; +type AsyncSubagentResultStatus = Extract['status']; + +export { createTransformStreamState }; + +function getToolInput(input: unknown): Record { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return {}; + } + return input as Record; +} + +function emitToolUse(parentToolUseId: string | null, fields: ToolUseFields): StreamChunk { + if (parentToolUseId === null) { + return { type: 'tool_use', ...fields }; + } + return { type: 'subagent_tool_use', subagentId: parentToolUseId, ...fields }; +} + +function emitToolResult(parentToolUseId: string | null, fields: ToolResultFields): StreamChunk { + if (parentToolUseId === null) { + return { type: 'tool_result', ...fields }; + } + return { type: 'subagent_tool_result', subagentId: parentToolUseId, ...fields }; +} + +function normalizeTaskNotificationStatus(status: unknown): AsyncSubagentResultStatus { + return status === 'completed' ? 'completed' : 'error'; +} + +function normalizeTaskNotificationResult(status: AsyncSubagentResultStatus, summary: unknown): string { + if (typeof summary === 'string' && summary.trim().length > 0) { + return summary.trim(); + } + return status === 'completed' ? 'Background task completed.' : 'Background task failed.'; +} + +function transformTaskNotification(message: SDKMessage): StreamChunk | null { + if (message.type !== 'system' || message.subtype !== 'task_notification') { + return null; + } + + const record = message as unknown as Record; + const taskId = record.task_id; + if (typeof taskId !== 'string' || taskId.length === 0) { + return null; + } + + const status = normalizeTaskNotificationStatus(record.status); + return { + type: 'async_subagent_result', + agentId: taskId, + status, + result: normalizeTaskNotificationResult(status, record.summary), + }; +} + +export interface TransformOptions { + /** The intended model from settings/query (used for context window size). */ + intendedModel?: string; + /** Custom context limits from settings (model ID → tokens). */ + customContextLimits?: Record; + /** Context window reported by the active provider runtime. */ + authoritativeContextWindow?: number; + /** Tracks active streamed tool blocks so input_json_delta can be normalized. */ + streamState?: TransformStreamState; + /** Tracks prompt-token usage across Anthropic-compatible stream events. */ + usageState?: TransformUsageState; +} + +export interface MessageUsage { + input_tokens?: number; + output_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; +} + +interface PromptUsageSnapshot { + inputTokens: number; + cacheCreationInputTokens: number; + cacheReadInputTokens: number; + contextTokens: number; +} + +export interface TransformUsageState { + clear(): void; + mergePromptUsage(usage: MessageUsage): PromptUsageSnapshot; + getPromptUsage(): PromptUsageSnapshot; + hasEmitted(promptUsage: PromptUsageSnapshot): boolean; + markEmitted(promptUsage: PromptUsageSnapshot): void; +} + +interface ContextWindowEntry { + model: string; + contextWindow: number; +} + +interface ClaudeModelSignature { + normalizedModel: string; + family: 'haiku' | 'sonnet' | 'opus' | 'fable'; + is1M: boolean; + major?: string; + minor?: string; + date?: string; +} + +function isResultError(message: { type: 'result'; subtype: string }): message is SDKResultError { + return !!message.subtype && message.subtype !== 'success'; +} + +function normalizeClaudeModelId(model: string): string { + const normalized = model.trim().toLowerCase(); + const claudeIndex = normalized.indexOf('claude-'); + return claudeIndex >= 0 ? normalized.slice(claudeIndex) : normalized; +} + +function parseClaudeModelSignature(model: string): ClaudeModelSignature | null { + const normalized = normalizeClaudeModelId(model); + if (normalized === 'haiku') { + return { normalizedModel: normalized, family: 'haiku', is1M: false }; + } + if (normalized === 'sonnet' || normalized === 'sonnet[1m]') { + return { normalizedModel: normalized, family: 'sonnet', is1M: normalized.endsWith('[1m]') }; + } + if (normalized === 'opus' || normalized === 'opus[1m]') { + return { normalizedModel: normalized, family: 'opus', is1M: normalized.endsWith('[1m]') }; + } + + const versionedMatch = normalized.match( + /^claude-(haiku|sonnet|opus|fable)-(\d+)(?:-(\d+))?(?:-(\d{8}))?(?:-v\d+:\d+)?(\[1m\])?$/, + ); + if (versionedMatch) { + const [, familyMatch, major, minor, date, oneMillionSuffix] = versionedMatch; + const family = familyMatch as ClaudeModelSignature['family']; + return { + normalizedModel: normalized, + family, + is1M: oneMillionSuffix === '[1m]', + major, + minor, + date, + }; + } + + return null; +} + +function findUniqueEntry( + entries: ContextWindowEntry[], + predicate: (entry: ContextWindowEntry) => boolean, +): ContextWindowEntry | null { + const matches = entries.filter(predicate); + return matches.length === 1 ? matches[0] : null; +} + +function matchClaudeModelSignature( + entrySignature: ClaudeModelSignature | null, + intendedSignature: ClaudeModelSignature, + options?: { ignoreIs1M?: boolean }, +): boolean { + if (!entrySignature || entrySignature.family !== intendedSignature.family) { + return false; + } + if (!options?.ignoreIs1M && entrySignature.is1M !== intendedSignature.is1M) { + return false; + } + if (intendedSignature.major && entrySignature.major !== intendedSignature.major) { + return false; + } + if (intendedSignature.minor && entrySignature.minor !== intendedSignature.minor) { + return false; + } + if (intendedSignature.date && entrySignature.date !== intendedSignature.date) { + return false; + } + return true; +} + +function selectContextWindowEntry( + modelUsage: Record, + intendedModel?: string +): ContextWindowEntry | null { + const entries: ContextWindowEntry[] = Object.entries(modelUsage) + .flatMap(([model, usage]) => + typeof usage?.contextWindow === 'number' && usage.contextWindow > 0 + ? [{ model, contextWindow: usage.contextWindow }] + : [] + ); + + if (entries.length === 0) { + return null; + } + + if (entries.length === 1) { + return entries[0]; + } + + if (!intendedModel) { + return null; + } + + const literalExactMatch = entries.find((entry) => entry.model === intendedModel); + if (literalExactMatch) { + return literalExactMatch; + } + + const normalizedIntendedModel = normalizeClaudeModelId(intendedModel); + const exactMatch = findUniqueEntry(entries, (entry) => normalizeClaudeModelId(entry.model) === normalizedIntendedModel); + if (exactMatch) { + return exactMatch; + } + + if (!isDefaultClaudeModel(intendedModel)) { + return null; + } + + const intendedSignature = parseClaudeModelSignature(intendedModel); + if (!intendedSignature) { + return null; + } + + const strictSignatureMatch = findUniqueEntry(entries, (entry) => + matchClaudeModelSignature(parseClaudeModelSignature(entry.model), intendedSignature), + ); + if (strictSignatureMatch) { + return strictSignatureMatch; + } + + const hasVersionedTarget = Boolean(intendedSignature.major || intendedSignature.date); + if (!hasVersionedTarget) { + return null; + } + + return findUniqueEntry(entries, (entry) => + matchClaudeModelSignature(parseClaudeModelSignature(entry.model), intendedSignature, { ignoreIs1M: true }), + ); +} + +const EMPTY_PROMPT_USAGE: PromptUsageSnapshot = { + inputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + contextTokens: 0, +}; + +function normalizeTokenCount(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0; +} + +function hasPromptUsageField(usage: unknown): usage is MessageUsage { + if (!usage || typeof usage !== 'object' || Array.isArray(usage)) { + return false; + } + + const record = usage as Record; + return typeof record.input_tokens === 'number' + || typeof record.cache_creation_input_tokens === 'number' + || typeof record.cache_read_input_tokens === 'number'; +} + +function toPromptUsageSnapshot(usage: MessageUsage): PromptUsageSnapshot { + const inputTokens = normalizeTokenCount(usage.input_tokens); + const cacheCreationInputTokens = normalizeTokenCount(usage.cache_creation_input_tokens); + const cacheReadInputTokens = normalizeTokenCount(usage.cache_read_input_tokens); + return { + inputTokens, + cacheCreationInputTokens, + cacheReadInputTokens, + contextTokens: inputTokens + cacheCreationInputTokens + cacheReadInputTokens, + }; +} + +function mergePromptUsage( + current: PromptUsageSnapshot, + usage: MessageUsage, +): PromptUsageSnapshot { + const next = toPromptUsageSnapshot(usage); + const inputTokens = Math.max(current.inputTokens, next.inputTokens); + const cacheCreationInputTokens = Math.max(current.cacheCreationInputTokens, next.cacheCreationInputTokens); + const cacheReadInputTokens = Math.max(current.cacheReadInputTokens, next.cacheReadInputTokens); + return { + inputTokens, + cacheCreationInputTokens, + cacheReadInputTokens, + contextTokens: inputTokens + cacheCreationInputTokens + cacheReadInputTokens, + }; +} + +function samePromptUsage(a: PromptUsageSnapshot, b: PromptUsageSnapshot): boolean { + return a.inputTokens === b.inputTokens + && a.cacheCreationInputTokens === b.cacheCreationInputTokens + && a.cacheReadInputTokens === b.cacheReadInputTokens + && a.contextTokens === b.contextTokens; +} + +function buildUsageInfo(promptUsage: PromptUsageSnapshot, options?: TransformOptions): UsageInfo { + const model = options?.intendedModel ?? 'sonnet'; + const hasAuthoritativeContextWindow = typeof options?.authoritativeContextWindow === 'number' + && options.authoritativeContextWindow > 0 + && Number.isFinite(options.authoritativeContextWindow); + const contextWindow = hasAuthoritativeContextWindow + ? options.authoritativeContextWindow! + : getContextWindowSize(model, options?.customContextLimits); + const percentage = Math.min(100, Math.max(0, Math.round((promptUsage.contextTokens / contextWindow) * 100))); + + return { + model, + inputTokens: promptUsage.inputTokens, + cacheCreationInputTokens: promptUsage.cacheCreationInputTokens, + cacheReadInputTokens: promptUsage.cacheReadInputTokens, + contextWindow, + ...(hasAuthoritativeContextWindow ? { contextWindowIsAuthoritative: true } : {}), + contextTokens: promptUsage.contextTokens, + percentage, + }; +} + +export function createTransformUsageState(): TransformUsageState { + let promptUsage: PromptUsageSnapshot = { ...EMPTY_PROMPT_USAGE }; + let lastEmittedPromptUsage: PromptUsageSnapshot | null = null; + + return { + clear(): void { + promptUsage = { ...EMPTY_PROMPT_USAGE }; + lastEmittedPromptUsage = null; + }, + + mergePromptUsage(usage: MessageUsage): PromptUsageSnapshot { + promptUsage = mergePromptUsage(promptUsage, usage); + return promptUsage; + }, + + getPromptUsage(): PromptUsageSnapshot { + return { ...promptUsage }; + }, + + hasEmitted(nextPromptUsage: PromptUsageSnapshot): boolean { + return lastEmittedPromptUsage !== null && samePromptUsage(lastEmittedPromptUsage, nextPromptUsage); + }, + + markEmitted(nextPromptUsage: PromptUsageSnapshot): void { + lastEmittedPromptUsage = { ...nextPromptUsage }; + }, + }; +} + +function maybeEmitUsageFromPromptUsage( + promptUsage: PromptUsageSnapshot, + options?: TransformOptions, + behavior: { emitZeroUsage?: boolean } = {}, +): StreamChunk | null { + if (promptUsage.contextTokens <= 0) { + return behavior.emitZeroUsage + ? { type: 'usage', usage: buildUsageInfo(promptUsage, options) } + : null; + } + + if (options?.usageState?.hasEmitted(promptUsage)) { + return null; + } + + options?.usageState?.markEmitted(promptUsage); + return { type: 'usage', usage: buildUsageInfo(promptUsage, options) }; +} + +/** + * Transform SDK message to StreamChunk format. + * One SDK message can yield multiple chunks (e.g., text + tool_use blocks). + */ +export function* transformSDKMessage( + message: SDKMessage, + options?: TransformOptions +): Generator { + switch (message.type) { + case 'system': + if (message.subtype === 'init' && message.session_id) { + yield { + type: 'session_init', + sessionId: message.session_id, + agents: message.agents, + permissionMode: message.permissionMode, + }; + } else if (message.subtype === 'compact_boundary') { + yield { type: 'context_compacted' }; + } else if (message.subtype === 'task_notification') { + const notification = transformTaskNotification(message); + if (notification) { + yield notification; + } + } + break; + + case 'assistant': { + const parentToolUseId = message.parent_tool_use_id ?? null; + + // Errors on assistant messages (e.g. rate_limit, billing_error) + if (message.error) { + yield { type: 'error', content: message.error }; + } + + if (message.message?.content && Array.isArray(message.message.content)) { + for (const block of message.message.content) { + if (block.type === 'thinking' && block.thinking) { + if (parentToolUseId === null) { + yield { type: 'thinking', content: block.thinking }; + } + } else if (block.type === 'text' && block.text && block.text.trim() !== '(no content)') { + if (parentToolUseId === null) { + yield { type: 'text', content: block.text }; + } + } else if (block.type === 'tool_use') { + yield emitToolUse(parentToolUseId, { + id: block.id || `tool-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, + name: block.name || 'unknown', + input: getToolInput(block.input), + }); + } + } + } + + options?.streamState?.clearParent(parentToolUseId); + + // Extract usage from main agent assistant messages only (not subagent) + // This gives accurate per-turn context usage without subagent token pollution + const usage = (message.message as { usage?: MessageUsage } | undefined)?.usage; + if (parentToolUseId === null && usage) { + if (options?.usageState) { + const promptUsage = options.usageState.mergePromptUsage(usage); + const usageChunk = maybeEmitUsageFromPromptUsage(promptUsage, options, { emitZeroUsage: true }); + if (usageChunk) { + yield usageChunk; + } + } else { + yield { type: 'usage', usage: buildUsageInfo(toPromptUsageSnapshot(usage), options) }; + } + } + break; + } + + case 'user': { + const parentToolUseId = message.parent_tool_use_id ?? null; + + // Check for blocked tool calls (from hook denials) + if (isBlockedMessage(message)) { + yield { + type: 'notice', + content: message._blockReason, + level: 'warning', + }; + break; + } + // User messages can contain tool results + if (message.tool_use_result !== undefined && message.parent_tool_use_id) { + const toolUseResult = (message.tool_use_result ?? undefined) as SDKToolUseResult | undefined; + yield emitToolResult(parentToolUseId, { + id: message.parent_tool_use_id, + content: extractToolResultContent(message.tool_use_result, { fallbackIndent: 2 }), + isError: false, + ...(toolUseResult !== undefined ? { toolUseResult } : {}), + }); + } + // Also check message.message.content for tool_result blocks + if (message.message?.content && Array.isArray(message.message.content)) { + for (const block of message.message.content) { + if (block.type === 'tool_result') { + const toolUseResult = (message.tool_use_result ?? undefined) as SDKToolUseResult | undefined; + yield emitToolResult(parentToolUseId, { + id: block.tool_use_id || message.parent_tool_use_id || '', + content: extractToolResultContent(block.content, { fallbackIndent: 2 }), + isError: block.is_error || false, + ...(toolUseResult !== undefined ? { toolUseResult } : {}), + }); + } + } + } + break; + } + + case 'stream_event': { + const parentToolUseId = message.parent_tool_use_id ?? null; + const event = message.event; + if (parentToolUseId === null && event?.type === 'message_start') { + options?.usageState?.clear(); + const usage = (event.message as { usage?: MessageUsage } | undefined)?.usage; + if (usage && hasPromptUsageField(usage)) { + if (options?.usageState) { + options.usageState.mergePromptUsage(usage); + } else { + const usageChunk = maybeEmitUsageFromPromptUsage(toPromptUsageSnapshot(usage), options); + if (usageChunk) { + yield usageChunk; + } + } + } + } else if (parentToolUseId === null && event?.type === 'message_delta' && hasPromptUsageField(event.usage)) { + if (options?.usageState) { + const previousPromptUsage = options.usageState.getPromptUsage(); + const promptUsage = options.usageState.mergePromptUsage(event.usage); + const shouldEmitDeltaUsage = previousPromptUsage.contextTokens <= 0 + || options.usageState.hasEmitted(previousPromptUsage); + if (shouldEmitDeltaUsage) { + const usageChunk = maybeEmitUsageFromPromptUsage(promptUsage, options); + if (usageChunk) { + yield usageChunk; + } + } + } else { + const usageChunk = maybeEmitUsageFromPromptUsage(toPromptUsageSnapshot(event.usage), options); + if (usageChunk) { + yield usageChunk; + } + } + } else if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') { + const toolUseFields: ToolUseFields = { + id: event.content_block.id || `tool-${Date.now()}`, + name: event.content_block.name || 'unknown', + input: getToolInput(event.content_block.input), + }; + if (typeof event.index === 'number') { + options?.streamState?.registerToolUse(parentToolUseId, event.index, toolUseFields); + } + yield emitToolUse(parentToolUseId, toolUseFields); + } else if (event?.type === 'content_block_start' && event.content_block?.type === 'thinking') { + if (parentToolUseId === null && event.content_block.thinking) { + yield { type: 'thinking', content: event.content_block.thinking }; + } + } else if (event?.type === 'content_block_start' && event.content_block?.type === 'text') { + if (parentToolUseId === null && event.content_block.text) { + yield { type: 'text', content: event.content_block.text }; + } + } else if (event?.type === 'content_block_delta') { + if (event.delta?.type === 'input_json_delta' && typeof event.index === 'number') { + const toolUseFields = options?.streamState?.applyInputJsonDelta( + parentToolUseId, + event.index, + event.delta.partial_json, + ); + if (toolUseFields) { + yield emitToolUse(parentToolUseId, toolUseFields); + } + } else if (parentToolUseId === null && event.delta?.type === 'thinking_delta' && event.delta.thinking) { + yield { type: 'thinking', content: event.delta.thinking }; + } else if (parentToolUseId === null && event.delta?.type === 'text_delta' && event.delta.text) { + yield { type: 'text', content: event.delta.text }; + } + } else if (event?.type === 'content_block_stop' && typeof event.index === 'number') { + options?.streamState?.clearContentBlock(parentToolUseId, event.index); + } + break; + } + + case 'result': + options?.streamState?.clearAll(); + if (options?.usageState) { + const usageChunk = maybeEmitUsageFromPromptUsage(options.usageState.getPromptUsage(), options); + if (usageChunk) { + yield usageChunk; + } + options.usageState.clear(); + } + if (isResultError(message)) { + const content = message.errors.filter((e) => e.trim().length > 0).join('\n'); + yield { + type: 'error', + content: content || `Result error: ${message.subtype}`, + }; + } + + // Usage is now extracted from assistant messages for accuracy (excludes subagent tokens) + // Result message usage is aggregated across main + subagents, causing inaccurate spikes + + if ('modelUsage' in message && message.modelUsage) { + const modelUsage = message.modelUsage as Record; + const selectedEntry = selectContextWindowEntry(modelUsage, options?.intendedModel); + if (selectedEntry) { + yield { type: 'context_window', contextWindow: selectedEntry.contextWindow }; + } + } + break; + + default: + break; + } +} diff --git a/src/providers/claude/types/agent.ts b/src/providers/claude/types/agent.ts new file mode 100644 index 0000000..32363b5 --- /dev/null +++ b/src/providers/claude/types/agent.ts @@ -0,0 +1,2 @@ +export const AGENT_PERMISSION_MODES = ['default', 'acceptEdits', 'auto', 'dontAsk', 'bypassPermissions', 'plan', 'delegate'] as const; +export type AgentPermissionMode = typeof AGENT_PERMISSION_MODES[number]; diff --git a/src/providers/claude/types/models.ts b/src/providers/claude/types/models.ts new file mode 100644 index 0000000..d07bd5f --- /dev/null +++ b/src/providers/claude/types/models.ts @@ -0,0 +1,167 @@ +/** + * Model type definitions and constants. + */ + +import { DEFAULT_REASONING_VALUE } from '../../../core/providers/reasoning'; +import { toClaudeRuntimeModelId } from '../modelSelection'; + +/** Model identifier (string to support custom models via environment variables). */ +export type ClaudeModel = string; + +export const DEFAULT_CLAUDE_MODELS: { value: ClaudeModel; label: string; description: string }[] = [ + { value: 'haiku', label: 'Haiku', description: 'Fast and efficient' }, + { value: 'sonnet', label: 'Sonnet', description: 'Balanced performance' }, + { value: 'opus', label: 'Opus', description: 'Most capable' }, + { value: 'claude-fable-5', label: 'Fable 5 ($$$)', description: "Anthropic's most capable model — premium pricing above Opus" }, +]; + +/** Effort levels for adaptive thinking models. */ +export type EffortLevel = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + +export const EFFORT_LEVELS: { value: EffortLevel; label: string }[] = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Med' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'XHigh' }, + { value: 'max', label: 'Max' }, +]; + +/** Default effort level per model tier. */ +export const DEFAULT_EFFORT_LEVEL: Record = { + 'haiku': DEFAULT_REASONING_VALUE, + 'sonnet': DEFAULT_REASONING_VALUE, + 'opus': DEFAULT_REASONING_VALUE, + 'claude-fable-5': DEFAULT_REASONING_VALUE, +}; + +const ONE_M_SUFFIX = '[1m]'; +const DEFAULT_MODEL_VALUES = new Set(DEFAULT_CLAUDE_MODELS.map(m => m.value.toLowerCase())); + +function normalizeModelId(model: string): string { + return toClaudeRuntimeModelId(model).trim().toLowerCase(); +} + +function isBuiltInFamilyVariant(model: string, family: 'sonnet' | 'opus'): boolean { + const normalized = normalizeModelId(model); + return normalized === family || normalized === `${family}${ONE_M_SUFFIX}`; +} + +export function normalizeLegacy1MModelAlias(model: string): string { + if (isBuiltInFamilyVariant(model, 'sonnet')) { + return 'sonnet'; + } + if (isBuiltInFamilyVariant(model, 'opus')) { + return 'opus'; + } + return model; +} + +/** Fable is a standalone tier (not a haiku/sonnet/opus version bump) — always 1M context, no [1m] toggle. */ +function isFableModel(model: string): boolean { + return /claude-fable-\d+/.test(normalizeModelId(model)); +} + +function isValidContextLimit(limit: unknown): limit is number { + return typeof limit === 'number' && limit > 0 && !isNaN(limit) && isFinite(limit); +} + +function resolveCustomContextLimit( + model: string, + customLimits?: Record, +): number | null { + if (!customLimits) { + return null; + } + + const exactLimit = customLimits[model]; + if (isValidContextLimit(exactLimit)) { + return exactLimit; + } + + const normalizedModel = normalizeModelId(model); + const matchingLimits = Object.entries(customLimits) + .filter(([key, limit]) => key !== model && normalizeModelId(key) === normalizedModel && isValidContextLimit(limit)) + .map(([, limit]) => limit); + + return matchingLimits.length === 1 ? matchingLimits[0] : null; +} + +export function isDefaultClaudeModel(model: string): boolean { + return DEFAULT_MODEL_VALUES.has(normalizeModelId(normalizeLegacy1MModelAlias(model))); +} + +/** + * Whether the model supports the `xhigh` effort level. Supported on Opus 4.7+, + * Sonnet 5+, and Fable — the SDK silently falls back to `high` on other models. + */ +export function supportsXHighEffort(model: string): boolean { + const normalized = normalizeModelId(model); + if (isBuiltInFamilyVariant(normalized, 'opus')) return true; + if (isBuiltInFamilyVariant(normalized, 'sonnet')) return true; + if (isFableModel(normalized)) return true; + return ( + /claude-opus-(4-[7-9]|[5-9])/.test(normalized) || + /claude-sonnet-(?:[5-9]|\d{2,})(?:-\d{8})?(?:-|$)/.test(normalized) + ); +} + +/** Clamp stored effort values to what the selected model actually supports. */ +export function normalizeEffortLevel( + model: string, + effortLevel: unknown, +): EffortLevel { + const allowsXHigh = supportsXHighEffort(model); + const isSupported = EFFORT_LEVELS.some((level) => + level.value === effortLevel && (allowsXHigh || level.value !== 'xhigh') + ); + + if (isSupported) { + return effortLevel as EffortLevel; + } + + return DEFAULT_EFFORT_LEVEL[normalizeModelId(model)] ?? DEFAULT_REASONING_VALUE; +} + +export function resolveEffortLevel( + model: string, + effortLevel: unknown, +): EffortLevel { + return normalizeEffortLevel(model, effortLevel); +} + +export const CONTEXT_WINDOW_STANDARD = 200_000; +export const CONTEXT_WINDOW_1M = 1_000_000; + +function isCurrentOneMillionContextModel(model: string): boolean { + const normalized = normalizeModelId(normalizeLegacy1MModelAlias(model)); + if (normalized === 'opus' || normalized === 'sonnet' || isFableModel(normalized)) { + return true; + } + + const canonicalStart = normalized.indexOf('claude-'); + const canonical = canonicalStart >= 0 ? normalized.slice(canonicalStart) : normalized; + const versionMatch = canonical.match(/^claude-(opus|sonnet)-(\d+)(?:-(\d+))?/); + if (!versionMatch) { + return false; + } + + const major = Number(versionMatch[2]); + const minor = versionMatch[3] === undefined ? 0 : Number(versionMatch[3]); + return major > 4 || (major === 4 && minor >= 6); +} + +export function getContextWindowSize( + model: string, + customLimits?: Record +): number { + const customLimit = resolveCustomContextLimit(model, customLimits); + if (customLimit !== null) { + return customLimit; + } + + if (isCurrentOneMillionContextModel(model)) { + return CONTEXT_WINDOW_1M; + } + + return CONTEXT_WINDOW_STANDARD; +} diff --git a/src/providers/claude/types/plugins.ts b/src/providers/claude/types/plugins.ts new file mode 100644 index 0000000..0757930 --- /dev/null +++ b/src/providers/claude/types/plugins.ts @@ -0,0 +1,14 @@ +export interface InstalledPluginEntry { + scope: 'user' | 'project'; + installPath: string; + version: string; + installedAt: string; + lastUpdated: string; + gitCommitSha?: string; + projectPath?: string; +} + +export interface InstalledPluginsFile { + version: number; + plugins: Record; +} diff --git a/src/providers/claude/types/providerState.ts b/src/providers/claude/types/providerState.ts new file mode 100644 index 0000000..6ad218c --- /dev/null +++ b/src/providers/claude/types/providerState.ts @@ -0,0 +1,39 @@ +import type { Conversation } from '../../../core/types'; +import type { ForkSource } from '../../../core/types/chat'; +import type { SubagentInfo } from '../../../core/types/tools'; + +export interface ClaudeProviderState { + providerSessionId?: string; + previousProviderSessionIds?: string[]; + forkSource?: ForkSource; + subagentData?: Record; +} + +/** Extracts typed Claude provider state from the opaque bag. */ +export function getClaudeState( + providerState: Record | undefined, +): ClaudeProviderState { + return (providerState ?? {}); +} + +export function clearClaudeResumeState(conversation: Conversation): boolean { + const providerState = { ...(conversation.providerState ?? {}) }; + const hadResumeState = conversation.sessionId != null + || conversation.resumeAtMessageId != null + || typeof providerState.providerSessionId === 'string' + || Array.isArray(providerState.previousProviderSessionIds) + || providerState.forkSource !== undefined; + if (!hadResumeState) { + return false; + } + + conversation.sessionId = null; + delete conversation.resumeAtMessageId; + delete providerState.providerSessionId; + delete providerState.previousProviderSessionIds; + delete providerState.forkSource; + conversation.providerState = Object.keys(providerState).length > 0 + ? providerState + : undefined; + return true; +} diff --git a/src/providers/claude/types/settings.ts b/src/providers/claude/types/settings.ts new file mode 100644 index 0000000..29aa3c1 --- /dev/null +++ b/src/providers/claude/types/settings.ts @@ -0,0 +1,98 @@ +/** Claude provider settings and Claude Code compatibility types. */ + +// Re-export shared defaults for backward compatibility within the Claude package +export { DEFAULT_CLAUDIAN_SETTINGS as DEFAULT_SETTINGS } from '../../../app/settings/defaultSettings'; + +/** + * CC-compatible permission rule string. + * Format: "Tool(pattern)" or "Tool" for all + * Examples: "Bash(git *)", "Read(*.md)", "WebFetch(domain:github.com)" + */ +export type PermissionRule = string & { readonly __brand: 'PermissionRule' }; + +/** + * Create a PermissionRule from a string. + */ +export function createPermissionRule(rule: string): PermissionRule { + return rule as PermissionRule; +} + +/** + * CC-compatible permissions object. + * Stored in .claude/settings.json for interoperability with Claude Code CLI. + */ +export interface CCPermissions { + /** Rules that auto-approve tool actions */ + allow?: PermissionRule[]; + /** Rules that auto-deny tool actions (highest persistent priority) */ + deny?: PermissionRule[]; + /** Rules that always prompt for confirmation */ + ask?: PermissionRule[]; + /** Default permission mode */ + defaultMode?: 'acceptEdits' | 'auto' | 'bypassPermissions' | 'default' | 'dontAsk' | 'plan'; + /** Additional directories to include in permission scope */ + additionalDirectories?: string[]; +} + +/** + * CC-compatible settings stored in .claude/settings.json. + * These settings are shared with Claude Code CLI. + */ +export interface CCSettings { + /** JSON Schema reference */ + $schema?: string; + /** Tool permissions (CC format) */ + permissions?: CCPermissions; + /** Model override */ + model?: string; + /** Environment variables (object format) */ + env?: Record; + /** MCP server settings */ + enableAllProjectMcpServers?: boolean; + enabledMcpjsonServers?: string[]; + disabledMcpjsonServers?: string[]; + /** Plugin enabled state (CC format: { "plugin-id": true/false }) */ + enabledPlugins?: Record; + /** Allow additional properties for CC compatibility */ + [key: string]: unknown; +} + +// Old DEFAULT_SETTINGS constant has been moved to src/app/settings/defaultSettings.ts. +// Re-exported above for backward compatibility within the Claude package. + +/** Default CC-compatible settings. */ +export const DEFAULT_CC_SETTINGS: CCSettings = { + $schema: 'https://json.schemastore.org/claude-code-settings.json', + permissions: { + allow: [], + deny: [], + ask: [], + }, +}; + +/** Default CC permissions. */ +export const DEFAULT_CC_PERMISSIONS: CCPermissions = { + allow: [], + deny: [], + ask: [], +}; + +/** + * Parse a CC permission rule into tool name and pattern. + * Examples: + * "Bash(git *)" → { tool: "Bash", pattern: "git *" } + * "Read" → { tool: "Read", pattern: undefined } + * "WebFetch(domain:github.com)" → { tool: "WebFetch", pattern: "domain:github.com" } + */ +export function parseCCPermissionRule(rule: PermissionRule): { + tool: string; + pattern?: string; +} { + const match = rule.match(/^(\w+)(?:\((.+)\))?$/); + if (!match) { + return { tool: rule }; + } + + const [, tool, pattern] = match; + return { tool, pattern }; +} diff --git a/src/providers/claude/ui/AgentSettings.ts b/src/providers/claude/ui/AgentSettings.ts new file mode 100644 index 0000000..9546e9d --- /dev/null +++ b/src/providers/claude/ui/AgentSettings.ts @@ -0,0 +1,389 @@ +import type { App } from 'obsidian'; +import { Modal, Notice, setIcon, Setting } from 'obsidian'; + +import type { + AppAgentManager, + AppAgentStorage, +} from '../../../core/providers/types'; +import type { AgentDefinition } from '../../../core/types'; +import { t } from '../../../i18n/i18n'; +import { confirmDelete } from '../../../shared/modals/ConfirmModal'; +import { validateAgentName } from '../../../utils/agent'; + +const MODEL_OPTIONS = [ + { value: 'inherit', label: 'Inherit' }, + { value: 'sonnet', label: 'Sonnet' }, + { value: 'opus', label: 'Opus' }, + { value: 'haiku', label: 'Haiku' }, +] as const; + +class AgentModal extends Modal { + private existingAgent: AgentDefinition | null; + private getAvailableAgents: () => AgentDefinition[]; + private onSave: (agent: AgentDefinition) => Promise; + + constructor( + app: App, + existingAgent: AgentDefinition | null, + getAvailableAgents: () => AgentDefinition[], + onSave: (agent: AgentDefinition) => Promise + ) { + super(app); + this.existingAgent = existingAgent; + this.getAvailableAgents = getAvailableAgents; + this.onSave = onSave; + } + + onOpen() { + this.setTitle( + this.existingAgent + ? t('settings.subagents.modal.titleEdit') + : t('settings.subagents.modal.titleAdd') + ); + this.modalEl.addClass('claudian-sp-modal'); + + const { contentEl } = this; + + let nameInput: HTMLInputElement; + let descInput: HTMLInputElement; + let modelValue: string = this.existingAgent?.model ?? 'inherit'; + let toolsInput: HTMLInputElement; + let disallowedToolsInput: HTMLInputElement; + let skillsInput: HTMLInputElement; + + new Setting(contentEl) + .setName(t('settings.subagents.modal.name')) + .setDesc(t('settings.subagents.modal.nameDesc')) + .addText(text => { + nameInput = text.inputEl; + text.setValue(this.existingAgent?.name || '') + .setPlaceholder(t('settings.subagents.modal.namePlaceholder')); + }); + + new Setting(contentEl) + .setName(t('settings.subagents.modal.description')) + .setDesc(t('settings.subagents.modal.descriptionDesc')) + .addText(text => { + descInput = text.inputEl; + text.setValue(this.existingAgent?.description || '') + .setPlaceholder(t('settings.subagents.modal.descriptionPlaceholder')); + }); + + const details = contentEl.createEl('details', { cls: 'claudian-sp-advanced-section' }); + details.createEl('summary', { + text: t('settings.subagents.modal.advancedOptions'), + cls: 'claudian-sp-advanced-summary', + }); + if ((this.existingAgent?.model && this.existingAgent.model !== 'inherit') || + this.existingAgent?.tools?.length || + this.existingAgent?.disallowedTools?.length || + this.existingAgent?.skills?.length) { + details.open = true; + } + + new Setting(details) + .setName(t('settings.subagents.modal.model')) + .setDesc(t('settings.subagents.modal.modelDesc')) + .addDropdown(dropdown => { + for (const opt of MODEL_OPTIONS) { + dropdown.addOption(opt.value, opt.label); + } + dropdown + .setValue(modelValue) + .onChange(value => { modelValue = value; }); + }); + + new Setting(details) + .setName(t('settings.subagents.modal.tools')) + .setDesc(t('settings.subagents.modal.toolsDesc')) + .addText(text => { + toolsInput = text.inputEl; + text.setValue(this.existingAgent?.tools?.join(', ') || ''); + }); + + new Setting(details) + .setName(t('settings.subagents.modal.disallowedTools')) + .setDesc(t('settings.subagents.modal.disallowedToolsDesc')) + .addText(text => { + disallowedToolsInput = text.inputEl; + text.setValue(this.existingAgent?.disallowedTools?.join(', ') || ''); + }); + + new Setting(details) + .setName(t('settings.subagents.modal.skills')) + .setDesc(t('settings.subagents.modal.skillsDesc')) + .addText(text => { + skillsInput = text.inputEl; + text.setValue(this.existingAgent?.skills?.join(', ') || ''); + }); + + new Setting(contentEl) + .setName(t('settings.subagents.modal.prompt')) + .setDesc(t('settings.subagents.modal.promptDesc')); + + const contentArea = contentEl.createEl('textarea', { + cls: 'claudian-sp-content-area', + attr: { + rows: '10', + placeholder: t('settings.subagents.modal.promptPlaceholder'), + }, + }); + contentArea.value = this.existingAgent?.prompt || ''; + + const buttonContainer = contentEl.createDiv({ cls: 'claudian-sp-modal-buttons' }); + + const cancelBtn = buttonContainer.createEl('button', { + text: t('common.cancel'), + cls: 'claudian-cancel-btn', + }); + cancelBtn.addEventListener('click', () => this.close()); + + const saveBtn = buttonContainer.createEl('button', { + text: t('common.save'), + cls: 'claudian-save-btn', + }); + saveBtn.addEventListener('click', () => { + void (async (): Promise => { + const name = nameInput.value.trim(); + const nameError = validateAgentName(name); + if (nameError) { + new Notice(nameError); + return; + } + + const description = descInput.value.trim(); + if (!description) { + new Notice(t('settings.subagents.descriptionRequired')); + return; + } + + const prompt = contentArea.value; + if (!prompt.trim()) { + new Notice(t('settings.subagents.promptRequired')); + return; + } + + const allAgents = this.getAvailableAgents(); + const duplicate = allAgents.find( + a => a.id.toLowerCase() === name.toLowerCase() && + a.id !== this.existingAgent?.id + ); + if (duplicate) { + new Notice(t('settings.subagents.duplicateName', { name })); + return; + } + + const parseList = (input: HTMLInputElement): string[] | undefined => { + const val = input.value.trim(); + if (!val) return undefined; + return val.split(',').map(s => s.trim()).filter(Boolean); + }; + + const agent: AgentDefinition = { + id: name, + name, + description, + prompt, + tools: parseList(toolsInput), + disallowedTools: parseList(disallowedToolsInput), + model: (modelValue) || 'inherit', + source: 'vault', + filePath: this.existingAgent?.filePath, + skills: parseList(skillsInput), + permissionMode: this.existingAgent?.permissionMode, + hooks: this.existingAgent?.hooks, + extraFrontmatter: this.existingAgent?.extraFrontmatter, + }; + + try { + await this.onSave(agent); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + new Notice(t('settings.subagents.saveFailed', { message })); + return; + } + this.close(); + })(); + }); + } + + onClose() { + this.contentEl.empty(); + } +} + +export interface AgentSettingsDeps { + app: App; + agentManager: Pick; + agentStorage: Pick; +} + +export class AgentSettings { + private app: App; + private containerEl: HTMLElement; + private agentManager: Pick; + private agentStorage: Pick; + + constructor(containerEl: HTMLElement, deps: AgentSettingsDeps) { + this.app = deps.app; + this.containerEl = containerEl; + this.agentManager = deps.agentManager; + this.agentStorage = deps.agentStorage; + this.render(); + } + + private render(): void { + this.containerEl.empty(); + + const headerEl = this.containerEl.createDiv({ cls: 'claudian-sp-header' }); + headerEl.createSpan({ text: t('settings.subagents.name'), cls: 'claudian-sp-label' }); + + const actionsEl = headerEl.createDiv({ cls: 'claudian-sp-header-actions' }); + + const refreshBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': t('common.refresh') }, + }); + setIcon(refreshBtn, 'refresh-cw'); + refreshBtn.addEventListener('click', () => { void this.refreshAgents(); }); + + const addBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': t('common.add') }, + }); + setIcon(addBtn, 'plus'); + addBtn.addEventListener('click', () => { void this.openAgentModal(null); }); + + const allAgents = this.agentManager.getAvailableAgents(); + const vaultAgents = allAgents.filter(a => a.source === 'vault'); + + if (vaultAgents.length === 0) { + const emptyEl = this.containerEl.createDiv({ cls: 'claudian-sp-empty-state' }); + emptyEl.setText(t('settings.subagents.noAgents')); + return; + } + + const listEl = this.containerEl.createDiv({ cls: 'claudian-sp-list' }); + + for (const agent of vaultAgents) { + this.renderAgentItem(listEl, agent); + } + } + + private renderAgentItem(listEl: HTMLElement, agent: AgentDefinition): void { + const itemEl = listEl.createDiv({ cls: 'claudian-sp-item' }); + + const infoEl = itemEl.createDiv({ cls: 'claudian-sp-info' }); + + const headerRow = infoEl.createDiv({ cls: 'claudian-sp-item-header' }); + + const nameEl = headerRow.createSpan({ cls: 'claudian-sp-item-name' }); + nameEl.setText(agent.name); + + if (agent.description) { + const descEl = infoEl.createDiv({ cls: 'claudian-sp-item-desc' }); + descEl.setText(agent.description); + } + + const actionsEl = itemEl.createDiv({ cls: 'claudian-sp-item-actions' }); + + const editBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': t('common.edit') }, + }); + setIcon(editBtn, 'pencil'); + editBtn.addEventListener('click', () => { void this.openAgentModal(agent); }); + + const deleteBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn claudian-settings-delete-btn', + attr: { 'aria-label': t('common.delete') }, + }); + setIcon(deleteBtn, 'trash-2'); + deleteBtn.addEventListener('click', () => { + void (async (): Promise => { + const confirmed = await confirmDelete( + this.app, + t('settings.subagents.deleteConfirm', { name: agent.name }) + ); + if (!confirmed) return; + try { + await this.deleteAgent(agent); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + new Notice(t('settings.subagents.deleteFailed', { message })); + } + })(); + }); + } + + private async refreshAgents(): Promise { + try { + await this.agentManager.loadAgents(); + this.render(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + new Notice(t('settings.subagents.refreshFailed', { message })); + } + } + + private async openAgentModal(existingAgent: AgentDefinition | null): Promise { + let fresh: AgentDefinition | null; + if (existingAgent) { + try { + fresh = await this.agentStorage.load(existingAgent) ?? existingAgent; + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + new Notice(`Failed to load subagent "${existingAgent.name}": ${message}`); + return; + } + } else { + fresh = null; + } + + new AgentModal( + this.app, + fresh, + () => this.agentManager.getAvailableAgents(), + (agent) => this.saveAgent(agent, fresh) + ).open(); + } + + private async saveAgent(agent: AgentDefinition, existing: AgentDefinition | null): Promise { + if (existing && existing.name !== agent.name) { + // Rename: save to new name-based path, then delete old file + await this.agentStorage.save({ ...agent, filePath: undefined }); + try { + await this.agentStorage.delete(existing); + } catch { + new Notice(t('settings.subagents.renameCleanupFailed', { name: existing.name })); + } + } else { + await this.agentStorage.save(agent); + } + + try { + await this.agentManager.loadAgents(); + } catch { + // Non-critical: agent list will refresh on next settings open + } + this.render(); + new Notice( + existing + ? t('settings.subagents.updated', { name: agent.name }) + : t('settings.subagents.created', { name: agent.name }) + ); + } + + private async deleteAgent(agent: AgentDefinition): Promise { + await this.agentStorage.delete(agent); + + try { + await this.agentManager.loadAgents(); + } catch { + // Non-critical: agent list will refresh on next settings open + } + this.render(); + new Notice(t('settings.subagents.deleted', { name: agent.name })); + } + +} diff --git a/src/providers/claude/ui/ClaudeChatUIConfig.ts b/src/providers/claude/ui/ClaudeChatUIConfig.ts new file mode 100644 index 0000000..7c2182f --- /dev/null +++ b/src/providers/claude/ui/ClaudeChatUIConfig.ts @@ -0,0 +1,109 @@ +import { DEFAULT_REASONING_VALUE } from '../../../core/providers/reasoning'; +import type { + ProviderChatUIConfig, + ProviderPermissionModeToggleConfig, + ProviderReasoningOption, + ProviderUIOption, +} from '../../../core/providers/types'; +import { CLAUDE_PROVIDER_ICON } from '../../../shared/icons'; +import { getCustomModelIds } from '../env/claudeModelEnv'; +import { getClaudeModelOptions } from '../modelOptions'; +import { toClaudeRuntimeModelId } from '../modelSelection'; +import { getClaudeProviderSettings, updateClaudeProviderSettings } from '../settings'; +import { + DEFAULT_CLAUDE_MODELS, + DEFAULT_EFFORT_LEVEL, + EFFORT_LEVELS, + getContextWindowSize, + normalizeEffortLevel, + normalizeLegacy1MModelAlias, + supportsXHighEffort, +} from '../types/models'; + +const CLAUDE_PERMISSION_MODE_TOGGLE: ProviderPermissionModeToggleConfig = { + inactiveValue: 'normal', + inactiveLabel: 'Safe', + activeValue: 'yolo', + activeLabel: 'YOLO', + planValue: 'plan', + planLabel: 'PLAN', +}; + +export const claudeChatUIConfig: ProviderChatUIConfig = { + getModelOptions(settings) { + return getClaudeModelOptions(settings); + }, + + ownsModel(model: string, settings: Record): boolean { + const runtimeModel = toClaudeRuntimeModelId(model); + return getClaudeModelOptions(settings).some((option: ProviderUIOption) => + option.value === model || toClaudeRuntimeModelId(option.value) === runtimeModel + ); + }, + + isAdaptiveReasoningModel(_model: string, _settings: Record): boolean { + return true; + }, + + getReasoningOptions(model: string, _settings: Record): ProviderReasoningOption[] { + const runtimeModel = toClaudeRuntimeModelId(model); + const levels = supportsXHighEffort(runtimeModel) + ? EFFORT_LEVELS + : EFFORT_LEVELS.filter(e => e.value !== 'xhigh'); + return levels.map(e => ({ value: e.value, label: e.label })); + }, + + getDefaultReasoningValue(model: string, _settings: Record): string { + return DEFAULT_EFFORT_LEVEL[toClaudeRuntimeModelId(model)] ?? DEFAULT_REASONING_VALUE; + }, + + getContextWindowSize(model: string, customLimits?: Record): number { + return getContextWindowSize(toClaudeRuntimeModelId(model), customLimits); + }, + + isDefaultModel(model: string): boolean { + const runtimeModel = normalizeLegacy1MModelAlias(toClaudeRuntimeModelId(model)); + return DEFAULT_CLAUDE_MODELS.some(m => m.value === runtimeModel); + }, + + applyModelDefaults(model: string, settings: unknown): void { + const target = settings as Record; + + const runtimeModel = normalizeLegacy1MModelAlias(toClaudeRuntimeModelId(model)); + if (DEFAULT_CLAUDE_MODELS.some(m => m.value === runtimeModel)) { + target.effortLevel = DEFAULT_EFFORT_LEVEL[runtimeModel] ?? DEFAULT_REASONING_VALUE; + updateClaudeProviderSettings(target, { lastModel: runtimeModel }); + } else { + target.lastCustomModel = model; + target.effortLevel = normalizeEffortLevel(runtimeModel, target.effortLevel); + } + }, + + normalizeModelVariant(model: string, settings) { + const normalizedRuntimeModel = normalizeLegacy1MModelAlias(toClaudeRuntimeModelId(model)); + const option = getClaudeModelOptions(settings).find(candidate => + candidate.value === normalizedRuntimeModel + || toClaudeRuntimeModelId(candidate.value) === normalizedRuntimeModel + ); + return option?.value ?? normalizedRuntimeModel; + }, + + getCustomModelIds(envVars: Record): Set { + return getCustomModelIds(envVars); + }, + + getPermissionModeToggle() { + return CLAUDE_PERMISSION_MODE_TOGGLE; + }, + + isBangBashEnabled(settings) { + return getClaudeProviderSettings(settings).enableBangBash; + }, + + getProviderIcon() { + return CLAUDE_PROVIDER_ICON; + }, +}; + +/** Re-export for type-only use in provider registration. */ +export type { ProviderUIOption }; diff --git a/src/providers/claude/ui/ClaudeSettingsTab.ts b/src/providers/claude/ui/ClaudeSettingsTab.ts new file mode 100644 index 0000000..e0c1e01 --- /dev/null +++ b/src/providers/claude/ui/ClaudeSettingsTab.ts @@ -0,0 +1,357 @@ +import * as fs from 'fs'; +import { Setting } from 'obsidian'; + +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import type { ProviderSettingsTabRenderer } from '../../../core/providers/types'; +import { t } from '../../../i18n/i18n'; +import { renderEnvironmentSettingsSection } from '../../../shared/settings/EnvironmentSettingsSection'; +import { McpSettingsManager } from '../../../shared/settings/McpSettingsManager'; +import { getHostnameKey } from '../../../utils/env'; +import { expandHomePath } from '../../../utils/path'; +import { getClaudeWorkspaceServices } from '../app/ClaudeWorkspaceServices'; +import { resolveClaudeModelSelection } from '../modelOptions'; +import { + CLAUDE_SAFE_MODES, + type ClaudeSafeMode, + getClaudeProviderSettings, + updateClaudeProviderSettings, +} from '../settings'; +import { AgentSettings } from './AgentSettings'; +import { claudeChatUIConfig } from './ClaudeChatUIConfig'; +import { PluginSettingsManager } from './PluginSettingsManager'; +import { SlashCommandSettings } from './SlashCommandSettings'; + +export const claudeSettingsTabRenderer: ProviderSettingsTabRenderer = { + render(container, context) { + const claudeWorkspace = getClaudeWorkspaceServices(); + const settingsBag = context.plugin.settings as unknown as Record; + const claudeSettings = getClaudeProviderSettings(settingsBag); + + const reconcileActiveClaudeModelSelection = (settings: Record): void => { + const activeProvider = settings.settingsProvider; + if (activeProvider !== undefined && activeProvider !== 'claude') { + return; + } + + const currentModel = typeof settings.model === 'string' ? settings.model : ''; + const nextModel = resolveClaudeModelSelection(settings, currentModel); + if (!nextModel || nextModel === currentModel) { + return; + } + + settings.model = nextModel; + claudeChatUIConfig.applyModelDefaults(nextModel, settings); + }; + + // --- Setup --- + + new Setting(container).setName(t('settings.setup')).setHeading(); + + const hostnameKey = getHostnameKey(); + const platformDesc = process.platform === 'win32' + ? t('settings.cliPath.descWindows') + : t('settings.cliPath.descUnix'); + const cliPathDescription = `${t('settings.cliPath.desc')} ${platformDesc}`; + + const cliPathSetting = new Setting(container) + .setName(t('settings.cliPath.name')) + .setDesc(cliPathDescription); + + const validationEl = container.createDiv({ + cls: 'claudian-cli-path-validation claudian-setting-validation claudian-setting-validation-error claudian-hidden', + }); + + const validatePath = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + + const expandedPath = expandHomePath(trimmed); + + if (!fs.existsSync(expandedPath)) { + return t('settings.cliPath.validation.notExist'); + } + const stat = fs.statSync(expandedPath); + if (!stat.isFile()) { + return t('settings.cliPath.validation.isDirectory'); + } + return null; + }; + + const updateCliPathValidation = (value: string, inputEl?: HTMLInputElement): boolean => { + const error = validatePath(value); + if (error) { + validationEl.setText(error); + validationEl.toggleClass('claudian-hidden', false); + if (inputEl) { + inputEl.toggleClass('claudian-input-error', true); + } + return false; + } + + validationEl.toggleClass('claudian-hidden', true); + if (inputEl) { + inputEl.toggleClass('claudian-input-error', false); + } + return true; + }; + + const currentValue = claudeSettings.cliPathsByHost[hostnameKey] || ''; + const cliPathsByHost = { ...claudeSettings.cliPathsByHost }; + let cliPathInputEl: HTMLInputElement | null = null; + + const persistCliPath = async (value: string): Promise => { + const isValid = updateCliPathValidation(value, cliPathInputEl ?? undefined); + if (!isValid) { + return false; + } + + const trimmed = value.trim(); + if (trimmed) { + cliPathsByHost[hostnameKey] = trimmed; + } else { + delete cliPathsByHost[hostnameKey]; + } + + await context.plugin.mutateSettings((settings) => { + updateClaudeProviderSettings(settings, { cliPathsByHost: { ...cliPathsByHost } }); + }); + claudeWorkspace.cliResolver.reset(); + await context.plugin.recycleProviderRuntimes?.('claude'); + return true; + }; + + cliPathSetting.addText((text) => { + const placeholder = process.platform === 'win32' + ? 'D:\\nodejs\\node_global\\node_modules\\@anthropic-ai\\claude-code\\cli-wrapper.cjs' + : '/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli-wrapper.cjs'; + + text + .setPlaceholder(placeholder) + .setValue(currentValue) + .onChange(async (value) => { + await persistCliPath(value); + }); + text.inputEl.addClass('claudian-settings-cli-path-input'); + cliPathInputEl = text.inputEl; + + updateCliPathValidation(currentValue, text.inputEl); + }); + + // --- Safety --- + + new Setting(container).setName(t('settings.safety')).setHeading(); + + new Setting(container) + .setName(t('settings.claudeSafeMode.name')) + .setDesc(t('settings.claudeSafeMode.desc')) + .addDropdown((dropdown) => { + for (const mode of CLAUDE_SAFE_MODES) { + dropdown.addOption(mode, mode); + } + dropdown + .setValue(claudeSettings.safeMode) + .onChange(async (value) => { + await context.plugin.mutateSettings((settings) => { + updateClaudeProviderSettings( + settings, + { safeMode: value as ClaudeSafeMode }, + ); + }); + }); + }); + + new Setting(container) + .setName(t('settings.loadUserSettings.name')) + .setDesc(t('settings.loadUserSettings.desc')) + .addToggle((toggle) => + toggle + .setValue(claudeSettings.loadUserSettings) + .onChange(async (value) => { + await context.plugin.mutateSettings((settings) => { + updateClaudeProviderSettings(settings, { loadUserSettings: value }); + }); + }) + ); + + // --- Models --- + + new Setting(container).setName(t('settings.models')).setHeading(); + + new Setting(container) + .setName(t('settings.customModels.name')) + .setDesc(t('settings.customModels.desc')) + .addTextArea((text) => { + let pendingCustomModels = claudeSettings.customModels; + let savedCustomModels = claudeSettings.customModels; + + const commitCustomModels = async (): Promise => { + if (pendingCustomModels === savedCustomModels) { + return; + } + + const nextCustomModels = pendingCustomModels; + await context.plugin.mutateSettings((settings) => { + updateClaudeProviderSettings(settings, { customModels: nextCustomModels }); + reconcileActiveClaudeModelSelection(settings); + ProviderSettingsCoordinator.reconcileTitleGenerationModelSelection(settings); + }); + savedCustomModels = nextCustomModels; + context.refreshModelSelectors(); + }; + + text + .setPlaceholder(t('settings.customModels.placeholder')) + .setValue(claudeSettings.customModels) + .onChange((value) => { + pendingCustomModels = value; + }); + text.inputEl.rows = 6; + text.inputEl.cols = 40; + text.inputEl.addEventListener('blur', () => { + void commitCustomModels(); + }); + }); + + // --- Slash Commands --- + + new Setting(container).setName(t('settings.slashCommands.name')).setHeading(); + + const slashCommandsDesc = container.createDiv({ cls: 'claudian-sp-settings-desc' }); + const descP = slashCommandsDesc.createEl('p', { cls: 'setting-item-description' }); + descP.appendText(t('settings.slashCommands.desc') + ' '); + descP.createEl('a', { + text: 'Learn more', + href: 'https://code.claude.com/docs/en/skills', + }); + + const slashCommandsContainer = container.createDiv({ cls: 'claudian-slash-commands-container' }); + new SlashCommandSettings( + slashCommandsContainer, + context.plugin.app, + claudeWorkspace.commandCatalog, + ); + + context.renderHiddenProviderCommandSetting(container, 'claude', { + name: t('settings.hiddenSlashCommands.name'), + desc: t('settings.hiddenSlashCommands.desc'), + placeholder: t('settings.hiddenSlashCommands.placeholder'), + }); + + // --- Subagents --- + + new Setting(container).setName(t('settings.subagents.name')).setHeading(); + + const agentsDesc = container.createDiv({ cls: 'claudian-sp-settings-desc' }); + agentsDesc.createEl('p', { + text: t('settings.subagents.desc'), + cls: 'setting-item-description', + }); + + const agentsContainer = container.createDiv({ cls: 'claudian-agents-container' }); + new AgentSettings(agentsContainer, { + app: context.plugin.app, + agentManager: claudeWorkspace.agentManager, + agentStorage: claudeWorkspace.agentStorage, + }); + + // --- MCP Servers --- + + new Setting(container).setName(t('settings.mcpServers.name')).setHeading(); + + const mcpDesc = container.createDiv({ cls: 'claudian-mcp-settings-desc' }); + mcpDesc.createEl('p', { + text: t('settings.mcpServers.desc'), + cls: 'setting-item-description', + }); + + const mcpContainer = container.createDiv({ cls: 'claudian-mcp-container' }); + new McpSettingsManager(mcpContainer, { + app: context.plugin.app, + mcpStorage: claudeWorkspace.mcpStorage, + broadcastMcpReload: async () => { + await context.plugin.broadcastToAllViewRuntimes?.( + (service) => service.reloadMcpServers(), + ); + }, + }); + + // --- Plugins --- + + new Setting(container).setName(t('settings.plugins.name')).setHeading(); + + const pluginsDesc = container.createDiv({ cls: 'claudian-plugin-settings-desc' }); + pluginsDesc.createEl('p', { + text: t('settings.plugins.desc'), + cls: 'setting-item-description', + }); + + const pluginsContainer = container.createDiv({ cls: 'claudian-plugins-container' }); + new PluginSettingsManager(pluginsContainer, { + pluginManager: claudeWorkspace.pluginManager, + agentManager: claudeWorkspace.agentManager, + restartTabs: async () => { + await context.plugin.broadcastToActiveViewRuntimes?.( + async (service) => { await service.ensureReady({ force: true }); }, + ); + }, + }); + + // --- Environment --- + + renderEnvironmentSettingsSection({ + container, + plugin: context.plugin, + scope: 'provider:claude', + heading: t('settings.environment'), + name: t('settings.customVariables.name'), + desc: 'Claude-owned runtime variables only. Use this for ANTHROPIC_* and Claude-specific toggles.', + placeholder: 'ANTHROPIC_API_KEY=your-key\nANTHROPIC_BASE_URL=https://api.example.com\nANTHROPIC_MODEL=custom-model\nCLAUDE_CODE_USE_BEDROCK=1', + renderCustomContextLimits: (target) => context.renderCustomContextLimits(target, 'claude'), + }); + + // --- Experimental --- + + new Setting(container).setName(t('settings.experimental')).setHeading(); + + new Setting(container) + .setName(t('settings.enableChrome.name')) + .setDesc(t('settings.enableChrome.desc')) + .addToggle((toggle) => + toggle + .setValue(claudeSettings.enableChrome) + .onChange(async (value) => { + await context.plugin.mutateSettings((settings) => { + updateClaudeProviderSettings(settings, { enableChrome: value }); + }); + }) + ); + + new Setting(container) + .setName(t('settings.enableBangBash.name')) + .setDesc(t('settings.enableBangBash.desc')) + .addToggle((toggle) => + toggle + .setValue(claudeSettings.enableBangBash) + .onChange(async (value) => { + bangBashValidationEl.toggleClass('claudian-hidden', true); + if (value) { + const { findNodeExecutable, getEnhancedPath } = await import('../../../utils/env'); + const nodePath = findNodeExecutable(getEnhancedPath()); + if (!nodePath) { + bangBashValidationEl.setText(t('settings.enableBangBash.validation.noNode')); + bangBashValidationEl.toggleClass('claudian-hidden', false); + toggle.setValue(false); + return; + } + } + await context.plugin.mutateSettings((settings) => { + updateClaudeProviderSettings(settings, { enableBangBash: value }); + }); + }) + ); + + const bangBashValidationEl = container.createDiv({ + cls: 'claudian-bang-bash-validation claudian-setting-validation claudian-setting-validation-error claudian-hidden', + }); + }, +}; diff --git a/src/providers/claude/ui/PluginSettingsManager.ts b/src/providers/claude/ui/PluginSettingsManager.ts new file mode 100644 index 0000000..e1c3407 --- /dev/null +++ b/src/providers/claude/ui/PluginSettingsManager.ts @@ -0,0 +1,164 @@ +import { Notice, setIcon } from 'obsidian'; + +import type { + AppAgentManager, + AppPluginManager, +} from '../../../core/providers/types'; +import { isNotifiedMutationError } from '../../../core/storage/NotifiedMutationError'; +import type { PluginInfo } from '../../../core/types'; + +export interface PluginSettingsManagerDeps { + pluginManager: AppPluginManager; + agentManager: Pick; + restartTabs: () => Promise; +} + +export class PluginSettingsManager { + private containerEl: HTMLElement; + private pluginManager: AppPluginManager; + private agentManager: Pick; + private restartTabs: () => Promise; + + constructor(containerEl: HTMLElement, deps: PluginSettingsManagerDeps) { + this.containerEl = containerEl; + this.pluginManager = deps.pluginManager; + this.agentManager = deps.agentManager; + this.restartTabs = deps.restartTabs; + this.render(); + } + + private render() { + this.containerEl.empty(); + + const headerEl = this.containerEl.createDiv({ cls: 'claudian-plugin-header' }); + headerEl.createSpan({ text: 'Claude Code Plugins', cls: 'claudian-plugin-label' }); + + const refreshBtn = headerEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': 'Refresh' }, + }); + setIcon(refreshBtn, 'refresh-cw'); + refreshBtn.addEventListener('click', () => { + void this.refreshPlugins(); + }); + + const plugins = this.pluginManager.getPlugins(); + + if (plugins.length === 0) { + const emptyEl = this.containerEl.createDiv({ cls: 'claudian-plugin-empty' }); + emptyEl.setText('No Claude code plugins found. Enable plugins via the Claude CLI.'); + return; + } + + const projectPlugins = plugins.filter(p => p.scope === 'project'); + const userPlugins = plugins.filter(p => p.scope === 'user'); + + const listEl = this.containerEl.createDiv({ cls: 'claudian-plugin-list' }); + + if (projectPlugins.length > 0) { + const sectionHeader = listEl.createDiv({ cls: 'claudian-plugin-section-header' }); + sectionHeader.setText('Project plugins'); + + for (const plugin of projectPlugins) { + this.renderPluginItem(listEl, plugin); + } + } + + if (userPlugins.length > 0) { + const sectionHeader = listEl.createDiv({ cls: 'claudian-plugin-section-header' }); + sectionHeader.setText('User plugins'); + + for (const plugin of userPlugins) { + this.renderPluginItem(listEl, plugin); + } + } + } + + private renderPluginItem(listEl: HTMLElement, plugin: PluginInfo) { + const itemEl = listEl.createDiv({ cls: 'claudian-plugin-item' }); + if (!plugin.enabled) { + itemEl.addClass('claudian-plugin-item-disabled'); + } + + const statusEl = itemEl.createDiv({ cls: 'claudian-plugin-status' }); + if (plugin.enabled) { + statusEl.addClass('claudian-plugin-status-enabled'); + } else { + statusEl.addClass('claudian-plugin-status-disabled'); + } + + const infoEl = itemEl.createDiv({ cls: 'claudian-plugin-info' }); + + const nameRow = infoEl.createDiv({ cls: 'claudian-plugin-name-row' }); + + const nameEl = nameRow.createSpan({ cls: 'claudian-plugin-name' }); + nameEl.setText(plugin.name); + + const actionsEl = itemEl.createDiv({ cls: 'claudian-plugin-actions' }); + + const toggleBtn = actionsEl.createEl('button', { + cls: 'claudian-plugin-action-btn', + attr: { 'aria-label': plugin.enabled ? 'Disable' : 'Enable' }, + }); + setIcon(toggleBtn, plugin.enabled ? 'toggle-right' : 'toggle-left'); + toggleBtn.addEventListener('click', () => { + void this.togglePlugin(plugin.id); + }); + } + + private async togglePlugin(pluginId: string) { + const plugin = this.pluginManager.getPlugins().find(p => p.id === pluginId); + const wasEnabled = plugin?.enabled ?? false; + let didPersistToggle = false; + + try { + await this.pluginManager.togglePlugin(pluginId); + didPersistToggle = true; + await this.agentManager.loadAgents(); + + try { + await this.restartTabs(); + } catch { + new Notice('Plugin toggled, but some tabs failed to restart.'); + } + + new Notice(`Plugin "${pluginId}" ${wasEnabled ? 'disabled' : 'enabled'}`); + } catch (err) { + if (didPersistToggle) { + try { + await this.pluginManager.togglePlugin(pluginId); + } catch (rollbackError) { + if (!isNotifiedMutationError(rollbackError)) { + const message = rollbackError instanceof Error ? rollbackError.message : 'Unknown error'; + new Notice(`Failed to roll back plugin toggle: ${message}`); + } + return; + } + } + if (!isNotifiedMutationError(err)) { + const message = err instanceof Error ? err.message : 'Unknown error'; + new Notice(`Failed to toggle plugin: ${message}`); + } + } finally { + this.render(); + } + } + + private async refreshPlugins() { + try { + await this.pluginManager.loadPlugins(); + await this.agentManager.loadAgents(); + + new Notice('Plugin list refreshed'); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + new Notice(`Failed to refresh plugins: ${message}`); + } finally { + this.render(); + } + } + + public refresh() { + this.render(); + } +} diff --git a/src/providers/claude/ui/SlashCommandSettings.ts b/src/providers/claude/ui/SlashCommandSettings.ts new file mode 100644 index 0000000..b8891b5 --- /dev/null +++ b/src/providers/claude/ui/SlashCommandSettings.ts @@ -0,0 +1,527 @@ +import type { App, ToggleComponent } from 'obsidian'; +import { Modal, Notice, setIcon, Setting } from 'obsidian'; + +import type { ProviderCommandCatalog } from '../../../core/providers/commands/ProviderCommandCatalog'; +import type { ProviderCommandEntry } from '../../../core/providers/commands/ProviderCommandEntry'; +import { t } from '../../../i18n/i18n'; +import { extractFirstParagraph, normalizeArgumentHint, parseSlashCommandContent, validateCommandName } from '../../../utils/slashCommand'; + +function resolveAllowedTools(inputValue: string, parsedTools?: string[]): string[] | undefined { + const trimmed = inputValue.trim(); + if (trimmed) { + return trimmed.split(',').map(s => s.trim()).filter(Boolean); + } + if (parsedTools && parsedTools.length > 0) { + return parsedTools; + } + return undefined; +} + +function isSkillEntry(entry: ProviderCommandEntry): boolean { + return entry.kind === 'skill'; +} + +export class SlashCommandModal extends Modal { + private entries: ProviderCommandEntry[]; + private existingEntry: ProviderCommandEntry | null; + private onSave: (entry: ProviderCommandEntry) => Promise; + + constructor( + app: App, + entries: ProviderCommandEntry[], + existingEntry: ProviderCommandEntry | null, + onSave: (entry: ProviderCommandEntry) => Promise, + ) { + super(app); + this.entries = entries; + this.existingEntry = existingEntry; + this.onSave = onSave; + } + + onOpen() { + const existingIsSkill = this.existingEntry ? isSkillEntry(this.existingEntry) : false; + let selectedType: 'command' | 'skill' = existingIsSkill ? 'skill' : 'command'; + + const typeLabel = () => selectedType === 'skill' ? 'Skill' : 'Slash Command'; + + this.setTitle(this.existingEntry ? `Edit ${typeLabel()}` : `Add ${typeLabel()}`); + this.modalEl.addClass('claudian-sp-modal'); + + const { contentEl } = this; + + let nameInput: HTMLInputElement; + let descInput: HTMLInputElement; + let hintInput: HTMLInputElement; + let modelInput: HTMLInputElement; + let toolsInput: HTMLInputElement; + let disableModelToggle = this.existingEntry?.disableModelInvocation ?? false; + let disableUserInvocation = this.existingEntry?.userInvocable === false; + let contextValue: 'fork' | '' = this.existingEntry?.context ?? ''; + let agentInput: HTMLInputElement; + + let disableUserSetting: Setting | null = null; + let disableUserToggle: ToggleComponent | null = null; + + const updateSkillOnlyFields = () => { + if (!disableUserSetting || !disableUserToggle) return; + + const isSkillType = selectedType === 'skill'; + disableUserSetting.settingEl.toggleClass('claudian-hidden', !isSkillType); + if (!isSkillType) { + disableUserInvocation = false; + disableUserToggle.setValue(false); + } + }; + + new Setting(contentEl) + .setName('Type') + .setDesc('Command or skill') + .addDropdown(dropdown => { + dropdown + .addOption('command', 'Command') + .addOption('skill', 'Skill') + .setValue(selectedType) + .onChange(value => { + selectedType = value as 'command' | 'skill'; + this.setTitle(this.existingEntry ? `Edit ${typeLabel()}` : `Add ${typeLabel()}`); + updateSkillOnlyFields(); + }); + if (this.existingEntry) { + dropdown.setDisabled(true); + } + }); + + new Setting(contentEl) + .setName('Command name') + .setDesc('The name used after / (e.g., "review" for /review)') + .addText(text => { + nameInput = text.inputEl; + text.setValue(this.existingEntry?.name || '') + .setPlaceholder('Review-code'); + }); + + new Setting(contentEl) + .setName('Description') + .setDesc('Optional description shown in dropdown') + .addText(text => { + descInput = text.inputEl; + text.setValue(this.existingEntry?.description || ''); + }); + + const details = contentEl.createEl('details', { cls: 'claudian-sp-advanced-section' }); + details.createEl('summary', { + text: 'Advanced options', + cls: 'claudian-sp-advanced-summary', + }); + if ( + this.existingEntry?.argumentHint + || this.existingEntry?.model + || this.existingEntry?.allowedTools?.length + || this.existingEntry?.disableModelInvocation + || this.existingEntry?.userInvocable === false + || this.existingEntry?.context + || this.existingEntry?.agent + ) { + details.open = true; + } + + new Setting(details) + .setName('Argument hint') + .setDesc('Placeholder text for arguments (e.g., "[file] [focus]")') + .addText(text => { + hintInput = text.inputEl; + text.setValue(this.existingEntry?.argumentHint || ''); + }); + + new Setting(details) + .setName('Model override') + .setDesc('Optional model to use for this command') + .addText(text => { + modelInput = text.inputEl; + text.setValue(this.existingEntry?.model || '') + .setPlaceholder('Claude-sonnet-4-5'); + }); + + new Setting(details) + .setName('Allowed tools') + .setDesc('Comma-separated list of tools to allow (empty = all)') + .addText(text => { + toolsInput = text.inputEl; + text.setValue(this.existingEntry?.allowedTools?.join(', ') || ''); + }); + + new Setting(details) + .setName('Disable model invocation') + .setDesc('Prevent the model from invoking this command itself') + .addToggle(toggle => { + toggle.setValue(disableModelToggle) + .onChange(value => { disableModelToggle = value; }); + }); + + disableUserSetting = new Setting(details) + .setName('Disable user invocation') + .setDesc('Prevent the user from invoking this skill directly') + .addToggle(toggle => { + disableUserToggle = toggle; + toggle.setValue(disableUserInvocation) + .onChange(value => { disableUserInvocation = value; }); + }); + + updateSkillOnlyFields(); + + new Setting(details) + .setName('Context') + .setDesc('Run in a subagent (fork)') + .addToggle(toggle => { + toggle.setValue(contextValue === 'fork') + .onChange(value => { + contextValue = value ? 'fork' : ''; + agentSetting.settingEl.toggleClass('claudian-hidden', !value); + }); + }); + + const agentSetting = new Setting(details) + .setName('Agent') + .setDesc('Subagent type when context is fork') + .addText(text => { + agentInput = text.inputEl; + text.setValue(this.existingEntry?.agent || '') + .setPlaceholder('Code-reviewer'); + }); + agentSetting.settingEl.toggleClass('claudian-hidden', contextValue !== 'fork'); + + new Setting(contentEl) + .setName('Prompt template') + .setDesc('Use $ARGUMENTS, $1, $2, @file, !`bash`'); + + const contentArea = contentEl.createEl('textarea', { + cls: 'claudian-sp-content-area', + attr: { + rows: '10', + placeholder: 'Review this code for:\n$ARGUMENTS\n\n@$1', + }, + }); + const initialContent = this.existingEntry + ? parseSlashCommandContent(this.existingEntry.content).promptContent + : ''; + contentArea.value = initialContent; + + const buttonContainer = contentEl.createDiv({ cls: 'claudian-sp-modal-buttons' }); + + const cancelBtn = buttonContainer.createEl('button', { + text: 'Cancel', + cls: 'claudian-cancel-btn', + }); + cancelBtn.addEventListener('click', () => this.close()); + + const saveBtn = buttonContainer.createEl('button', { + text: 'Save', + cls: 'claudian-save-btn', + }); + saveBtn.addEventListener('click', () => { + void (async (): Promise => { + const name = nameInput.value.trim(); + const nameError = validateCommandName(name); + if (nameError) { + new Notice(nameError); + return; + } + + const content = contentArea.value; + if (!content.trim()) { + new Notice('Prompt template is required'); + return; + } + + const existing = this.entries.find( + entry => entry.name.toLowerCase() === name.toLowerCase() + && entry.id !== this.existingEntry?.id, + ); + if (existing) { + new Notice(`A command named "/${name}" already exists`); + return; + } + + const parsed = parseSlashCommandContent(content); + const promptContent = parsed.promptContent; + const isSkillType = selectedType === 'skill'; + + const entry: ProviderCommandEntry = { + id: this.existingEntry?.id || ( + isSkillType + ? `skill-${name}` + : `cmd-${Date.now()}-${Math.random().toString(36).substring(2, 11)}` + ), + providerId: 'claude', + kind: isSkillType ? 'skill' : 'command', + name, + description: descInput.value.trim() || parsed.description || undefined, + argumentHint: normalizeArgumentHint(hintInput.value.trim()) || parsed.argumentHint || undefined, + allowedTools: resolveAllowedTools(toolsInput.value, parsed.allowedTools), + model: modelInput.value.trim() || parsed.model || undefined, + content: promptContent, + disableModelInvocation: disableModelToggle || undefined, + userInvocable: disableUserInvocation ? false : undefined, + context: contextValue || undefined, + agent: contextValue === 'fork' ? (agentInput.value.trim() || undefined) : undefined, + hooks: parsed.hooks ?? this.existingEntry?.hooks, + scope: 'vault', + source: this.existingEntry?.source ?? 'user', + isEditable: true, + isDeletable: true, + displayPrefix: '/', + insertPrefix: '/', + persistenceKey: this.existingEntry?.persistenceKey, + }; + + try { + await this.onSave(entry); + } catch { + const label = isSkillType ? 'skill' : 'slash command'; + new Notice(`Failed to save ${label}`); + return; + } + this.close(); + })(); + }); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + this.close(); + } + }; + contentEl.addEventListener('keydown', handleKeyDown); + } + + onClose() { + this.contentEl.empty(); + } +} + +export class SlashCommandSettings { + private app: App; + private containerEl: HTMLElement; + private catalog: ProviderCommandCatalog | null; + private commands: ProviderCommandEntry[] = []; + + constructor( + containerEl: HTMLElement, + app: App, + catalog: ProviderCommandCatalog | null, + ) { + this.app = app; + this.containerEl = containerEl; + this.catalog = catalog; + void this.loadAndRender(); + } + + private async loadAndRender(): Promise { + if (!this.catalog) { + this.renderUnavailable(); + return; + } + + this.commands = await this.catalog.listVaultEntries(); + this.render(); + } + + private renderUnavailable(): void { + this.containerEl.empty(); + const emptyEl = this.containerEl.createDiv({ cls: 'claudian-sp-empty-state' }); + emptyEl.setText('Claude command catalog is unavailable.'); + } + + private render(): void { + this.containerEl.empty(); + + const headerEl = this.containerEl.createDiv({ cls: 'claudian-sp-header' }); + headerEl.createSpan({ text: t('settings.slashCommands.name'), cls: 'claudian-sp-label' }); + + const actionsEl = headerEl.createDiv({ cls: 'claudian-sp-header-actions' }); + + const addBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': 'Add' }, + }); + setIcon(addBtn, 'plus'); + addBtn.addEventListener('click', () => this.openCommandModal(null)); + + if (this.commands.length === 0) { + const emptyEl = this.containerEl.createDiv({ cls: 'claudian-sp-empty-state' }); + emptyEl.setText('No commands or skills configured. Click + to create one.'); + return; + } + + const listEl = this.containerEl.createDiv({ cls: 'claudian-sp-list' }); + + for (const cmd of this.commands) { + this.renderCommandItem(listEl, cmd); + } + } + + private renderCommandItem(listEl: HTMLElement, cmd: ProviderCommandEntry): void { + const itemEl = listEl.createDiv({ cls: 'claudian-sp-item' }); + + const infoEl = itemEl.createDiv({ cls: 'claudian-sp-info' }); + + const headerRow = infoEl.createDiv({ cls: 'claudian-sp-item-header' }); + + const nameEl = headerRow.createSpan({ cls: 'claudian-sp-item-name' }); + nameEl.setText(`/${cmd.name}`); + + if (isSkillEntry(cmd)) { + headerRow.createSpan({ text: 'skill', cls: 'claudian-slash-item-badge' }); + } + + if (cmd.argumentHint) { + const hintEl = headerRow.createSpan({ cls: 'claudian-slash-item-hint' }); + hintEl.setText(cmd.argumentHint); + } + + if (cmd.description) { + const descEl = infoEl.createDiv({ cls: 'claudian-sp-item-desc' }); + descEl.setText(cmd.description); + } + + const actionsEl = itemEl.createDiv({ cls: 'claudian-sp-item-actions' }); + + if (cmd.isEditable) { + const editBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': 'Edit' }, + }); + setIcon(editBtn, 'pencil'); + editBtn.addEventListener('click', () => this.openCommandModal(cmd)); + } + + if (!isSkillEntry(cmd) && cmd.isEditable) { + const convertBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': 'Convert to skill' }, + }); + setIcon(convertBtn, 'package'); + convertBtn.addEventListener('click', () => { + void (async (): Promise => { + try { + await this.transformToSkill(cmd); + } catch { + new Notice('Failed to convert to skill'); + } + })(); + }); + } + + if (cmd.isDeletable) { + const deleteBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn claudian-settings-delete-btn', + attr: { 'aria-label': 'Delete' }, + }); + setIcon(deleteBtn, 'trash-2'); + deleteBtn.addEventListener('click', () => { + void (async (): Promise => { + try { + await this.deleteCommand(cmd); + } catch { + const label = isSkillEntry(cmd) ? 'skill' : 'slash command'; + new Notice(`Failed to delete ${label}`); + } + })(); + }); + } + } + + private openCommandModal(existingCmd: ProviderCommandEntry | null): void { + const modal = new SlashCommandModal( + this.app, + this.commands, + existingCmd, + async (cmd) => { + await this.saveCommand(cmd, existingCmd); + }, + ); + modal.open(); + } + + private async saveCommand(cmd: ProviderCommandEntry, existing: ProviderCommandEntry | null): Promise { + if (!this.catalog) { + return; + } + + await this.catalog.saveVaultEntry(cmd); + + if (existing && existing.name !== cmd.name) { + await this.catalog.deleteVaultEntry(existing); + } + + await this.reloadCommands(); + + this.render(); + const label = isSkillEntry(cmd) ? 'Skill' : 'Slash command'; + new Notice(`${label} "/${cmd.name}" ${existing ? 'updated' : 'created'}`); + } + + private async deleteCommand(cmd: ProviderCommandEntry): Promise { + if (!this.catalog) { + return; + } + + await this.catalog.deleteVaultEntry(cmd); + + await this.reloadCommands(); + + this.render(); + const label = isSkillEntry(cmd) ? 'Skill' : 'Slash command'; + new Notice(`${label} "/${cmd.name}" deleted`); + } + + private async transformToSkill(cmd: ProviderCommandEntry): Promise { + if (!this.catalog) { + return; + } + + const skillName = cmd.name.toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 64); + + const existingSkill = this.commands.find( + entry => isSkillEntry(entry) && entry.name === skillName, + ); + if (existingSkill) { + new Notice(`A skill named "/${skillName}" already exists`); + return; + } + + const skill: ProviderCommandEntry = { + ...cmd, + id: `skill-${skillName}`, + kind: 'skill', + name: skillName, + description: cmd.description || extractFirstParagraph(cmd.content), + source: 'user', + scope: 'vault', + isEditable: true, + isDeletable: true, + displayPrefix: '/', + insertPrefix: '/', + }; + + await this.catalog.saveVaultEntry(skill); + await this.catalog.deleteVaultEntry(cmd); + + await this.reloadCommands(); + this.render(); + new Notice(`Converted "/${cmd.name}" to skill`); + } + + private async reloadCommands(): Promise { + if (!this.catalog) { + this.commands = []; + return; + } + + this.commands = await this.catalog.listVaultEntries(); + } + + public refresh(): void { + void this.loadAndRender(); + } +} diff --git a/src/providers/codex/AGENTS.md b/src/providers/codex/AGENTS.md new file mode 100644 index 0000000..68b0169 --- /dev/null +++ b/src/providers/codex/AGENTS.md @@ -0,0 +1,41 @@ +# Codex Provider + +`src/providers/codex/` adapts OpenAI Codex through `codex app-server` over stdio JSON-RPC 2.0. + +## Protocol Rules + +- The startup handshake is mandatory: send `initialize`, then notify `initialized`. +- `initialize` must include `{ experimentalApi: true }` for extended capabilities. +- Client requests include `thread/*`, `turn/*`, and `skills/list`. +- Server notifications drive streaming, item events, turn completion, and usage. +- Server requests drive approval gates and ask-user prompts; the client must answer them. + +## Live Output vs History + +- Live turn output comes from JSON-RPC notifications. `thread/start` and `thread/resume` request `experimentalRawEvents: true`. +- `CodexNotificationRouter` projects normalized notifications and raw response items into Claudian `StreamChunk`s. +- Do not reintroduce live JSONL polling unless the app-server stops emitting equivalent notifications and the tradeoff is documented with a current wire trace. +- JSONL is the replay source for history hydration and session-file discovery. + +## Design Rules + +- `CodexSkillListingService` uses a separate short-lived app-server process for `skills/list`. Do not couple skill discovery to the active chat runtime. +- Environment hash changes for `OPENAI_MODEL`, `OPENAI_BASE_URL`, and `OPENAI_API_KEY` invalidate existing Codex sessions. +- Existing threads require `thread/resume` before operations in a new app-server process. +- For forks, resume the new fork thread before `thread/rollback`. +- Notifications can arrive before `turn/start` returns; preserve pending-turn buffering. +- Compact turns get their ID from `turn/started`, not from the `thread/compact/start` response. + +## History Gotchas + +- A session file may contain legacy records and modern records. Prefer the modern path if any modern records are present. +- Do not replay `type: 'compacted'` `replacement_history` as visible UI history. The durable visible marker is `event_msg:context_compacted`. +- Session file names may include a date prefix. Keep DFS fallback in session-file lookup. + +## Runtime Gotchas + +- Images are written to a temp directory, passed as local image paths, and cleaned up in `query()` `finally`. +- `serverRequest/resolved` can auto-dismiss approval or ask-user UI without client input. +- `CodexAuxQueryRunner` owns its own process, transport, and thread. It is independent from the chat runtime. +- `CodexTaskResultInterpreter` is intentionally no-op because Claudian's Claude async-agent task system does not apply to Codex. +- Codex is opt-in and must stay disabled by default. diff --git a/src/providers/codex/CLAUDE.md b/src/providers/codex/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/src/providers/codex/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/providers/codex/agents/CodexAgentMentionProvider.ts b/src/providers/codex/agents/CodexAgentMentionProvider.ts new file mode 100644 index 0000000..57ad5cb --- /dev/null +++ b/src/providers/codex/agents/CodexAgentMentionProvider.ts @@ -0,0 +1,33 @@ +import type { AgentMentionProvider } from '../../../core/providers/types'; +import type { CodexSubagentStorage } from '../storage/CodexSubagentStorage'; +import type { CodexSubagentDefinition } from '../types/subagent'; + +export class CodexAgentMentionProvider implements AgentMentionProvider { + private agents: CodexSubagentDefinition[] = []; + + constructor(private storage: CodexSubagentStorage) {} + + async loadAgents(): Promise { + this.agents = await this.storage.loadAll(); + } + + searchAgents(query: string): Array<{ + id: string; + name: string; + description?: string; + source: 'plugin' | 'vault' | 'global' | 'builtin'; + }> { + const q = query.toLowerCase(); + return this.agents + .filter(a => + a.name.toLowerCase().includes(q) || + a.description.toLowerCase().includes(q) + ) + .map(a => ({ + id: a.name, + name: a.name, + description: a.description, + source: 'vault' as const, + })); + } +} diff --git a/src/providers/codex/app/CodexWorkspaceServices.ts b/src/providers/codex/app/CodexWorkspaceServices.ts new file mode 100644 index 0000000..9ca7679 --- /dev/null +++ b/src/providers/codex/app/CodexWorkspaceServices.ts @@ -0,0 +1,129 @@ +import type { ProviderCommandCatalog } from '../../../core/providers/commands/ProviderCommandCatalog'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import { ProviderWorkspaceRegistry } from '../../../core/providers/ProviderWorkspaceRegistry'; +import type { + ProviderCliResolver, + ProviderWorkspaceRegistration, + ProviderWorkspaceServices, +} from '../../../core/providers/types'; +import type { HomeFileAdapter } from '../../../core/storage/HomeFileAdapter'; +import type { VaultFileAdapter } from '../../../core/storage/VaultFileAdapter'; +import { getVaultPath } from '../../../utils/path'; +import { CodexAgentMentionProvider } from '../agents/CodexAgentMentionProvider'; +import { CodexSkillCatalog } from '../commands/CodexSkillCatalog'; +import { CodexCliResolver } from '../runtime/CodexCliResolver'; +import { CodexModelDiscoveryService } from '../runtime/CodexModelDiscoveryService'; +import { + getCodexProviderSettings, + normalizeCodexVisibleModels, + updateCodexProviderSettings, +} from '../settings'; +import { CodexSkillListingService } from '../skills/CodexSkillListingService'; +import { CodexSkillStorage } from '../storage/CodexSkillStorage'; +import { CodexSubagentStorage } from '../storage/CodexSubagentStorage'; +import { codexSettingsTabRenderer } from '../ui/CodexSettingsTab'; + +export interface CodexWorkspaceServices extends ProviderWorkspaceServices { + subagentStorage: CodexSubagentStorage; + commandCatalog: ProviderCommandCatalog; + agentMentionProvider: CodexAgentMentionProvider; + cliResolver: ProviderCliResolver; +} + +function sameCatalog(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function createCodexCliResolver(): ProviderCliResolver { + return new CodexCliResolver(); +} + +export async function createCodexWorkspaceServices( + plugin: ProviderHost, + vaultAdapter: VaultFileAdapter, + homeAdapter: HomeFileAdapter, +): Promise { + const subagentStorage = new CodexSubagentStorage(vaultAdapter); + const agentMentionProvider = new CodexAgentMentionProvider(subagentStorage); + await agentMentionProvider.loadAgents(); + + const skillListProvider = new CodexSkillListingService(plugin); + const modelDiscovery = new CodexModelDiscoveryService(plugin); + const commandCatalog = new CodexSkillCatalog( + new CodexSkillStorage( + vaultAdapter, + homeAdapter, + ), + skillListProvider, + getVaultPath(plugin.app), + ); + + const services: CodexWorkspaceServices = { + subagentStorage, + commandCatalog, + agentMentionProvider, + cliResolver: createCodexCliResolver(), + settingsTabRenderer: codexSettingsTabRenderer, + refreshAgentMentions: async () => { + await agentMentionProvider.loadAgents(); + }, + refreshModelCatalog: async () => { + const result = await modelDiscovery.discoverModels(); + if (result.diagnostics) { + return { changed: false, diagnostics: result.diagnostics }; + } + if (result.models.length === 0) { + return { changed: false, diagnostics: 'Codex app-server returned no visible models' }; + } + + let refreshResult = { changed: false, persistedSettingsChanged: false }; + await plugin.mutateSettingsConditionally((settings) => { + const currentSettings = getCodexProviderSettings(settings); + const currentModels = currentSettings.discoveredModels; + const visibleModels = normalizeCodexVisibleModels( + currentSettings.visibleModels, + result.models, + ); + const catalogChanged = !sameCatalog(currentModels, result.models); + const visibilityChanged = !sameCatalog(currentSettings.visibleModels, visibleModels); + if (catalogChanged || visibilityChanged) { + updateCodexProviderSettings(settings, { + discoveredModels: result.models, + visibleModels, + }); + } + const selectionChanged = ProviderSettingsCoordinator.normalizeAllModelVariants(settings); + const persistedSettingsChanged = visibilityChanged || selectionChanged; + refreshResult = { + changed: catalogChanged || persistedSettingsChanged, + persistedSettingsChanged, + }; + return persistedSettingsChanged; + }); + return refreshResult; + }, + }; + + if (getCodexProviderSettings(plugin.settings).enabled) { + await services.refreshModelCatalog!(); + } + + return services; +} + +export const codexWorkspaceRegistration: ProviderWorkspaceRegistration = { + initialize: async ({ plugin, vaultAdapter, homeAdapter }) => createCodexWorkspaceServices( + plugin, + vaultAdapter, + homeAdapter, + ), +}; + +export function maybeGetCodexWorkspaceServices(): CodexWorkspaceServices | null { + return ProviderWorkspaceRegistry.getServices('codex') as CodexWorkspaceServices | null; +} + +export function getCodexWorkspaceServices(): CodexWorkspaceServices { + return ProviderWorkspaceRegistry.requireServices('codex') as CodexWorkspaceServices; +} diff --git a/src/providers/codex/auxiliary/CodexInlineEditService.ts b/src/providers/codex/auxiliary/CodexInlineEditService.ts new file mode 100644 index 0000000..14897d2 --- /dev/null +++ b/src/providers/codex/auxiliary/CodexInlineEditService.ts @@ -0,0 +1,9 @@ +import { QueryBackedInlineEditService } from '../../../core/auxiliary/QueryBackedInlineEditService'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { CodexAuxQueryRunner } from '../runtime/CodexAuxQueryRunner'; + +export class CodexInlineEditService extends QueryBackedInlineEditService { + constructor(plugin: ProviderHost) { + super(new CodexAuxQueryRunner(plugin)); + } +} diff --git a/src/providers/codex/auxiliary/CodexInstructionRefineService.ts b/src/providers/codex/auxiliary/CodexInstructionRefineService.ts new file mode 100644 index 0000000..76918c1 --- /dev/null +++ b/src/providers/codex/auxiliary/CodexInstructionRefineService.ts @@ -0,0 +1,9 @@ +import { QueryBackedInstructionRefineService } from '../../../core/auxiliary/QueryBackedInstructionRefineService'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { CodexAuxQueryRunner } from '../runtime/CodexAuxQueryRunner'; + +export class CodexInstructionRefineService extends QueryBackedInstructionRefineService { + constructor(plugin: ProviderHost) { + super(new CodexAuxQueryRunner(plugin)); + } +} diff --git a/src/providers/codex/auxiliary/CodexTaskResultInterpreter.ts b/src/providers/codex/auxiliary/CodexTaskResultInterpreter.ts new file mode 100644 index 0000000..27fdcd2 --- /dev/null +++ b/src/providers/codex/auxiliary/CodexTaskResultInterpreter.ts @@ -0,0 +1,29 @@ +import type { + ProviderTaskResultInterpreter, + ProviderTaskTerminalStatus, +} from '../../../core/providers/types'; + +export class CodexTaskResultInterpreter implements ProviderTaskResultInterpreter { + hasAsyncLaunchMarker(_toolUseResult: unknown): boolean { + return false; + } + + extractAgentId(_toolUseResult: unknown): string | null { + return null; + } + + extractStructuredResult(_toolUseResult: unknown): string | null { + return null; + } + + resolveTerminalStatus( + _toolUseResult: unknown, + fallbackStatus: ProviderTaskTerminalStatus, + ): ProviderTaskTerminalStatus { + return fallbackStatus; + } + + extractTagValue(_payload: string, _tagName: string): string | null { + return null; + } +} diff --git a/src/providers/codex/auxiliary/CodexTitleGenerationService.ts b/src/providers/codex/auxiliary/CodexTitleGenerationService.ts new file mode 100644 index 0000000..6580680 --- /dev/null +++ b/src/providers/codex/auxiliary/CodexTitleGenerationService.ts @@ -0,0 +1,22 @@ +import { QueryBackedTitleGenerationService } from '../../../core/auxiliary/QueryBackedTitleGenerationService'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { toCodexRuntimeModelId } from '../modelSelection'; +import { CodexAuxQueryRunner } from '../runtime/CodexAuxQueryRunner'; +import { codexChatUIConfig } from '../ui/CodexChatUIConfig'; + +export class CodexTitleGenerationService extends QueryBackedTitleGenerationService { + constructor(plugin: ProviderHost) { + super({ + createRunner: () => new CodexAuxQueryRunner(plugin), + resolveModel: () => { + const settings = plugin.settings as unknown as Record; + const titleModel = typeof settings.titleGenerationModel === 'string' + ? settings.titleGenerationModel + : ''; + return codexChatUIConfig.ownsModel(titleModel, settings) + ? toCodexRuntimeModelId(titleModel) + : undefined; + }, + }); + } +} diff --git a/src/providers/codex/capabilities.ts b/src/providers/codex/capabilities.ts new file mode 100644 index 0000000..b179564 --- /dev/null +++ b/src/providers/codex/capabilities.ts @@ -0,0 +1,16 @@ +import type { ProviderCapabilities } from '../../core/providers/types'; + +export const CODEX_PROVIDER_CAPABILITIES: Readonly = Object.freeze({ + providerId: 'codex', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: false, + supportsFork: true, + supportsProviderCommands: false, + supportsImageAttachments: true, + supportsInstructionMode: true, + supportsMcpTools: false, + supportsTurnSteer: true, + reasoningControl: 'effort', +}); diff --git a/src/providers/codex/codexUserText.ts b/src/providers/codex/codexUserText.ts new file mode 100644 index 0000000..aae430f --- /dev/null +++ b/src/providers/codex/codexUserText.ts @@ -0,0 +1,108 @@ +const CODEX_IMAGE_OPEN_TAG_PATTERN = /^]*>$/i; +const CODEX_IMAGE_CLOSE_TAG_PATTERN = /^<\/image>$/i; +const CODEX_EMPTY_IMAGE_TAG_PATTERN = /^]*>\s*<\/image>$/i; +const CODEX_EMPTY_IMAGE_TAG_PATTERN_GLOBAL = /]*>\s*<\/image>\s*/gi; + +const CODEX_CONTROL_BLOCK_TAGS = [ + 'recommended_plugins', + 'system_instruction', + 'environment_context', + 'turn_aborted', + 'user-preferences', + 'subagent_notification', + 'skill', +]; + +const CODEX_AGENTS_INSTRUCTIONS_PREFIX = '# AGENTS.md instructions'; +const CODEX_AGENTS_INSTRUCTIONS_CLOSE_TAG = ''; + +function stripLeadingTaggedBlock(text: string, tagName: string): string | null { + const openTag = `<${tagName}>`; + if (!text.startsWith(openTag)) { + return null; + } + + const closeTag = ``; + const closeIndex = text.indexOf(closeTag, openTag.length); + if (closeIndex === -1) { + return ''; + } + + return text.slice(closeIndex + closeTag.length); +} + +function stripLeadingAgentsInstructions(text: string): string | null { + if (!text.startsWith(CODEX_AGENTS_INSTRUCTIONS_PREFIX)) { + return null; + } + + const closeIndex = text.indexOf(CODEX_AGENTS_INSTRUCTIONS_CLOSE_TAG); + if (closeIndex === -1) { + return ''; + } + + return text.slice(closeIndex + CODEX_AGENTS_INSTRUCTIONS_CLOSE_TAG.length); +} + +function stripLeadingCodexControlMetadata(text: string): string { + let remaining = text.trimStart(); + + while (remaining) { + const withoutAgentsInstructions = stripLeadingAgentsInstructions(remaining); + if (withoutAgentsInstructions !== null) { + remaining = withoutAgentsInstructions.trimStart(); + continue; + } + + let strippedTaggedBlock = false; + for (const tagName of CODEX_CONTROL_BLOCK_TAGS) { + const next = stripLeadingTaggedBlock(remaining, tagName); + if (next === null) { + continue; + } + + remaining = next.trimStart(); + strippedTaggedBlock = true; + break; + } + + if (!strippedTaggedBlock) { + break; + } + } + + return remaining; +} + +export function stripCodexImagePlaceholderText(text: string): string { + const trimmed = text.trim(); + if (!trimmed) { + return text; + } + + if ( + CODEX_IMAGE_OPEN_TAG_PATTERN.test(trimmed) + || CODEX_IMAGE_CLOSE_TAG_PATTERN.test(trimmed) + || CODEX_EMPTY_IMAGE_TAG_PATTERN.test(trimmed) + ) { + return ''; + } + + return text.replace(CODEX_EMPTY_IMAGE_TAG_PATTERN_GLOBAL, ''); +} + +export function joinCodexUserTextParts(parts: readonly string[], separator = ''): string { + return parts + .map(stripCodexImagePlaceholderText) + .filter(text => text.length > 0) + .join(separator); +} + +export function extractCodexUserVisibleText(text: string): string | null { + const withoutImagePlaceholders = stripCodexImagePlaceholderText(text); + const visible = stripCodexImagePlaceholderText( + stripLeadingCodexControlMetadata(withoutImagePlaceholders), + ).trim(); + + return visible ? visible : null; +} diff --git a/src/providers/codex/commands/CodexSkillCatalog.ts b/src/providers/codex/commands/CodexSkillCatalog.ts new file mode 100644 index 0000000..8f729e9 --- /dev/null +++ b/src/providers/codex/commands/CodexSkillCatalog.ts @@ -0,0 +1,180 @@ +import type { + ProviderCommandCatalog, + ProviderCommandDropdownConfig, +} from '../../../core/providers/commands/ProviderCommandCatalog'; +import type { ProviderCommandEntry } from '../../../core/providers/commands/ProviderCommandEntry'; +import type { SlashCommand } from '../../../core/types'; +import type { SkillMetadata } from '../runtime/codexAppServerTypes'; +import { + type CodexSkillListProvider, + compareCodexSkillPriority, + getCodexSkillDescription, +} from '../skills/CodexSkillListingService'; +import { + type CodexSkillStorage, + createCodexSkillPersistenceKey, + parseCodexSkillPersistenceKey, + resolveCodexSkillLocationFromPath, +} from '../storage/CodexSkillStorage'; + +const CODEX_SKILL_ID_PREFIX = 'codex-skill-'; + +const CODEX_COMPACT_COMMAND: ProviderCommandEntry = { + id: 'codex-builtin-compact', + providerId: 'codex', + kind: 'command', + name: 'compact', + description: 'Compact conversation history', + content: '', + scope: 'system', + source: 'builtin', + isEditable: false, + isDeletable: false, + displayPrefix: '/', + insertPrefix: '/', +}; + +function buildSkillId( + skill: Pick, + location?: { rootId: string; name: string } | null, +): string { + if (location) { + return `${CODEX_SKILL_ID_PREFIX}${location.rootId}-${location.name}`; + } + + const encodedPath = encodeURIComponent(skill.path); + return `${CODEX_SKILL_ID_PREFIX}${skill.scope}-${encodedPath}`; +} + +function listedSkillToProviderEntry( + skill: SkillMetadata, + vaultPath: string | null, +): ProviderCommandEntry { + const location = vaultPath ? resolveCodexSkillLocationFromPath(skill.path, vaultPath) : null; + const isVault = skill.scope === 'repo' && location !== null; + + return { + id: buildSkillId(skill, isVault ? location : null), + providerId: 'codex', + kind: 'skill', + name: skill.name, + description: getCodexSkillDescription(skill), + content: '', + scope: isVault ? 'vault' : 'user', + source: 'user', + isEditable: isVault, + isDeletable: isVault, + displayPrefix: '$', + insertPrefix: '$', + ...(isVault + ? { + persistenceKey: createCodexSkillPersistenceKey({ + rootId: location.rootId, + currentName: location.name, + }), + } + : {}), + }; +} + +export class CodexSkillCatalog implements ProviderCommandCatalog { + constructor( + private storage: CodexSkillStorage, + private listProvider: CodexSkillListProvider, + private vaultPath: string | null, + ) {} + + setRuntimeCommands(_commands: SlashCommand[]): void { + // Codex dropdown entries come from app-server metadata; runtime commands are ignored. + } + + async listDropdownEntries(context: { includeBuiltIns: boolean }): Promise { + const skills = (await this.listProvider.listSkills()) + .filter(skill => skill.enabled) + .sort(compareCodexSkillPriority); + const entries = skills.map(skill => listedSkillToProviderEntry(skill, this.vaultPath)); + return context.includeBuiltIns ? [CODEX_COMPACT_COMMAND, ...entries] : entries; + } + + async listVaultEntries(): Promise { + if (!this.vaultPath) { + return []; + } + + const listedSkills = (await this.listProvider.listSkills()) + .filter(skill => skill.scope === 'repo') + .sort(compareCodexSkillPriority); + const entries: ProviderCommandEntry[] = []; + + for (const listedSkill of listedSkills) { + const location = resolveCodexSkillLocationFromPath(listedSkill.path, this.vaultPath); + if (!location) { + continue; + } + + const storedSkill = await this.storage.load(location); + if (!storedSkill) { + continue; + } + + entries.push({ + id: `${CODEX_SKILL_ID_PREFIX}${location.rootId}-${storedSkill.name}`, + providerId: 'codex', + kind: 'skill', + name: storedSkill.name, + description: storedSkill.description ?? getCodexSkillDescription(listedSkill), + content: storedSkill.content, + scope: 'vault', + source: 'user', + isEditable: true, + isDeletable: true, + displayPrefix: '$', + insertPrefix: '$', + persistenceKey: createCodexSkillPersistenceKey({ + rootId: location.rootId, + currentName: location.name, + }), + }); + } + + return entries; + } + + async saveVaultEntry(entry: ProviderCommandEntry): Promise { + const persistenceState = parseCodexSkillPersistenceKey(entry.persistenceKey); + await this.storage.save({ + name: entry.name, + description: entry.description, + content: entry.content, + rootId: persistenceState?.rootId, + previousLocation: persistenceState?.currentName + ? { rootId: persistenceState.rootId, name: persistenceState.currentName } + : undefined, + }); + this.listProvider.invalidate(); + } + + async deleteVaultEntry(entry: ProviderCommandEntry): Promise { + const persistenceState = parseCodexSkillPersistenceKey(entry.persistenceKey); + await this.storage.delete({ + name: persistenceState?.currentName ?? entry.name, + rootId: persistenceState?.rootId ?? 'vault-codex', + }); + this.listProvider.invalidate(); + } + + getDropdownConfig(): ProviderCommandDropdownConfig { + return { + providerId: 'codex', + triggerChars: ['/', '$'], + builtInPrefix: '/', + skillPrefix: '$', + commandPrefix: '/', + }; + } + + async refresh(): Promise { + this.listProvider.invalidate(); + await this.listProvider.listSkills({ forceReload: true }); + } +} diff --git a/src/providers/codex/env/CodexSettingsReconciler.ts b/src/providers/codex/env/CodexSettingsReconciler.ts new file mode 100644 index 0000000..e469170 --- /dev/null +++ b/src/providers/codex/env/CodexSettingsReconciler.ts @@ -0,0 +1,68 @@ +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import type { ProviderSettingsReconciler } from '../../../core/providers/types'; +import type { Conversation } from '../../../core/types'; +import { parseEnvironmentVariables } from '../../../utils/env'; +import { resolveCodexModelSelection } from '../modelOptions'; +import { getCodexProviderSettings, updateCodexProviderSettings } from '../settings'; +import { getCodexState } from '../types'; +import { codexChatUIConfig } from '../ui/CodexChatUIConfig'; + +const ENV_HASH_KEYS = ['OPENAI_MODEL', 'OPENAI_BASE_URL', 'OPENAI_API_KEY']; + +function computeCodexEnvHash(envText: string): string { + const envVars = parseEnvironmentVariables(envText || ''); + return ENV_HASH_KEYS + .filter(key => envVars[key]) + .map(key => `${key}=${envVars[key]}`) + .sort() + .join('|'); +} + +export const codexSettingsReconciler: ProviderSettingsReconciler = { + reconcileModelWithEnvironment( + settings: Record, + conversations: Conversation[], + ): { changed: boolean; invalidatedConversations: Conversation[] } { + const envText = getRuntimeEnvironmentText(settings, 'codex'); + const currentHash = computeCodexEnvHash(envText); + const savedHash = getCodexProviderSettings(settings).environmentHash; + + if (currentHash === savedHash) { + return { changed: false, invalidatedConversations: [] }; + } + + const invalidatedConversations: Conversation[] = []; + for (const conv of conversations) { + const state = getCodexState(conv.providerState); + if (conv.providerId === 'codex' && (conv.sessionId || state.threadId)) { + conv.sessionId = null; + conv.providerState = undefined; + invalidatedConversations.push(conv); + } + } + + const currentModel = typeof settings.model === 'string' ? settings.model : ''; + const nextModel = resolveCodexModelSelection(settings, currentModel); + if (nextModel) { + settings.model = nextModel; + } + + updateCodexProviderSettings(settings, { environmentHash: currentHash }); + return { changed: true, invalidatedConversations }; + }, + + normalizeModelVariantSettings(settings: Record): boolean { + const model = settings.model as string; + if (!model) { + return false; + } + + const normalizedModel = codexChatUIConfig.normalizeModelVariant(model, settings); + if (normalizedModel === model) { + return false; + } + + settings.model = normalizedModel; + return true; + }, +}; diff --git a/src/providers/codex/history/CodexConversationHistoryService.ts b/src/providers/codex/history/CodexConversationHistoryService.ts new file mode 100644 index 0000000..aee8769 --- /dev/null +++ b/src/providers/codex/history/CodexConversationHistoryService.ts @@ -0,0 +1,269 @@ +import * as fs from 'node:fs/promises'; + +import type { + ProviderConversationHistoryService, + ProviderHistoryPathContext, +} from '../../../core/providers/types'; +import type { Conversation } from '../../../core/types'; +import type { CodexProviderState } from '../types'; +import { getCodexState } from '../types'; +import { + CODEX_HISTORY_LOOKUP_TIMEOUT_MS, + resolveCodexSessionFileHint, + resolveCodexTranscriptRootHint, +} from './CodexHistoryPathResolver'; +import { + type CodexParsedTurn, + deriveCodexSessionsRootFromSessionPath, + findCodexSessionFileAsync, + parseCodexSessionFileAsync, + parseCodexSessionTurns, +} from './CodexHistoryStore'; + +async function readSessionTurns(sessionFilePath: string): Promise { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 10_000); + try { + const content = await fs.readFile(sessionFilePath, { + encoding: 'utf-8', + signal: controller.signal, + }); + return parseCodexSessionTurns(content); + } catch { + return []; + } finally { + window.clearTimeout(timer); + } +} + +export class CodexConversationHistoryService implements ProviderConversationHistoryService { + private hydratedConversationPaths = new Map(); + + async hydrateConversationHistory( + conversation: Conversation, + _vaultPath: string | null, + pathContext?: ProviderHistoryPathContext, + ): Promise { + const lookupDeadline = Date.now() + CODEX_HISTORY_LOOKUP_TIMEOUT_MS; + const state = getCodexState(conversation.providerState); + const transcriptRootPath = resolveCodexTranscriptRootHint( + state.transcriptRootPath ?? deriveCodexSessionsRootFromSessionPath(state.sessionFilePath), + pathContext, + ); + + // Pending fork with existing in-memory messages: keep them as-is + if (this.isPendingForkConversation(conversation) && conversation.messages.length > 0) { + return; + } + + // Pending fork without messages: hydrate from source transcript truncated at resumeAt + if (this.isPendingForkConversation(conversation)) { + const sourceSessionFile = await this.resolveSourceSessionFile( + state, + pathContext, + lookupDeadline, + ); + if (!sourceSessionFile) return; + + const turns = await readSessionTurns(sourceSessionFile); + const resumeAt = state.forkSource!.resumeAt; + const truncated = this.truncateTurnsAtCheckpoint(turns, resumeAt); + if (!truncated) { + this.hydratedConversationPaths.delete(conversation.id); + return; + } + conversation.messages = truncated.flatMap(t => t.messages); + return; + } + + // Established fork: source prefix + fork-only turns + if (state.forkSource && state.threadId) { + const sourceSessionFile = await this.resolveSourceSessionFile( + state, + pathContext, + lookupDeadline, + ); + const forkSessionFile = await resolveCodexSessionFileHint( + state.sessionFilePath, + state.threadId, + pathContext, + lookupDeadline, + ) ?? (state.threadId && transcriptRootPath + ? await findCodexSessionFileAsync( + state.threadId, + transcriptRootPath, + Math.max(0, lookupDeadline - Date.now()), + ) + : null); + + if (sourceSessionFile && forkSessionFile) { + const sourceTurns = await readSessionTurns(sourceSessionFile); + const forkTurns = await readSessionTurns(forkSessionFile); + + const resumeAt = state.forkSource.resumeAt; + const sourcePrefix = this.truncateTurnsAtCheckpoint(sourceTurns, resumeAt); + if (!sourcePrefix) { + this.hydratedConversationPaths.delete(conversation.id); + return; + } + const sourceTurnIds = new Set(sourceTurns.map(t => t.turnId).filter(Boolean)); + const forkOnlyTurns = forkTurns.filter(t => !t.turnId || !sourceTurnIds.has(t.turnId)); + + const messages = [ + ...sourcePrefix.flatMap(t => t.messages), + ...forkOnlyTurns.flatMap(t => t.messages), + ]; + + if (messages.length === 0) { + this.hydratedConversationPaths.delete(conversation.id); + return; + } + + conversation.messages = messages; + this.hydratedConversationPaths.set(conversation.id, `fork::${state.threadId}`); + return; + } + } + + // Normal hydration + const threadId = state.threadId ?? conversation.sessionId ?? null; + const sessionFilePath = await resolveCodexSessionFileHint( + state.sessionFilePath, + threadId, + pathContext, + lookupDeadline, + ) ?? (threadId && transcriptRootPath + ? await findCodexSessionFileAsync( + threadId, + transcriptRootPath, + Math.max(0, lookupDeadline - Date.now()), + ) + : null); + const resolvedTranscriptRootPath = transcriptRootPath + ?? deriveCodexSessionsRootFromSessionPath(sessionFilePath); + + if (!sessionFilePath) { + this.hydratedConversationPaths.delete(conversation.id); + return; + } + + const hydrationKey = `${threadId ?? ''}::${sessionFilePath}`; + if ( + conversation.messages.length > 0 + && this.hydratedConversationPaths.get(conversation.id) === hydrationKey + ) { + return; + } + + if (sessionFilePath !== state.sessionFilePath) { + conversation.providerState = { + ...(conversation.providerState ?? {}), + ...(threadId ? { threadId } : {}), + sessionFilePath, + ...(resolvedTranscriptRootPath ? { transcriptRootPath: resolvedTranscriptRootPath } : {}), + }; + } else if (resolvedTranscriptRootPath && resolvedTranscriptRootPath !== state.transcriptRootPath) { + conversation.providerState = { + ...(conversation.providerState ?? {}), + ...(threadId ? { threadId } : {}), + transcriptRootPath: resolvedTranscriptRootPath, + }; + } + + const sdkMessages = await parseCodexSessionFileAsync(sessionFilePath); + if (sdkMessages.length === 0) { + this.hydratedConversationPaths.delete(conversation.id); + return; + } + + conversation.messages = sdkMessages; + this.hydratedConversationPaths.set(conversation.id, hydrationKey); + } + + async deleteConversationSession( + _conversation: Conversation, + _vaultPath: string | null, + ): Promise { + // Never delete ~/.codex transcripts + } + + resolveSessionIdForConversation(conversation: Conversation | null): string | null { + if (!conversation) return null; + const state = getCodexState(conversation.providerState); + return state.threadId ?? conversation.sessionId ?? state.forkSource?.sessionId ?? null; + } + + isPendingForkConversation(conversation: Conversation): boolean { + const state = getCodexState(conversation.providerState); + return !!state.forkSource && !state.threadId && !conversation.sessionId; + } + + buildForkProviderState( + sourceSessionId: string, + resumeAt: string, + sourceProviderState?: Record, + ): Record { + const sourceState = getCodexState(sourceProviderState); + const sourceTranscriptRootPath = sourceState.transcriptRootPath + ?? deriveCodexSessionsRootFromSessionPath(sourceState.sessionFilePath); + const providerState: CodexProviderState = { + forkSource: { sessionId: sourceSessionId, resumeAt }, + ...(sourceState.sessionFilePath ? { forkSourceSessionFilePath: sourceState.sessionFilePath } : {}), + ...( + sourceTranscriptRootPath + ? { forkSourceTranscriptRootPath: sourceTranscriptRootPath } + : {} + ), + }; + return providerState as Record; + } + + buildPersistedProviderState( + conversation: Conversation, + ): Record | undefined { + const entries = Object.entries(getCodexState(conversation.providerState)) + .filter(([, value]) => value !== undefined); + return entries.length > 0 ? Object.fromEntries(entries) : undefined; + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private async resolveSourceSessionFile( + state: CodexProviderState, + pathContext?: ProviderHistoryPathContext, + lookupDeadline = Date.now() + CODEX_HISTORY_LOOKUP_TIMEOUT_MS, + ): Promise { + if (!state.forkSource) return null; + const sourceTranscriptRootPath = resolveCodexTranscriptRootHint( + state.forkSourceTranscriptRootPath + ?? deriveCodexSessionsRootFromSessionPath(state.forkSourceSessionFilePath), + pathContext, + ); + return await resolveCodexSessionFileHint( + state.forkSourceSessionFilePath, + state.forkSource.sessionId, + pathContext, + lookupDeadline, + ) ?? (sourceTranscriptRootPath + ? findCodexSessionFileAsync( + state.forkSource.sessionId, + sourceTranscriptRootPath, + Math.max(0, lookupDeadline - Date.now()), + ) + : null); + } + + private truncateTurnsAtCheckpoint( + turns: CodexParsedTurn[], + resumeAt: string, + ): CodexParsedTurn[] | null { + const checkpointIndex = turns.findIndex(turn => turn.turnId === resumeAt); + if (checkpointIndex < 0) { + return null; + } + + return turns.slice(0, checkpointIndex + 1); + } +} diff --git a/src/providers/codex/history/CodexHistoryPathResolver.ts b/src/providers/codex/history/CodexHistoryPathResolver.ts new file mode 100644 index 0000000..53d8b03 --- /dev/null +++ b/src/providers/codex/history/CodexHistoryPathResolver.ts @@ -0,0 +1,170 @@ +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { ProviderHistoryPathContext } from '../../../core/providers/types'; +import { isPathWithinRoot } from '../../../core/storage/pathContainment'; +import { inferWslDistroFromWindowsPath } from '../runtime/CodexExecutionTargetResolver'; +import { createCodexPathMapper } from '../runtime/CodexPathMapper'; +import { getCodexProviderSettings } from '../settings'; +import { findCodexSessionFileAsync } from './CodexHistoryStore'; + +export const CODEX_HISTORY_LOOKUP_TIMEOUT_MS = 10_000; + +interface WslSessionsRoot { + distroName: string; + root: string; +} + +function isAbsolutePath(value: string): boolean { + return path.posix.isAbsolute(value) || path.win32.isAbsolute(value); +} + +function isWindowsPath(value: string): boolean { + return value.includes('\\') || /^[A-Za-z]:/.test(value); +} + +function joinSessionsRoot(home: string): string { + const pathModule = isWindowsPath(home) + ? path.win32 + : path.posix; + return pathModule.join(home, 'sessions'); +} + +function parseStandardWslSessionsRoot(value: string): WslSessionsRoot | null { + const normalized = path.win32.normalize(value); + const match = normalized.match( + /^(\\\\wsl\$\\([^\\]+)\\home\\[^\\]+\\\.codex\\sessions)(?:\\|$)/i, + ); + return match ? { distroName: match[2], root: match[1] } : null; +} + +function getWslDistroConstraint(context: ProviderHistoryPathContext): string | null | undefined { + if ((context.hostPlatform ?? process.platform) !== 'win32' || !context.settings) { + return undefined; + } + const settings = getCodexProviderSettings(context.settings); + if (settings.installationMethod !== 'wsl') { + return undefined; + } + return settings.wslDistroOverride + || inferWslDistroFromWindowsPath(context.vaultPath) + || null; +} + +function getTrustedWslRoot( + value: string | null | undefined, + context: ProviderHistoryPathContext, +): WslSessionsRoot | null { + if (!value) { + return null; + } + const distroConstraint = getWslDistroConstraint(context); + if (distroConstraint === undefined) { + return null; + } + const parsed = parseStandardWslSessionsRoot(value); + if (!parsed || ( + distroConstraint + && parsed.distroName.toLowerCase() !== distroConstraint.toLowerCase() + )) { + return null; + } + return parsed; +} + +function getTrustedSessionRoots( + context: ProviderHistoryPathContext, + hints: Array = [], +): string[] { + const roots: string[] = []; + const configuredHome = context.environment.CODEX_HOME?.trim(); + if (configuredHome && isAbsolutePath(configuredHome)) { + roots.push(joinSessionsRoot(configuredHome)); + const distroConstraint = getWslDistroConstraint(context); + if (distroConstraint !== undefined && path.posix.isAbsolute(configuredHome)) { + const hintedDistro = hints + .map(hint => inferWslDistroFromWindowsPath(hint)) + .find((distro): distro is string => !!distro); + const pathMapper = createCodexPathMapper({ + distroName: distroConstraint || hintedDistro, + method: 'wsl', + platformFamily: 'unix', + platformOs: 'linux', + }); + const hostSessionsRoot = pathMapper.toHostPath( + path.posix.join(configuredHome, 'sessions'), + ); + if (hostSessionsRoot) { + roots.push(hostSessionsRoot); + } + } + } + + const home = context.environment.HOME?.trim() + || context.environment.USERPROFILE?.trim() + || os.homedir(); + const homePathModule = isWindowsPath(home) + ? path.win32 + : path.posix; + roots.push(homePathModule.join(home, '.codex', 'sessions')); + for (const hint of hints) { + const wslRoot = getTrustedWslRoot(hint, context); + if (wslRoot) { + roots.push(wslRoot.root); + } + } + return [...new Set(roots)]; +} + +export function resolveCodexTranscriptRootHint( + persistedRoot: string | null | undefined, + context?: ProviderHistoryPathContext, +): string | null { + if (!persistedRoot) { + return null; + } + if (!context) { + return persistedRoot; + } + + return getTrustedSessionRoots(context, [persistedRoot]) + .find(root => isPathWithinRoot(persistedRoot, root)) + ? persistedRoot + : null; +} + +export async function resolveCodexSessionFileHint( + persistedPath: string | null | undefined, + logicalSessionId: string | null | undefined, + context?: ProviderHistoryPathContext, + deadline = Date.now() + CODEX_HISTORY_LOOKUP_TIMEOUT_MS, +): Promise { + if (!context) { + return persistedPath ?? ( + logicalSessionId + ? findCodexSessionFileAsync(logicalSessionId, undefined, Math.max(0, deadline - Date.now())) + : null + ); + } + + const roots = getTrustedSessionRoots(context, [persistedPath]); + if (persistedPath && roots.some(root => isPathWithinRoot(persistedPath, root))) { + return persistedPath; + } + + if (!logicalSessionId) { + return null; + } + + for (const root of roots) { + const remainingMs = Math.max(0, deadline - Date.now()); + if (remainingMs === 0) { + return null; + } + const resolved = await findCodexSessionFileAsync(logicalSessionId, root, remainingMs); + if (resolved) { + return resolved; + } + } + return null; +} diff --git a/src/providers/codex/history/CodexHistoryStore.ts b/src/providers/codex/history/CodexHistoryStore.ts new file mode 100644 index 0000000..0ea098e --- /dev/null +++ b/src/providers/codex/history/CodexHistoryStore.ts @@ -0,0 +1,1811 @@ +import * as fsp from 'node:fs/promises'; + +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import type { ChatMessage, ContentBlock, ImageAttachment, ToolCallInfo } from '../../../core/types'; +import { extractUserDisplayContent } from '../../../utils/context'; +import { + buildImageAttachmentFromBase64, + parseImageDataUri, +} from '../../../utils/imageAttachment'; +import { + extractCodexUserVisibleText, + joinCodexUserTextParts, +} from '../codexUserText'; +import { + appendCodexCommandOutput, + extractCodexExecCellId, + isCodexToolOutputError, + normalizeCodexMcpToolInput, + normalizeCodexMcpToolName, + normalizeCodexMcpToolState, + normalizeCodexToolCall, + normalizeCodexToolInput, + normalizeCodexToolName, + normalizeCodexToolResult, + parseCodexArguments, + readCodexExecCellIdArgument, + stringifyCodexToolOutput, +} from '../normalization/codexToolNormalization'; + +interface CodexEvent { + type: string; + thread_id?: string; + item?: CodexItem; + usage?: { input_tokens: number; cached_input_tokens: number; output_tokens: number }; + error?: { message: string }; + message?: string; +} + +interface CodexItem { + id: string; + type: string; + text?: string; + command?: string; + aggregated_output?: string; + exit_code?: number; + status?: string; + changes?: Array<{ path: string; kind: string }>; + query?: string; + message?: string; + server?: string; + tool?: string; +} + +interface PersistedMessagePart { + image_url?: string | { url?: string }; + type?: string; + text?: string; +} + +interface PersistedMessagePayload { + type: 'message'; + role?: string; + content?: PersistedMessagePart[]; +} + +interface PersistedReasoningPayload { + type: 'reasoning'; + summary?: Array<{ type?: string; text?: string } | string>; + content?: Array<{ type?: string; text?: string } | string>; + text?: string; +} + +interface PersistedToolCallPayload { + type: 'function_call' | 'custom_tool_call'; + name?: string; + arguments?: string; + call_id?: string; + input?: string; +} + +interface PersistedToolCallOutputPayload { + type: 'function_call_output' | 'custom_tool_call_output'; + call_id?: string; + output?: string | unknown[]; +} + +interface PersistedWebSearchCallPayload { + type: 'web_search_call'; + action?: { + type?: string; + query?: string; + queries?: string[]; + url?: string; + pattern?: string; + }; + status?: string; + call_id?: string; +} + +interface PersistedMcpToolCallPayload { + type: 'mcp_tool_call'; + server?: string; + tool?: string; + call_id?: string; + status?: string; + arguments?: string | Record; + result?: { content?: Array<{ type?: string; text?: string }> } | null; + error?: string | null; + duration_ms?: number | null; +} + +interface PersistedEventPayload { + type?: string; + text?: string; + message?: string; +} + +interface PersistedCompactionPayload { + type: 'compaction'; + encrypted_content?: string; +} + +interface ParsedSessionRecord { + timestamp: number; + type?: string; + event?: CodexEvent; + payload?: PersistedPayload; +} + +// --------------------------------------------------------------------------- +// Multi-bubble turn model +// --------------------------------------------------------------------------- + +interface CodexAssistantBubble { + contentChunks: string[]; + thinkingChunks: string[]; + toolCalls: ToolCallInfo[]; + toolIndexesById: Map; + contentBlocks: ContentBlock[]; + startedAt: number; + lastEventAt: number; + interrupted: boolean; +} + +interface CodexTurnState { + id: string; + serverTurnId?: string; + startedAt: number; + completedAt?: number; + completed?: boolean; + lastEventAt: number; + userTimestamp?: number; + userChunks: string[]; + userImages: ImageAttachment[]; + assistantBubbles: CodexAssistantBubble[]; + activeBubbleIndex: number | null; +} + +type PersistedPayload = + | PersistedMessagePayload + | PersistedReasoningPayload + | PersistedToolCallPayload + | PersistedToolCallOutputPayload + | PersistedWebSearchCallPayload + | PersistedMcpToolCallPayload + | PersistedCompactionPayload + | PersistedEventPayload + | undefined; + +// --------------------------------------------------------------------------- +// Turn/bubble lifecycle helpers +// --------------------------------------------------------------------------- + +function newBubble(timestamp: number): CodexAssistantBubble { + return { + contentChunks: [], + thinkingChunks: [], + toolCalls: [], + toolIndexesById: new Map(), + contentBlocks: [], + startedAt: timestamp, + lastEventAt: timestamp, + interrupted: false, + }; +} + +function newTurnState(id: string, timestamp: number): CodexTurnState { + return { + id, + startedAt: timestamp, + lastEventAt: timestamp, + userChunks: [], + userImages: [], + assistantBubbles: [], + activeBubbleIndex: null, + }; +} + +function createPersistedParseContext(): PersistedParseContext { + return { + turns: new Map(), + turnOrder: [], + currentTurnId: null, + toolCallToTurn: new Map(), + suppressedToolOutputIds: new Set(), + terminalSessionToCommandId: new Map(), + stdinCallToCommandId: new Map(), + execCellToCommandId: new Map(), + waitCallToCommand: new Map(), + turnCounter: 0, + }; +} + +function ensureTurn( + turns: Map, + turnOrder: string[], + preferredTurnId: string, + currentTurnId: string | null, + timestamp: number, +): CodexTurnState { + const id = currentTurnId ?? preferredTurnId; + const existing = turns.get(id); + if (existing) { + if (timestamp > 0 && timestamp > existing.lastEventAt) { + existing.lastEventAt = timestamp; + } + return existing; + } + + const turn = newTurnState(id, timestamp); + turns.set(id, turn); + turnOrder.push(id); + return turn; +} + +function ensureAssistantBubble(turn: CodexTurnState, timestamp: number): CodexAssistantBubble { + if (turn.activeBubbleIndex !== null) { + const bubble = turn.assistantBubbles[turn.activeBubbleIndex]; + if (timestamp > 0 && timestamp > bubble.lastEventAt) { + bubble.lastEventAt = timestamp; + } + return bubble; + } + + const bubble = newBubble(timestamp); + turn.assistantBubbles.push(bubble); + turn.activeBubbleIndex = turn.assistantBubbles.length - 1; + return bubble; +} + +function closeAssistantBubble(turn: CodexTurnState): void { + turn.activeBubbleIndex = null; +} + +function pushToolInvocation(bubble: CodexAssistantBubble, toolCall: ToolCallInfo): void { + const existingIndex = bubble.toolIndexesById.get(toolCall.id); + if (existingIndex !== undefined) { + bubble.toolCalls[existingIndex] = toolCall; + return; + } + + bubble.toolIndexesById.set(toolCall.id, bubble.toolCalls.length); + bubble.toolCalls.push(toolCall); + bubble.contentBlocks.push({ type: 'tool_use', toolId: toolCall.id }); +} + +function appendUniqueChunk(chunks: string[], value: string): void { + const trimmed = value.trim(); + if (!trimmed) return; + if (chunks[chunks.length - 1] === trimmed) return; + chunks.push(trimmed); +} + +function replaceLatestChunk(chunks: string[], value: string): void { + const trimmed = value.trim(); + if (!trimmed) return; + chunks.length = 0; + chunks.push(trimmed); +} + +function appendUserChunk(turn: CodexTurnState, value: string, timestamp: number): void { + const chunkCountBefore = turn.userChunks.length; + appendUniqueChunk(turn.userChunks, value); + + if (turn.userChunks.length > chunkCountBefore && !turn.userTimestamp && timestamp > 0) { + turn.userTimestamp = timestamp; + } +} + +function appendUserImages( + turn: CodexTurnState, + content: PersistedMessagePart[] | undefined, + timestamp: number, +): void { + const images = extractMessageImages(content, `codex-img-${turn.id}`, turn.userImages.length); + if (images.length === 0) { + return; + } + + turn.userImages.push(...images); + if (!turn.userTimestamp && timestamp > 0) { + turn.userTimestamp = timestamp; + } +} + +// --------------------------------------------------------------------------- +// Legacy TurnAccumulator — kept for the `event` wrapper format +// --------------------------------------------------------------------------- + +interface TurnAccumulator { + assistantText: string; + thinkingText: string; + toolCalls: ToolCallInfo[]; + contentBlocks: ContentBlock[]; + interrupted: boolean; + timestamp: number; +} + +function newTurn(timestamp = 0): TurnAccumulator { + return { + assistantText: '', + thinkingText: '', + toolCalls: [], + contentBlocks: [], + interrupted: false, + timestamp, + }; +} + +function flushTurn(turn: TurnAccumulator, messages: ChatMessage[], msgIndex: number): number { + if ( + !turn.assistantText && + !turn.thinkingText && + turn.toolCalls.length === 0 + ) { + return msgIndex; + } + + const msg: ChatMessage = { + id: `codex-msg-${msgIndex}`, + role: 'assistant', + content: turn.assistantText, + timestamp: turn.timestamp || Date.now(), + toolCalls: turn.toolCalls.length > 0 ? turn.toolCalls : undefined, + contentBlocks: turn.contentBlocks.length > 0 ? turn.contentBlocks : undefined, + }; + + if (turn.interrupted) { + msg.isInterrupt = true; + } + + messages.push(msg); + return msgIndex + 1; +} + +function setTextBlock(turn: TurnAccumulator, content: string): void { + const index = turn.contentBlocks.findIndex(block => block.type === 'text'); + if (index === -1) { + turn.contentBlocks.push({ type: 'text', content }); + return; + } + + turn.contentBlocks[index] = { type: 'text', content }; +} + +function setThinkingBlock(turn: TurnAccumulator, content: string): void { + const normalized = content.trim(); + if (!normalized) { + return; + } + + turn.thinkingText = normalized; + + const index = turn.contentBlocks.findIndex(block => block.type === 'thinking'); + if (index === -1) { + turn.contentBlocks.push({ type: 'thinking', content: normalized }); + return; + } + + turn.contentBlocks[index] = { type: 'thinking', content: normalized }; +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +function parseTimestamp(value: unknown): number { + if (typeof value !== 'string') { + return 0; + } + + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function parseSessionRecord(line: string): ParsedSessionRecord | null { + let parsed: { + timestamp?: string; + type?: string; + event?: CodexEvent; + payload?: PersistedPayload; + }; + + try { + parsed = JSON.parse(line) as typeof parsed; + } catch { + return null; + } + + return { + timestamp: parseTimestamp(parsed.timestamp), + type: parsed.type, + event: parsed.event, + payload: parsed.payload, + }; +} + +function extractMessageText(content: PersistedMessagePart[] | undefined): string { + if (!Array.isArray(content)) { + return ''; + } + + return content + .map(part => (typeof part?.text === 'string' ? part.text : '')) + .join(''); +} + +function extractUserMessageText(content: PersistedMessagePart[] | undefined): string { + if (!Array.isArray(content)) { + return ''; + } + + return joinCodexUserTextParts( + content.map(part => (typeof part?.text === 'string' ? part.text : '')), + ); +} + +function extractMessageImages( + content: PersistedMessagePart[] | undefined, + idPrefix: string, + startIndex = 0, +): ImageAttachment[] { + if (!Array.isArray(content)) { + return []; + } + + const images: ImageAttachment[] = []; + for (const part of content) { + if (part?.type !== 'input_image') { + continue; + } + + const imageUrl = typeof part.image_url === 'string' + ? part.image_url + : typeof part.image_url?.url === 'string' + ? part.image_url.url + : null; + const parsed = parseImageDataUri(imageUrl); + if (!parsed) { + continue; + } + + const image = buildImageAttachmentFromBase64({ + data: parsed.data, + id: `${idPrefix}-${startIndex + images.length}`, + mediaType: parsed.mediaType, + name: `image-${startIndex + images.length + 1}.${parsed.mediaType.split('/')[1]}`, + }); + if (image) { + images.push(image); + } + } + + return images; +} + +function hasMessageImages(content: PersistedMessagePart[] | undefined): boolean { + if (!Array.isArray(content)) { + return false; + } + + return content.some((part) => { + if (part?.type !== 'input_image') { + return false; + } + const imageUrl = typeof part.image_url === 'string' + ? part.image_url + : typeof part.image_url?.url === 'string' + ? part.image_url.url + : null; + return parseImageDataUri(imageUrl) !== null; + }); +} + +function joinTextParts(parts: Array<{ text?: string } | string>): string { + return parts + .map((part) => { + if (typeof part === 'string') return part; + return typeof part?.text === 'string' ? part.text : ''; + }) + .map(part => part.trim()) + .filter(Boolean) + .join('\n\n') + .trim(); +} + +function extractReasoningText(payload: PersistedReasoningPayload | PersistedEventPayload): string { + if ('summary' in payload && Array.isArray(payload.summary) && payload.summary.length > 0) { + return joinTextParts(payload.summary); + } + + if ('content' in payload && Array.isArray(payload.content) && payload.content.length > 0) { + return joinTextParts(payload.content); + } + + return typeof payload.text === 'string' ? payload.text.trim() : ''; +} + +// --------------------------------------------------------------------------- +// Legacy event wrapper processing (kept as-is) +// --------------------------------------------------------------------------- + +function processLegacyItem( + eventType: string, + item: CodexItem, + turn: TurnAccumulator, +): void { + switch (item.type) { + case 'agent_message': + if (eventType === 'item.completed' || eventType === 'item.updated') { + if (item.text) { + turn.assistantText = item.text; + setTextBlock(turn, item.text); + } + } + break; + + case 'reasoning': + if (eventType === 'item.completed' || eventType === 'item.updated') { + if (item.text) { + setThinkingBlock(turn, item.text); + } + } + break; + + case 'command_execution': + if (eventType === 'item.started') { + turn.toolCalls.push({ + id: item.id, + name: normalizeCodexToolName(item.type), + input: normalizeCodexToolInput(item.type, { command: item.command ?? '' }), + status: 'running', + }); + turn.contentBlocks.push({ type: 'tool_use', toolId: item.id }); + } else if (eventType === 'item.completed') { + const tc = turn.toolCalls.find(tool => tool.id === item.id); + if (tc) { + const rawOutput = item.aggregated_output ?? ''; + tc.result = normalizeCodexToolResult(tc.name, rawOutput); + tc.status = item.exit_code === 0 ? 'completed' : 'error'; + } + } + break; + + case 'file_change': { + const changes = item.changes ?? []; + if (eventType === 'item.started' || eventType === 'item.completed') { + const existing = turn.toolCalls.find(tool => tool.id === item.id); + if (!existing) { + const paths = changes.map(change => `${change.kind}: ${change.path}`).join(', '); + turn.toolCalls.push({ + id: item.id, + name: normalizeCodexToolName('file_change'), + input: { changes }, + status: item.status === 'completed' ? 'completed' : 'error', + result: paths ? `Applied: ${paths}` : 'Applied', + }); + turn.contentBlocks.push({ type: 'tool_use', toolId: item.id }); + } else if (eventType === 'item.completed') { + existing.status = item.status === 'completed' ? 'completed' : 'error'; + } + } + break; + } + + case 'web_search': + if (eventType === 'item.started') { + turn.toolCalls.push({ + id: item.id, + name: normalizeCodexToolName(item.type), + input: normalizeCodexToolInput(item.type, { query: item.query ?? '' }), + status: 'running', + }); + turn.contentBlocks.push({ type: 'tool_use', toolId: item.id }); + } else if (eventType === 'item.completed') { + const tc = turn.toolCalls.find(tool => tool.id === item.id); + if (tc) { + tc.result = 'Search complete'; + tc.status = 'completed'; + } + } + break; + + case 'mcp_tool_call': + if (eventType === 'item.started') { + const server = item.server ?? ''; + const tool = item.tool ?? ''; + turn.toolCalls.push({ + id: item.id, + name: `mcp__${server}__${tool}`, + input: {}, + status: 'running', + }); + turn.contentBlocks.push({ type: 'tool_use', toolId: item.id }); + } else if (eventType === 'item.completed') { + const tc = turn.toolCalls.find(tool => tool.id === item.id); + if (tc) { + tc.status = item.status === 'completed' ? 'completed' : 'error'; + tc.result = item.status === 'completed' ? 'Completed' : 'Failed'; + } + } + break; + + default: + break; + } +} + +// --------------------------------------------------------------------------- +// Persisted-format (response_item) processing — with bubble model +// --------------------------------------------------------------------------- + +interface PersistedParseContext { + turns: Map; + turnOrder: string[]; + currentTurnId: string | null; + toolCallToTurn: Map; + suppressedToolOutputIds: Set; + terminalSessionToCommandId: Map; + stdinCallToCommandId: Map; + execCellToCommandId: Map; + waitCallToCommand: Map; + turnCounter: number; +} + +function nextTurnId(ctx: PersistedParseContext): string { + ctx.turnCounter += 1; + return `turn-${ctx.turnCounter}`; +} + +function processPersistedToolCall( + payload: PersistedToolCallPayload, + timestamp: number, + ctx: PersistedParseContext, +): void { + const callId = payload.call_id; + if (!callId) return; + + const rawArgs = payload.arguments ?? payload.input; + const parsedArgs = parseCodexArguments(rawArgs); + const normalized = normalizeCodexToolCall(payload.name, parsedArgs); + + if (normalized.name === 'wait') { + const cellId = readCodexExecCellIdArgument(normalized.input); + const commandCallId = cellId ? ctx.execCellToCommandId.get(cellId) : undefined; + if (cellId && commandCallId) { + ctx.waitCallToCommand.set(callId, { commandCallId, cellId }); + return; + } + } + + if (normalized.name === 'write_stdin') { + if (isSilentWriteStdinInput(parsedArgs)) { + const terminalSessionId = readTerminalSessionIdArgument(parsedArgs); + const parentCallId = terminalSessionId + ? ctx.terminalSessionToCommandId.get(terminalSessionId) + : undefined; + if (parentCallId) { + ctx.stdinCallToCommandId.set(callId, parentCallId); + } + ctx.suppressedToolOutputIds.add(callId); + return; + } + } + + const turn = ensureTurn(ctx.turns, ctx.turnOrder, nextTurnId(ctx), ctx.currentTurnId, timestamp); + const bubble = ensureAssistantBubble(turn, timestamp); + + const toolCall: ToolCallInfo = { + id: callId, + name: normalized.name, + input: normalized.input, + status: 'running', + }; + + pushToolInvocation(bubble, toolCall); + + ctx.toolCallToTurn.set(callId, { + turnId: turn.id, + bubbleIndex: turn.activeBubbleIndex!, + }); +} + +function processPersistedToolOutput( + payload: PersistedToolCallOutputPayload, + timestamp: number, + ctx: PersistedParseContext, +): void { + const callId = payload.call_id; + if (!callId) return; + + // output can be a string or an array (e.g. view_image returns image objects) + const rawOutput = stringifyCodexToolOutput(payload.output); + + const waitCall = ctx.waitCallToCommand.get(callId); + if (waitCall) { + const parentToolCall = findPersistedToolCallById(ctx, waitCall.commandCallId); + ctx.execCellToCommandId.delete(waitCall.cellId); + if (parentToolCall) { + applyPersistedToolOutput(parentToolCall, payload.output, rawOutput, ctx); + } + ctx.waitCallToCommand.delete(callId); + return; + } + + const parentCommandId = ctx.stdinCallToCommandId.get(callId); + if (parentCommandId) { + const parentToolCall = findPersistedToolCallById(ctx, parentCommandId); + if (parentToolCall) { + applyPersistedToolOutput(parentToolCall, payload.output, rawOutput, ctx, { + allowImplicitCommandCompletion: false, + }); + } + ctx.stdinCallToCommandId.delete(callId); + ctx.suppressedToolOutputIds.delete(callId); + return; + } + + if (ctx.suppressedToolOutputIds.delete(callId)) { + return; + } + + // Cross-turn resolution: look up where the tool call was originally pushed + const origin = ctx.toolCallToTurn.get(callId); + if (origin) { + const originTurn = ctx.turns.get(origin.turnId); + if (originTurn && origin.bubbleIndex < originTurn.assistantBubbles.length) { + const originBubble = originTurn.assistantBubbles[origin.bubbleIndex]; + const existing = originBubble.toolCalls.find(tool => tool.id === callId); + if (existing) { + applyPersistedToolOutput(existing, payload.output, rawOutput, ctx); + return; + } + } + } + + if (payload.type === 'custom_tool_call_output') { + return; + } + + // Fallback: push orphan entry into current turn + const turn = ensureTurn(ctx.turns, ctx.turnOrder, nextTurnId(ctx), ctx.currentTurnId, timestamp); + const bubble = ensureAssistantBubble(turn, timestamp); + const normalizedResult = normalizeCodexToolResult('tool', rawOutput); + + pushToolInvocation(bubble, { + id: callId, + name: 'tool', + input: {}, + status: isCodexToolOutputError(rawOutput) ? 'error' : 'completed', + result: normalizedResult, + }); +} + +function findPersistedToolCallById(ctx: PersistedParseContext, callId: string): ToolCallInfo | null { + const origin = ctx.toolCallToTurn.get(callId); + if (!origin) { + return null; + } + + const turn = ctx.turns.get(origin.turnId); + if (!turn || origin.bubbleIndex >= turn.assistantBubbles.length) { + return null; + } + + return turn.assistantBubbles[origin.bubbleIndex].toolCalls.find(tool => tool.id === callId) ?? null; +} + +function readTerminalSessionIdArgument(input: Record): string | undefined { + const value = input.session_id ?? input.sessionId; + if (typeof value === 'string' && value) return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return undefined; +} + +function isSilentWriteStdinInput(input: Record): boolean { + return typeof input.chars !== 'string' || input.chars.length === 0; +} + +function readPersistedCommandToolResult(rawOutputText: string): { + output: string; + status: 'running' | 'completed' | 'unknown'; + exitCode?: number; + terminalSessionId?: string; + execCellId?: string; +} { + const output = normalizeCodexToolResult('Bash', rawOutputText); + const exitCodeMatch = rawOutputText.match(/(?:Exit code:|Process exited with code)\s*(-?\d+)/i); + const runningMatch = rawOutputText.match(/Process running with session ID\s*([^\n]+)/i); + const execCellId = extractCodexExecCellId(rawOutputText); + + return { + output, + status: exitCodeMatch ? 'completed' : runningMatch || execCellId ? 'running' : 'unknown', + ...(exitCodeMatch ? { exitCode: Number(exitCodeMatch[1] ?? 0) } : {}), + ...(runningMatch ? { terminalSessionId: (runningMatch[1] ?? '').trim() } : {}), + ...(execCellId ? { execCellId } : {}), + }; +} + +function applyPersistedToolOutput( + toolCall: ToolCallInfo, + rawOutputValue: string | unknown[] | undefined, + rawOutputText: string, + ctx: PersistedParseContext, + options: { allowImplicitCommandCompletion?: boolean } = {}, +): void { + if (toolCall.name === 'Bash') { + const commandResult = readPersistedCommandToolResult(rawOutputText); + toolCall.result = appendCodexCommandOutput(toolCall.result, commandResult.output); + if (commandResult.terminalSessionId) { + ctx.terminalSessionToCommandId.set(commandResult.terminalSessionId, toolCall.id); + } + if (commandResult.execCellId) { + ctx.execCellToCommandId.set(commandResult.execCellId, toolCall.id); + } + if (commandResult.status === 'running') { + toolCall.status = 'running'; + return; + } + if (commandResult.status === 'unknown' && options.allowImplicitCommandCompletion === false) { + return; + } + toolCall.status = commandResult.exitCode !== undefined + ? commandResult.exitCode === 0 ? 'completed' : 'error' + : isCodexToolOutputError(rawOutputText) ? 'error' : 'completed'; + return; + } + + toolCall.result = normalizePersistedToolOutput(toolCall, rawOutputValue, rawOutputText); + toolCall.status = isCodexToolOutputError(rawOutputText) ? 'error' : 'completed'; +} + +function normalizePersistedToolOutput( + toolCall: ToolCallInfo, + rawOutputValue: string | unknown[] | undefined, + rawOutputText: string, +): string { + if (Array.isArray(rawOutputValue) && toolCall.name === 'Read') { + const filePath = toolCall.input.file_path; + if (typeof filePath === 'string' && filePath) { + return filePath; + } + } + + return normalizeCodexToolResult(toolCall.name, rawOutputText); +} + +function processPersistedWebSearchCall( + payload: PersistedWebSearchCallPayload, + timestamp: number, + lineIndex: number, + ctx: PersistedParseContext, +): void { + const turn = ensureTurn(ctx.turns, ctx.turnOrder, nextTurnId(ctx), ctx.currentTurnId, timestamp); + const bubble = ensureAssistantBubble(turn, timestamp); + + // Persisted web_search_call entries commonly omit call_id. Use transcript line index + // so live tailing and history reload reconstruct the same visible tool sequence. + const callId = payload.call_id || `tail-ws-${lineIndex}`; + + if (bubble.toolIndexesById.has(callId)) return; + + const input = normalizeCodexToolInput('web_search_call', { + action: payload.action ?? {}, + }); + + const isTerminal = payload.status === 'completed' || payload.status === 'failed' + || payload.status === 'error' || payload.status === 'cancelled'; + + const toolCall: ToolCallInfo = { + id: callId, + name: 'WebSearch', + input, + status: isTerminal ? (payload.status === 'completed' ? 'completed' : 'error') : 'running', + ...(isTerminal ? { result: 'Search complete' } : {}), + }; + + pushToolInvocation(bubble, toolCall); + + ctx.toolCallToTurn.set(callId, { + turnId: turn.id, + bubbleIndex: turn.assistantBubbles.indexOf(bubble), + }); +} + +function processPersistedMcpToolCall( + payload: PersistedMcpToolCallPayload, + timestamp: number, + ctx: PersistedParseContext, +): void { + const callId = payload.call_id; + if (!callId) return; + + const turn = ensureTurn(ctx.turns, ctx.turnOrder, nextTurnId(ctx), ctx.currentTurnId, timestamp); + const bubble = ensureAssistantBubble(turn, timestamp); + + if (bubble.toolIndexesById.has(callId)) return; + + const normalizedInput = normalizeCodexMcpToolInput(payload.arguments); + const normalizedState = normalizeCodexMcpToolState(payload.status, payload.result, payload.error); + + const toolCall: ToolCallInfo = { + id: callId, + name: normalizeCodexMcpToolName(payload.server, payload.tool), + input: normalizedInput, + status: normalizedState.status, + ...(normalizedState.result ? { result: normalizedState.result } : {}), + }; + + pushToolInvocation(bubble, toolCall); + + ctx.toolCallToTurn.set(callId, { + turnId: turn.id, + bubbleIndex: turn.activeBubbleIndex!, + }); +} + +function processPersistedPayload( + payload: PersistedPayload, + timestamp: number, + lineIndex: number, + ctx: PersistedParseContext, +): void { + if (!payload?.type) { + return; + } + + switch (payload.type) { + case 'message': { + const messagePayload = payload as PersistedMessagePayload; + + if (messagePayload.role === 'user') { + const text = extractUserMessageText(messagePayload.content); + const visibleText = extractCodexUserVisibleText(text); + const hasImages = hasMessageImages(messagePayload.content); + if (visibleText === null && !hasImages) break; + + // Close any active bubble in the current turn before starting user content + if (ctx.currentTurnId) { + const prevTurn = ctx.turns.get(ctx.currentTurnId); + if (prevTurn) closeAssistantBubble(prevTurn); + } + + // User message opens a new turn + ctx.currentTurnId = null; + const turn = ensureTurn(ctx.turns, ctx.turnOrder, nextTurnId(ctx), null, timestamp); + ctx.currentTurnId = turn.id; + if (visibleText !== null) { + appendUserChunk(turn, visibleText, timestamp); + } + appendUserImages(turn, messagePayload.content, timestamp); + } else if (messagePayload.role === 'assistant') { + const text = extractMessageText(messagePayload.content); + const turn = ensureTurn(ctx.turns, ctx.turnOrder, nextTurnId(ctx), ctx.currentTurnId, timestamp); + const bubble = ensureAssistantBubble(turn, timestamp); + if (text) { + appendUniqueChunk(bubble.contentChunks, text); + } + } + break; + } + + case 'reasoning': { + const reasoningPayload = payload as PersistedReasoningPayload; + const text = extractReasoningText(reasoningPayload); + if (!text) break; + + const turn = ensureTurn(ctx.turns, ctx.turnOrder, nextTurnId(ctx), ctx.currentTurnId, timestamp); + const bubble = ensureAssistantBubble(turn, timestamp); + appendUniqueChunk(bubble.thinkingChunks, text); + break; + } + + case 'function_call': + case 'custom_tool_call': + processPersistedToolCall(payload as PersistedToolCallPayload, timestamp, ctx); + break; + + case 'function_call_output': + case 'custom_tool_call_output': + processPersistedToolOutput(payload as PersistedToolCallOutputPayload, timestamp, ctx); + break; + + case 'web_search_call': + processPersistedWebSearchCall(payload as PersistedWebSearchCallPayload, timestamp, lineIndex, ctx); + break; + + case 'mcp_tool_call': + processPersistedMcpToolCall(payload as PersistedMcpToolCallPayload, timestamp, ctx); + break; + + case 'compaction': + break; + + default: + break; + } +} + +// --------------------------------------------------------------------------- +// event_msg processing +// --------------------------------------------------------------------------- + +function extractServerTurnId(payload: PersistedEventPayload): string | undefined { + const turnId = (payload as Record).turn_id; + return typeof turnId === 'string' ? turnId : undefined; +} + +function processEventMsg( + payload: PersistedEventPayload, + timestamp: number, + ctx: PersistedParseContext, +): void { + if (!payload?.type) return; + + switch (payload.type) { + case 'task_started': { + const serverTurnId = extractServerTurnId(payload); + const id = nextTurnId(ctx); + const turn = ensureTurn(ctx.turns, ctx.turnOrder, id, null, timestamp); + turn.startedAt = timestamp; + if (serverTurnId) turn.serverTurnId = serverTurnId; + ctx.currentTurnId = turn.id; + break; + } + + case 'task_complete': { + if (ctx.currentTurnId) { + const turn = ctx.turns.get(ctx.currentTurnId); + if (turn) { + turn.completedAt = timestamp; + turn.completed = true; + closeAssistantBubble(turn); + const serverTurnId = extractServerTurnId(payload); + if (serverTurnId && !turn.serverTurnId) turn.serverTurnId = serverTurnId; + } + } + ctx.currentTurnId = null; + break; + } + + case 'turn_aborted': { + if (ctx.currentTurnId) { + const turn = ctx.turns.get(ctx.currentTurnId); + if (turn) { + const bubble = ensureAssistantBubble(turn, timestamp); + bubble.interrupted = true; + closeAssistantBubble(turn); + turn.completedAt = timestamp; + } + } + ctx.currentTurnId = null; + break; + } + + case 'user_message': { + const turn = ensureTurn(ctx.turns, ctx.turnOrder, nextTurnId(ctx), ctx.currentTurnId, timestamp); + const msg = payload.message; + if (typeof msg === 'string') { + const visibleText = extractCodexUserVisibleText(msg); + if (visibleText !== null) { + appendUserChunk(turn, visibleText, timestamp); + } + } + break; + } + + case 'agent_message': { + const turn = ensureTurn(ctx.turns, ctx.turnOrder, nextTurnId(ctx), ctx.currentTurnId, timestamp); + const bubble = ensureAssistantBubble(turn, timestamp); + const msg = payload.message; + if (typeof msg === 'string') { + appendUniqueChunk(bubble.contentChunks, msg); + } + break; + } + + case 'agent_reasoning': { + const text = extractReasoningText(payload); + if (!text) break; + + const turn = ensureTurn(ctx.turns, ctx.turnOrder, nextTurnId(ctx), ctx.currentTurnId, timestamp); + const bubble = ensureAssistantBubble(turn, timestamp); + appendUniqueChunk(bubble.thinkingChunks, text); + break; + } + + case 'context_compacted': { + const activeTurnId = ctx.currentTurnId; + if (activeTurnId) { + const activeTurn = ctx.turns.get(activeTurnId); + if (activeTurn) closeAssistantBubble(activeTurn); + } + + // Auto-compaction can occur in the middle of a running turn. Keep the + // boundary in that turn so later records retain their turn ownership. + const turn = ensureTurn( + ctx.turns, + ctx.turnOrder, + nextTurnId(ctx), + activeTurnId, + timestamp, + ); + const bubble = ensureAssistantBubble(turn, timestamp); + bubble.contentBlocks.push({ type: 'context_compacted' }); + closeAssistantBubble(turn); + break; + } + + default: + break; + } +} + +// --------------------------------------------------------------------------- +// Flush multi-bubble turns to ChatMessage[] +// --------------------------------------------------------------------------- + +function flushBubbleTurnMessages( + turn: CodexTurnState, + msgIndex: number, +): { messages: ChatMessage[]; nextMsgIndex: number } { + const messages: ChatMessage[] = []; + + const visibleUserText = extractCodexUserVisibleText(turn.userChunks.join('\n')); + const userImages = turn.userImages.length > 0 ? turn.userImages : undefined; + if (visibleUserText || userImages) { + const displayContent = visibleUserText ? extractUserDisplayContent(visibleUserText) : undefined; + messages.push({ + id: `codex-msg-${msgIndex}`, + role: 'user', + content: visibleUserText ?? '', + ...(displayContent !== undefined ? { displayContent } : {}), + ...(userImages ? { images: userImages } : {}), + ...(turn.serverTurnId ? { userMessageId: turn.serverTurnId } : {}), + timestamp: turn.userTimestamp || turn.startedAt || Date.now(), + }); + msgIndex += 1; + } + + let lastAssistantTimestamp = 0; + const assistantMessages: ChatMessage[] = []; + + for (const bubble of turn.assistantBubbles) { + const contentText = bubble.contentChunks.join('\n\n'); + const thinkingText = bubble.thinkingChunks.join('\n\n'); + const hasContent = contentText.trim().length > 0; + const hasThinking = thinkingText.trim().length > 0; + const hasToolCalls = bubble.toolCalls.length > 0; + const hasCompactBoundary = bubble.contentBlocks.some(b => b.type === 'context_compacted'); + + if (!hasContent && !hasThinking && !hasToolCalls && !hasCompactBoundary) { + if (bubble.interrupted) { + messages.push({ + id: `codex-msg-${msgIndex}`, + role: 'assistant', + content: '', + timestamp: bubble.startedAt || turn.startedAt || Date.now(), + isInterrupt: true, + }); + msgIndex += 1; + } + continue; + } + + const contentBlocks: ContentBlock[] = []; + if (hasThinking) { + contentBlocks.push({ type: 'thinking', content: thinkingText.trim() }); + } + contentBlocks.push(...bubble.contentBlocks); + if (hasContent) { + contentBlocks.push({ type: 'text', content: contentText.trim() }); + } + + const msg: ChatMessage = { + id: `codex-msg-${msgIndex}`, + role: 'assistant', + content: contentText.trim(), + timestamp: bubble.startedAt || turn.startedAt || Date.now(), + toolCalls: hasToolCalls ? bubble.toolCalls : undefined, + contentBlocks: contentBlocks.length > 0 ? contentBlocks : undefined, + }; + + if (bubble.interrupted) { + msg.isInterrupt = true; + } + + if (bubble.lastEventAt > lastAssistantTimestamp) { + lastAssistantTimestamp = bubble.lastEventAt; + } + + assistantMessages.push(msg); + messages.push(msg); + msgIndex += 1; + } + + if (assistantMessages.length > 0 && turn.userTimestamp && lastAssistantTimestamp > turn.userTimestamp) { + const durationMs = lastAssistantTimestamp - turn.userTimestamp; + const lastMsg = assistantMessages[assistantMessages.length - 1]; + lastMsg.durationSeconds = Math.round(durationMs / 1000); + } + + if (turn.serverTurnId && turn.completed && assistantMessages.length > 0) { + const lastNonInterrupt = [...assistantMessages].reverse().find(m => !m.isInterrupt); + if (lastNonInterrupt) { + lastNonInterrupt.assistantMessageId = turn.serverTurnId; + } + } + + return { messages, nextMsgIndex: msgIndex }; +} + +// --------------------------------------------------------------------------- +// Session file discovery +// --------------------------------------------------------------------------- + +const SAFE_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/; + +function getPathModuleForSessionPath(sessionPath: string): typeof path.posix { + return sessionPath.includes('\\') || /^[A-Za-z]:/.test(sessionPath) + ? path.win32 + : path.posix; +} + +export function deriveCodexSessionsRootFromSessionPath( + sessionFilePath: string | null | undefined, +): string | null { + if (!sessionFilePath) { + return null; + } + + const pathModule = getPathModuleForSessionPath(sessionFilePath); + let current = pathModule.dirname(pathModule.normalize(sessionFilePath)); + let previous: string | null = null; + + while (current && current !== previous) { + if (pathModule.basename(current).toLowerCase() === 'sessions') { + return current; + } + previous = current; + current = pathModule.dirname(current); + } + + return null; +} + +export function deriveCodexMemoriesDirFromSessionsRoot( + sessionsDir: string | null | undefined, +): string | null { + if (!sessionsDir) { + return null; + } + + const pathModule = getPathModuleForSessionPath(sessionsDir); + return pathModule.join(pathModule.dirname(sessionsDir), 'memories'); +} + +export function findCodexSessionFile( + threadId: string, + root: string = path.join(os.homedir(), '.codex', 'sessions'), +): string | null { + if (!threadId || !SAFE_SESSION_ID_PATTERN.test(threadId) || !fs.existsSync(root)) { + return null; + } + + const directPath = path.join(root, `${threadId}.jsonl`); + if (fs.existsSync(directPath)) { + return directPath; + } + + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(current, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + + if (entry.isFile() && entry.name.endsWith(`-${threadId}.jsonl`)) { + return fullPath; + } + } + } + + return null; +} + +export async function findCodexSessionFileAsync( + threadId: string, + root: string = path.join(os.homedir(), '.codex', 'sessions'), + timeoutMs = 10_000, + dependencies: CodexSessionFileLookupDependencies = {}, +): Promise { + if (!threadId || !SAFE_SESSION_ID_PATTERN.test(threadId)) { + return null; + } + + const deadline = Date.now() + Math.max(0, timeoutMs); + const pathExists = dependencies.pathExists ?? defaultPathExists; + const readDirectory = dependencies.readDirectory + ?? ((value: string) => fsp.readdir(value, { withFileTypes: true })); + try { + if (!(await runBeforeDeadline(() => pathExists(root), deadline))) { + return null; + } + const directPath = path.join(root, `${threadId}.jsonl`); + if (await runBeforeDeadline(() => pathExists(directPath), deadline)) { + return directPath; + } + } catch { + return null; + } + + const stack = [root]; + while (stack.length > 0 && Date.now() <= deadline) { + const current = stack.pop(); + if (!current) continue; + + let entries: fs.Dirent[]; + try { + entries = await runBeforeDeadline(() => readDirectory(current), deadline); + } catch { + if (Date.now() >= deadline) { + return null; + } + continue; + } + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + } else if (entry.isFile() && entry.name.endsWith(`-${threadId}.jsonl`)) { + return fullPath; + } + } + } + return null; +} + +export interface CodexSessionFileLookupDependencies { + pathExists?: (value: string) => Promise; + readDirectory?: (value: string) => Promise; +} + +async function runBeforeDeadline( + operation: () => Promise, + deadline: number, +): Promise { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + throw new Error('Codex history lookup deadline exceeded.'); + } + + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + reject(new Error('Codex history lookup deadline exceeded.')); + }, remainingMs); + operation().then( + (value) => { + window.clearTimeout(timer); + resolve(value); + }, + (error) => { + window.clearTimeout(timer); + reject(error instanceof Error ? error : new Error(String(error))); + }, + ); + }); +} + +async function defaultPathExists(value: string): Promise { + try { + await fsp.access(value); + return true; + } catch { + return false; + } +} + +export function parseCodexSessionFile(filePath: string): ChatMessage[] { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + return []; + } + + return parseCodexSessionContent(content); +} + +export async function parseCodexSessionFileAsync( + filePath: string, + timeoutMs = 10_000, +): Promise { + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), timeoutMs); + try { + const content = await fsp.readFile(filePath, { encoding: 'utf-8', signal: controller.signal }); + return parseCodexSessionContent(content); + } catch { + return []; + } finally { + window.clearTimeout(timer); + } +} + +export interface CodexParsedTurn { + turnId: string | null; + messages: ChatMessage[]; +} + +export function parseCodexSessionContent(content: string): ChatMessage[] { + const turns = parseCodexSessionTurns(content); + return turns.flatMap(t => t.messages); +} + +export function parseCodexSessionTurns(content: string): CodexParsedTurn[] { + const records = content + .split('\n') + .filter(line => line.trim()) + .map(parseSessionRecord) + .filter((record): record is ParsedSessionRecord => record !== null); + + // Detect format: legacy uses type=event, modern uses event_msg/response_item + let hasLegacy = false; + let hasModern = false; + for (const record of records) { + if (record.type === 'event') hasLegacy = true; + else if (record.type === 'event_msg' || record.type === 'response_item' || record.type === 'compacted') hasModern = true; + if (hasLegacy && hasModern) break; + } + + // Pure legacy sessions use the old flat accumulator (no turn-level structure) + if (hasLegacy && !hasModern) { + const messages = parseLegacySession(records); + return messages.length > 0 ? [{ turnId: null, messages }] : []; + } + + // Modern or mixed sessions use the bubble model with turn-level grouping + return parseModernSessionTurns(records); +} + +// --------------------------------------------------------------------------- +// Legacy (event wrapper) parser — preserved for backward compat +// --------------------------------------------------------------------------- + +function parseLegacySession(records: ParsedSessionRecord[]): ChatMessage[] { + const messages: ChatMessage[] = []; + let turn = newTurn(); + let msgIndex = 0; + + for (const parsed of records) { + if (parsed.type === 'event' && parsed.event) { + const event = parsed.event; + + switch (event.type) { + case 'turn.started': + if (turn.assistantText || turn.thinkingText || turn.toolCalls.length > 0) { + msgIndex = flushTurn(turn, messages, msgIndex); + } + turn = newTurn(); + break; + + case 'item.started': + case 'item.updated': + case 'item.completed': + if (event.item) { + processLegacyItem(event.type, event.item, turn); + } + break; + + case 'turn.completed': + msgIndex = flushTurn(turn, messages, msgIndex); + turn = newTurn(); + break; + + case 'turn.failed': + turn.interrupted = true; + msgIndex = flushTurn(turn, messages, msgIndex); + turn = newTurn(); + break; + + default: + break; + } + } + } + + flushTurn(turn, messages, msgIndex); + return messages; +} + +// --------------------------------------------------------------------------- +// Modern (response_item + event_msg) parser — bubble model +// --------------------------------------------------------------------------- + +function parseModernSessionTurns(records: ParsedSessionRecord[]): CodexParsedTurn[] { + const ctx = createPersistedParseContext(); + + for (const [lineIndex, parsed] of records.entries()) { + const timestamp = parsed.timestamp; + + // Legacy event records can appear in mixed sessions + if (parsed.type === 'event' && parsed.event) { + processLegacyEventInModernContext(parsed.event, timestamp, ctx); + continue; + } + + if (parsed.type === 'event_msg') { + processEventMsg(parsed.payload as PersistedEventPayload, timestamp, ctx); + continue; + } + + if (parsed.type === 'compacted') { + // Codex replacement_history is compacted provider context, not a role-complete + // UI transcript. The durable visible marker is event_msg:context_compacted. + continue; + } + + if (parsed.type === 'response_item') { + processPersistedPayload(parsed.payload, timestamp, lineIndex, ctx); + } + } + + return flushBubbleTurnsGrouped(ctx.turns, ctx.turnOrder); +} + +function flushBubbleTurnsGrouped( + turns: Map, + turnOrder: string[], +): CodexParsedTurn[] { + const result: CodexParsedTurn[] = []; + let messageOffset = 0; + + for (const turnId of turnOrder) { + const turn = turns.get(turnId); + if (!turn) continue; + const { messages: turnMessages, nextMsgIndex } = flushBubbleTurnMessages(turn, messageOffset); + if (turnMessages.length === 0) continue; + messageOffset = nextMsgIndex; + + result.push({ + turnId: turn.serverTurnId ?? null, + messages: turnMessages, + }); + } + + return result; +} + +function findToolCallOrigin( + ctx: PersistedParseContext, + callId: string, +): ToolCallInfo | null { + const origin = ctx.toolCallToTurn.get(callId); + if (!origin) { + return null; + } + + const turn = ctx.turns.get(origin.turnId); + if (!turn || origin.bubbleIndex >= turn.assistantBubbles.length) { + return null; + } + + return turn.assistantBubbles[origin.bubbleIndex].toolCalls.find(tool => tool.id === callId) ?? null; +} + +function trackToolCallOrigin( + ctx: PersistedParseContext, + callId: string, + turn: CodexTurnState, +): void { + ctx.toolCallToTurn.set(callId, { + turnId: turn.id, + bubbleIndex: turn.activeBubbleIndex!, + }); +} + +function ensureModernLegacyToolCall( + ctx: PersistedParseContext, + timestamp: number, + item: CodexItem, + build: () => ToolCallInfo, +): ToolCallInfo { + const existing = findToolCallOrigin(ctx, item.id); + if (existing) { + return existing; + } + + const turn = ensureTurn(ctx.turns, ctx.turnOrder, nextTurnId(ctx), ctx.currentTurnId, timestamp); + const bubble = ensureAssistantBubble(turn, timestamp); + const toolCall = build(); + pushToolInvocation(bubble, toolCall); + trackToolCallOrigin(ctx, item.id, turn); + return toolCall; +} + +function processLegacyItemInModernContext( + eventType: string, + item: CodexItem, + timestamp: number, + ctx: PersistedParseContext, +): void { + switch (item.type) { + case 'agent_message': { + if ((eventType === 'item.updated' || eventType === 'item.completed') && item.text) { + const turn = ensureTurn(ctx.turns, ctx.turnOrder, nextTurnId(ctx), ctx.currentTurnId, timestamp); + const bubble = ensureAssistantBubble(turn, timestamp); + replaceLatestChunk(bubble.contentChunks, item.text); + } + break; + } + + case 'reasoning': { + if ((eventType === 'item.updated' || eventType === 'item.completed') && item.text) { + const turn = ensureTurn(ctx.turns, ctx.turnOrder, nextTurnId(ctx), ctx.currentTurnId, timestamp); + const bubble = ensureAssistantBubble(turn, timestamp); + replaceLatestChunk(bubble.thinkingChunks, item.text); + } + break; + } + + case 'command_execution': { + if (eventType === 'item.started') { + ensureModernLegacyToolCall(ctx, timestamp, item, () => ({ + id: item.id, + name: normalizeCodexToolName(item.type), + input: normalizeCodexToolInput(item.type, { command: item.command ?? '' }), + status: 'running', + })); + break; + } + + if (eventType === 'item.completed') { + const toolCall = ensureModernLegacyToolCall(ctx, timestamp, item, () => ({ + id: item.id, + name: normalizeCodexToolName(item.type), + input: normalizeCodexToolInput(item.type, { command: item.command ?? '' }), + status: 'running', + })); + const rawOutput = item.aggregated_output ?? ''; + toolCall.result = normalizeCodexToolResult(toolCall.name, rawOutput); + toolCall.status = item.exit_code === 0 ? 'completed' : 'error'; + } + break; + } + + case 'file_change': { + if (eventType !== 'item.started' && eventType !== 'item.completed') { + break; + } + + const changes = item.changes ?? []; + const toolCall = ensureModernLegacyToolCall(ctx, timestamp, item, () => ({ + id: item.id, + name: normalizeCodexToolName('file_change'), + input: { changes }, + status: 'running', + })); + + if (eventType === 'item.completed') { + const paths = changes.map(change => `${change.kind}: ${change.path}`).join(', '); + toolCall.result = paths ? `Applied: ${paths}` : 'Applied'; + toolCall.status = item.status === 'completed' ? 'completed' : 'error'; + } + break; + } + + case 'web_search': { + if (eventType === 'item.started') { + ensureModernLegacyToolCall(ctx, timestamp, item, () => ({ + id: item.id, + name: normalizeCodexToolName(item.type), + input: normalizeCodexToolInput(item.type, { query: item.query ?? '' }), + status: 'running', + })); + break; + } + + if (eventType === 'item.completed') { + const toolCall = ensureModernLegacyToolCall(ctx, timestamp, item, () => ({ + id: item.id, + name: normalizeCodexToolName(item.type), + input: normalizeCodexToolInput(item.type, { query: item.query ?? '' }), + status: 'running', + })); + toolCall.result = 'Search complete'; + toolCall.status = 'completed'; + } + break; + } + + case 'mcp_tool_call': { + if (eventType === 'item.started') { + ensureModernLegacyToolCall(ctx, timestamp, item, () => ({ + id: item.id, + name: `mcp__${item.server ?? ''}__${item.tool ?? ''}`, + input: {}, + status: 'running', + })); + break; + } + + if (eventType === 'item.completed') { + const toolCall = ensureModernLegacyToolCall(ctx, timestamp, item, () => ({ + id: item.id, + name: `mcp__${item.server ?? ''}__${item.tool ?? ''}`, + input: {}, + status: 'running', + })); + toolCall.status = item.status === 'completed' ? 'completed' : 'error'; + toolCall.result = item.status === 'completed' ? 'Completed' : 'Failed'; + } + break; + } + + default: + break; + } +} + +function processLegacyEventInModernContext( + event: CodexEvent, + timestamp: number, + ctx: PersistedParseContext, +): void { + switch (event.type) { + case 'turn.started': { + if (ctx.currentTurnId) { + const previousTurn = ctx.turns.get(ctx.currentTurnId); + if (previousTurn) { + closeAssistantBubble(previousTurn); + } + } + const id = nextTurnId(ctx); + ensureTurn(ctx.turns, ctx.turnOrder, id, null, timestamp); + ctx.currentTurnId = id; + break; + } + + case 'turn.completed': { + if (ctx.currentTurnId) { + const turn = ctx.turns.get(ctx.currentTurnId); + if (turn) closeAssistantBubble(turn); + } + ctx.currentTurnId = null; + break; + } + + case 'turn.failed': { + if (ctx.currentTurnId) { + const turn = ctx.turns.get(ctx.currentTurnId); + if (turn) { + const bubble = ensureAssistantBubble(turn, timestamp); + bubble.interrupted = true; + closeAssistantBubble(turn); + } + } + ctx.currentTurnId = null; + break; + } + + case 'item.started': + case 'item.updated': + case 'item.completed': + if (event.item) { + processLegacyItemInModernContext(event.type, event.item, timestamp, ctx); + } + break; + + default: + break; + } +} diff --git a/src/providers/codex/modelOptions.ts b/src/providers/codex/modelOptions.ts new file mode 100644 index 0000000..f2040cd --- /dev/null +++ b/src/providers/codex/modelOptions.ts @@ -0,0 +1,216 @@ +import { getRuntimeEnvironmentVariables } from '../../core/providers/providerEnvironment'; +import type { ProviderUIOption } from '../../core/providers/types'; +import { getCodexModelsInPickerOrder, getDefaultCodexModel } from './models'; +import { + encodeCodexModelSelectionId, + isCodexModelSelectionId, + looksLikeCodexModel, + toCodexRuntimeModelId, +} from './modelSelection'; +import { getCodexProviderSettings, getVisibleCodexModelIds } from './settings'; +import { formatCodexModelLabel } from './types/models'; + +function createCustomCodexModelOption(modelId: string, description: string): ProviderUIOption { + const runtimeModelId = toCodexRuntimeModelId(modelId); + return { + value: encodeCodexModelSelectionId(runtimeModelId), + label: formatCodexModelLabel(runtimeModelId), + description, + }; +} + +function getConfiguredEnvModel(settings: Record): string | null { + const modelId = getRuntimeEnvironmentVariables(settings, 'codex').OPENAI_MODEL?.trim(); + return modelId ? modelId : null; +} + +export function getConfiguredEnvCustomModel(settings: Record): string | null { + const modelId = getConfiguredEnvModel(settings); + const discoveredModels = getCodexProviderSettings(settings).discoveredModels; + return modelId && !discoveredModels.some(model => model.model === modelId) ? modelId : null; +} + +export function parseConfiguredCustomModelIds(value: string): string[] { + const modelIds: string[] = []; + const seen = new Set(); + + for (const line of value.split(/\r?\n/)) { + const modelId = line.trim(); + if (!modelId || seen.has(modelId)) { + continue; + } + + seen.add(modelId); + modelIds.push(modelId); + } + + return modelIds; +} + +export function getCodexModelOptions(settings: Record): ProviderUIOption[] { + const codexSettings = getCodexProviderSettings(settings); + const getModelLabel = (modelId: string, fallback: string): string => { + return codexSettings.modelAliases[modelId] ?? fallback; + }; + const visibleModelIds = new Set(getVisibleCodexModelIds( + codexSettings.visibleModels, + codexSettings.discoveredModels, + )); + const savedProviderModel = ( + settings.savedProviderModel + && typeof settings.savedProviderModel === 'object' + && !Array.isArray(settings.savedProviderModel) + ) + ? settings.savedProviderModel as Record + : null; + const pinnedModelIds = new Set(); + for (const value of [ + settings.model, + savedProviderModel?.codex, + getConfiguredEnvModel(settings), + ]) { + if (typeof value === 'string' && value.trim()) { + pinnedModelIds.add(toCodexRuntimeModelId(value)); + } + } + const absentPinnedSelections: string[] = []; + const currentModel = typeof settings.model === 'string' ? settings.model.trim() : ''; + if ( + codexSettings.discoveredModels.length === 0 + && currentModel + && ( + isCodexModelSelectionId(currentModel) + || looksLikeCodexModel(toCodexRuntimeModelId(currentModel)) + ) + ) { + absentPinnedSelections.push(currentModel); + } + const savedCodexModel = typeof savedProviderModel?.codex === 'string' + ? savedProviderModel.codex.trim() + : ''; + if (codexSettings.discoveredModels.length === 0 && savedCodexModel) { + absentPinnedSelections.push(savedCodexModel); + } + + const pickerOrderedModels = getCodexModelsInPickerOrder(codexSettings.discoveredModels); + const visibleDiscoveredModels = pickerOrderedModels + .filter(model => visibleModelIds.has(model.model)); + const pinnedDiscoveredModels = pickerOrderedModels.filter(model => + !visibleModelIds.has(model.model) && pinnedModelIds.has(model.model) + ); + const models: ProviderUIOption[] = visibleDiscoveredModels.map(model => ({ + value: model.model, + label: getModelLabel(model.model, model.displayName), + description: model.description || undefined, + })); + const seenModelIds = new Set(visibleDiscoveredModels.map(model => model.model)); + + const persistedVisibleModels = codexSettings.visibleModels === null + ? [] + : [...codexSettings.visibleModels].reverse(); + for (const modelId of persistedVisibleModels) { + if (seenModelIds.has(modelId)) { + continue; + } + + seenModelIds.add(modelId); + models.push({ + value: modelId, + label: getModelLabel(modelId, formatCodexModelLabel(modelId)), + description: 'Selected model', + }); + } + + for (const model of pinnedDiscoveredModels) { + seenModelIds.add(model.model); + models.push({ + value: model.model, + label: getModelLabel(model.model, model.displayName), + description: model.description || undefined, + }); + } + + for (const selection of absentPinnedSelections) { + const modelId = toCodexRuntimeModelId(selection); + if (seenModelIds.has(modelId)) { + continue; + } + + seenModelIds.add(modelId); + const fallbackOption = ( + isCodexModelSelectionId(selection) || !looksLikeCodexModel(modelId) + ? createCustomCodexModelOption(modelId, 'Selected model') + : { + value: modelId, + label: formatCodexModelLabel(modelId), + description: 'Selected model', + } + ); + models.push({ + ...fallbackOption, + label: getModelLabel(modelId, fallbackOption.label), + }); + } + + const envModel = getConfiguredEnvCustomModel(settings); + if (envModel) { + const runtimeModelId = toCodexRuntimeModelId(envModel); + const existingIndex = models.findIndex(option => + toCodexRuntimeModelId(option.value) === runtimeModelId + ); + if (existingIndex >= 0) { + models.splice(existingIndex, 1); + } + seenModelIds.add(runtimeModelId); + models.unshift(createCustomCodexModelOption(envModel, 'Custom (env)')); + } + + for (const configuredModelId of parseConfiguredCustomModelIds(codexSettings.customModels)) { + const modelId = toCodexRuntimeModelId(configuredModelId); + if (seenModelIds.has(modelId)) { + continue; + } + + seenModelIds.add(modelId); + models.push(createCustomCodexModelOption(modelId, 'Custom model')); + } + + return models; +} + +export function resolveCodexModelSelection( + settings: Record, + currentModel: string, +): string | null { + const modelOptions = getCodexModelOptions(settings); + const envModel = getConfiguredEnvModel(settings); + if (envModel) { + const envRuntimeModel = toCodexRuntimeModelId(envModel); + const envOption = modelOptions.find(option => + option.value === envModel + || toCodexRuntimeModelId(option.value) === envRuntimeModel + ); + return envOption?.value ?? envModel; + } + + if (currentModel) { + const currentRuntimeModel = toCodexRuntimeModelId(currentModel); + const currentOption = modelOptions.find(option => + option.value === currentModel + || toCodexRuntimeModelId(option.value) === currentRuntimeModel + ); + if (currentOption) { + return currentOption.value; + } + } + + const codexSettings = getCodexProviderSettings(settings); + const visibleModelIds = new Set(getVisibleCodexModelIds( + codexSettings.visibleModels, + codexSettings.discoveredModels, + )); + const defaultModel = getDefaultCodexModel( + codexSettings.discoveredModels.filter(model => visibleModelIds.has(model.model)), + ); + return defaultModel?.model ?? modelOptions[0]?.value ?? null; +} diff --git a/src/providers/codex/modelSelection.ts b/src/providers/codex/modelSelection.ts new file mode 100644 index 0000000..85fb4ba --- /dev/null +++ b/src/providers/codex/modelSelection.ts @@ -0,0 +1,21 @@ +import { + encodeProviderModelSelectionId, + isProviderModelSelectionId, + toProviderRuntimeModelId, +} from '../../core/providers/modelSelection'; + +export function encodeCodexModelSelectionId(modelId: string): string { + return encodeProviderModelSelectionId('codex', modelId); +} + +export function isCodexModelSelectionId(modelId: string): boolean { + return isProviderModelSelectionId('codex', modelId); +} + +export function toCodexRuntimeModelId(modelId: string): string { + return toProviderRuntimeModelId('codex', modelId); +} + +export function looksLikeCodexModel(modelId: string): boolean { + return /^gpt-/i.test(modelId) || /^o\d/i.test(modelId); +} diff --git a/src/providers/codex/models.ts b/src/providers/codex/models.ts new file mode 100644 index 0000000..260e4d1 --- /dev/null +++ b/src/providers/codex/models.ts @@ -0,0 +1,213 @@ +import { + DEFAULT_REASONING_VALUE, + resolvePreferredReasoningDefault, +} from '../../core/providers/reasoning'; +import { toCodexRuntimeModelId } from './modelSelection'; +import { formatCodexModelLabel } from './types/models'; + +export interface CodexReasoningEffortOption { + value: string; + description: string; +} + +export interface CodexModelServiceTier { + id: string; + name: string; + description: string; +} + +export interface CodexDiscoveredModel { + model: string; + displayName: string; + description: string; + supportedReasoningEfforts: CodexReasoningEffortOption[]; + defaultReasoningEffort: string; + serviceTiers: CodexModelServiceTier[]; + defaultServiceTier: string | null; + inputModalities: Array<'text' | 'image'>; + isDefault: boolean; +} + +const DEFAULT_INPUT_MODALITIES: Array<'text' | 'image'> = ['text', 'image']; +const EXCLUDED_REASONING_EFFORTS = new Set(['ultra']); + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim(); + return normalized || null; +} + +function normalizeReasoningEfforts(value: unknown): CodexReasoningEffortOption[] { + if (!Array.isArray(value)) { + return []; + } + + const efforts: CodexReasoningEffortOption[] = []; + const seen = new Set(); + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + + const effort = normalizeNonEmptyString(entry.value ?? entry.reasoningEffort); + if (!effort || EXCLUDED_REASONING_EFFORTS.has(effort.toLowerCase()) || seen.has(effort)) { + continue; + } + + seen.add(effort); + efforts.push({ + value: effort, + description: normalizeNonEmptyString(entry.description) ?? '', + }); + } + + return efforts; +} + +function normalizeServiceTiers(value: unknown): CodexModelServiceTier[] { + if (!Array.isArray(value)) { + return []; + } + + const tiers: CodexModelServiceTier[] = []; + const seen = new Set(); + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + + const id = normalizeNonEmptyString(entry.id); + if (!id || seen.has(id)) { + continue; + } + + seen.add(id); + tiers.push({ + id, + name: normalizeNonEmptyString(entry.name) ?? id, + description: normalizeNonEmptyString(entry.description) ?? '', + }); + } + + return tiers; +} + +function normalizeInputModalities(value: unknown): Array<'text' | 'image'> { + if (value === undefined) { + return [...DEFAULT_INPUT_MODALITIES]; + } + if (!Array.isArray(value)) { + return []; + } + + const modalities = new Set<'text' | 'image'>(); + for (const entry of value) { + if (typeof entry === 'string' && (entry === 'text' || entry === 'image')) { + modalities.add(entry); + } + } + return Array.from(modalities); +} + +export function normalizeCodexDiscoveredModels(value: unknown): CodexDiscoveredModel[] { + if (!Array.isArray(value)) { + return []; + } + + const models: CodexDiscoveredModel[] = []; + const seen = new Set(); + for (const entry of value) { + if (!isRecord(entry) || entry.hidden === true) { + continue; + } + + const model = normalizeNonEmptyString(entry.model ?? entry.id); + if (!model || seen.has(model)) { + continue; + } + + const supportedReasoningEfforts = normalizeReasoningEfforts(entry.supportedReasoningEfforts); + let defaultReasoningEffort = normalizeNonEmptyString(entry.defaultReasoningEffort); + if ( + !defaultReasoningEffort + || !supportedReasoningEfforts.some(option => option.value === defaultReasoningEffort) + ) { + if ( + defaultReasoningEffort + && EXCLUDED_REASONING_EFFORTS.has(defaultReasoningEffort.toLowerCase()) + && supportedReasoningEfforts.length > 0 + ) { + defaultReasoningEffort = resolvePreferredReasoningDefault( + supportedReasoningEfforts.map(option => option.value), + supportedReasoningEfforts[0].value, + ); + } else { + continue; + } + } + + const serviceTiers = normalizeServiceTiers(entry.serviceTiers); + const defaultServiceTier = normalizeNonEmptyString(entry.defaultServiceTier); + + seen.add(model); + models.push({ + model, + displayName: normalizeNonEmptyString(entry.displayName) ?? formatCodexModelLabel(model), + description: normalizeNonEmptyString(entry.description) ?? '', + supportedReasoningEfforts, + defaultReasoningEffort, + serviceTiers, + defaultServiceTier, + inputModalities: normalizeInputModalities(entry.inputModalities), + isDefault: entry.isDefault === true, + }); + } + + return models; +} + +export function findCodexModel( + models: CodexDiscoveredModel[], + modelId: string | undefined, +): CodexDiscoveredModel | null { + if (!modelId) { + return null; + } + + const runtimeModelId = toCodexRuntimeModelId(modelId); + return models.find(model => model.model === runtimeModelId) ?? null; +} + +export function getDefaultCodexModel( + models: CodexDiscoveredModel[], +): CodexDiscoveredModel | null { + return models.find(model => model.isDefault) ?? models[0] ?? null; +} + +export function getCodexModelsInPickerOrder( + models: CodexDiscoveredModel[], +): CodexDiscoveredModel[] { + return [...models].reverse(); +} + +export function getCodexDefaultReasoningEffort( + model: CodexDiscoveredModel, +): string { + return resolvePreferredReasoningDefault( + model.supportedReasoningEfforts.map(option => option.value), + model.defaultReasoningEffort || DEFAULT_REASONING_VALUE, + ); +} + +export function getCodexFastServiceTier( + model: CodexDiscoveredModel, +): CodexModelServiceTier | null { + return model.serviceTiers.find(tier => tier.name.trim().toLowerCase() === 'fast') ?? null; +} diff --git a/src/providers/codex/normalization/codexSubagentNormalization.ts b/src/providers/codex/normalization/codexSubagentNormalization.ts new file mode 100644 index 0000000..da5bcaa --- /dev/null +++ b/src/providers/codex/normalization/codexSubagentNormalization.ts @@ -0,0 +1,227 @@ +import type { ProviderSubagentLifecycleAdapter } from '../../../core/providers/types'; +import { + TOOL_CLOSE_AGENT, + TOOL_SPAWN_AGENT, + TOOL_WAIT, + TOOL_WAIT_AGENT, +} from '../../../core/tools/toolNames'; +import type { SubagentInfo, ToolCallInfo } from '../../../core/types'; + +interface CodexSpawnResult { + agentId?: string; + nickname?: string; +} + +interface CodexWaitStatus { + completed?: string; + error?: string; + failed?: string; +} + +interface CodexWaitResult { + statuses: Record; + timedOut: boolean; +} + +function parseJsonObject(raw: string | undefined): Record | null { + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return null; + } + + return null; +} + +export function extractCodexSpawnResult(raw: string | undefined): CodexSpawnResult { + const parsed = parseJsonObject(raw); + if (!parsed) return {}; + + return { + agentId: typeof parsed.agent_id === 'string' ? parsed.agent_id : undefined, + nickname: typeof parsed.nickname === 'string' ? parsed.nickname : undefined, + }; +} + +export function extractCodexWaitResult(raw: string | undefined): CodexWaitResult { + const parsed = parseJsonObject(raw); + if (!parsed) { + return { statuses: {}, timedOut: false }; + } + + const rawStatuses = parsed.status; + const statuses: Record = {}; + + if (rawStatuses && typeof rawStatuses === 'object' && !Array.isArray(rawStatuses)) { + for (const [agentId, value] of Object.entries(rawStatuses as Record)) { + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const status = value as Record; + statuses[agentId] = { + completed: typeof status.completed === 'string' ? status.completed : undefined, + error: typeof status.error === 'string' ? status.error : undefined, + failed: typeof status.failed === 'string' ? status.failed : undefined, + }; + } + } + + return { + statuses, + timedOut: parsed.timed_out === true, + }; +} + +function getCodexSubagentPrompt(input: Record): string { + return typeof input.message === 'string' ? input.message : ''; +} + +function getCodexSubagentModel(input: Record): string { + return typeof input.model === 'string' ? input.model : ''; +} + +function getCodexSubagentDescription( + nickname: string | undefined, + model: string, +): string { + if (nickname && model) return `${nickname} (${model})`; + if (nickname) return nickname; + if (model) return `Codex subagent (${model})`; + return 'Codex subagent'; +} + +function resolveCodexWaitCompletion( + spawnResult: CodexSpawnResult, + siblingToolCalls: ToolCallInfo[], +): { status: SubagentInfo['status']; result?: string } { + for (const toolCall of siblingToolCalls) { + if (toolCall.name !== TOOL_WAIT && toolCall.name !== TOOL_WAIT_AGENT) { + continue; + } + + const waitResult = extractCodexWaitResult(toolCall.result); + const statusEntries = Object.entries(waitResult.statuses); + if (statusEntries.length === 0 && !waitResult.timedOut) { + continue; + } + + let agentStatus: CodexWaitStatus | undefined; + if (spawnResult.agentId) { + agentStatus = waitResult.statuses[spawnResult.agentId]; + } else if (statusEntries.length === 1) { + agentStatus = statusEntries[0][1]; + } + + if (agentStatus?.completed) { + return { status: 'completed', result: agentStatus.completed }; + } + + const failure = agentStatus?.error ?? agentStatus?.failed; + if (failure) { + return { status: 'error', result: failure }; + } + + if (waitResult.timedOut) { + return { status: 'error', result: 'Timed out' }; + } + } + + return { status: 'running' }; +} + +export function buildCodexSubagentInfo( + spawnToolCall: ToolCallInfo, + siblingToolCalls: ToolCallInfo[] = [], +): SubagentInfo { + const prompt = getCodexSubagentPrompt(spawnToolCall.input); + const model = getCodexSubagentModel(spawnToolCall.input); + const spawnResult = extractCodexSpawnResult(spawnToolCall.result); + const description = getCodexSubagentDescription(spawnResult.nickname, model); + + if (spawnToolCall.status === 'error') { + return { + id: spawnToolCall.id, + description, + prompt, + mode: 'sync', + isExpanded: false, + status: 'error', + result: spawnToolCall.result, + toolCalls: [], + }; + } + + const completion = resolveCodexWaitCompletion(spawnResult, siblingToolCalls); + + return { + id: spawnToolCall.id, + description, + prompt, + mode: 'sync', + isExpanded: false, + status: completion.status, + result: completion.result, + toolCalls: [], + ...(spawnResult.agentId ? { agentId: spawnResult.agentId } : {}), + }; +} + +export function isCodexSubagentSpawnToolCall(toolCall: ToolCallInfo): boolean { + return toolCall.name === TOOL_SPAWN_AGENT; +} + +export const codexSubagentLifecycleAdapter: ProviderSubagentLifecycleAdapter = { + isHiddenTool(name: string): boolean { + return name === TOOL_WAIT || name === TOOL_WAIT_AGENT || name === TOOL_CLOSE_AGENT; + }, + isSpawnTool(name: string): boolean { + return name === TOOL_SPAWN_AGENT; + }, + isWaitTool(name: string): boolean { + return name === TOOL_WAIT || name === TOOL_WAIT_AGENT; + }, + isCloseTool(name: string): boolean { + return name === TOOL_CLOSE_AGENT; + }, + resolveSpawnToolIds( + waitToolCall, + agentIdToSpawnId, + ): string[] { + const spawnIds = new Set(); + const waitResult = extractCodexWaitResult(waitToolCall.result); + + for (const agentId of Object.keys(waitResult.statuses)) { + const spawnId = agentIdToSpawnId.get(agentId); + if (spawnId) { + spawnIds.add(spawnId); + } + } + + const targets = Array.isArray(waitToolCall.input.targets) + ? waitToolCall.input.targets + : Array.isArray(waitToolCall.input.ids) + ? waitToolCall.input.ids + : []; + for (const target of targets) { + if (typeof target !== 'string') continue; + const spawnId = agentIdToSpawnId.get(target); + if (spawnId) { + spawnIds.add(spawnId); + } + } + + return [...spawnIds]; + }, + buildSubagentInfo(spawnToolCall, siblingToolCalls = []): SubagentInfo { + return buildCodexSubagentInfo(spawnToolCall, siblingToolCalls); + }, + extractSpawnResult(raw: string | undefined) { + return extractCodexSpawnResult(raw); + }, + extractWaitResult(raw: string | undefined) { + return extractCodexWaitResult(raw); + }, +}; diff --git a/src/providers/codex/normalization/codexToolNormalization.ts b/src/providers/codex/normalization/codexToolNormalization.ts new file mode 100644 index 0000000..dcf1a0e --- /dev/null +++ b/src/providers/codex/normalization/codexToolNormalization.ts @@ -0,0 +1,719 @@ +/** + * Shared Codex tool normalization layer. + * + * Used by both CodexChatRuntime (live streaming) and CodexHistoryStore (history reload) + * to ensure tool identity parity between live and restored conversations. + */ + +// --------------------------------------------------------------------------- +// Tool name normalization +// --------------------------------------------------------------------------- + +const TOOL_NAME_MAP: Record = { + command_execution: 'Bash', + shell_command: 'Bash', + shell: 'Bash', + exec_command: 'Bash', + update_plan: 'TodoWrite', + request_user_input: 'AskUserQuestion', + view_image: 'Read', + web_search: 'WebSearch', + web_search_call: 'WebSearch', + file_change: 'apply_patch', +}; + +/** Native Codex tools that should NOT be remapped. */ +const NATIVE_TOOLS = new Set([ + 'apply_patch', + 'write_stdin', + 'spawn_agent', + 'send_input', + 'wait', + 'wait_agent', + 'resume_agent', + 'close_agent', +]); + +export function normalizeCodexToolName(rawName: string | undefined): string { + if (!rawName) return 'tool'; + if (NATIVE_TOOLS.has(rawName)) return rawName; + return TOOL_NAME_MAP[rawName] ?? rawName; +} + +export interface NormalizedCodexToolCall { + name: string; + input: Record; +} + +export function normalizeCodexToolCall( + rawName: string | undefined, + rawInput: Record, +): NormalizedCodexToolCall { + const nestedCall = rawName === 'exec' ? decodeExecEnvelope(rawInput) : null; + const effectiveName = nestedCall?.name ?? rawName; + const effectiveInput = nestedCall?.input ?? rawInput; + + return { + name: normalizeCodexToolName(effectiveName), + input: normalizeCodexToolInput(effectiveName, effectiveInput), + }; +} + +function decodeExecEnvelope(input: Record): { + name: string; + input: Record; +} | null { + const source = firstNonEmptyString(input.raw, input.value); + if (!source) return null; + + const tokens = tokenizeExecEnvelope(source); + if (!tokens) return null; + + const calls = findExecEnvelopeToolCalls(tokens); + if (!calls || calls.length !== 1) return null; + + const call = calls[0]; + if (!call) return null; + + if (call.name === 'exec_command') { + const command = extractExecCommand(tokens, call); + return command ? { name: call.name, input: { cmd: command } } : null; + } + + if (call.name === 'apply_patch') { + const patch = extractApplyPatch(tokens, call); + return patch ? { name: call.name, input: { patch } } : null; + } + + return null; +} + +type JavaScriptTokenKind = 'identifier' | 'string' | 'punctuation'; + +interface JavaScriptToken { + kind: JavaScriptTokenKind; + value: string; +} + +interface ExecEnvelopeToolCall { + name: string; + toolTokenIndex: number; + openParenTokenIndex: number; +} + +function tokenizeExecEnvelope(source: string): JavaScriptToken[] | null { + const tokens: JavaScriptToken[] = []; + + for (let index = 0; index < source.length;) { + const char = source[index] ?? ''; + const next = source[index + 1] ?? ''; + + if (/\s/.test(char)) { + index += 1; + continue; + } + + if (char === '/' && next === '/') { + const lineEnd = source.indexOf('\n', index + 2); + index = lineEnd === -1 ? source.length : lineEnd + 1; + continue; + } + + if (char === '/' && next === '*') { + const commentEnd = source.indexOf('*/', index + 2); + if (commentEnd === -1) return null; + index = commentEnd + 2; + continue; + } + + if (char === '"' || char === "'") { + const stringToken = readJavaScriptStringToken(source, index); + if (!stringToken) return null; + tokens.push({ kind: 'string', value: stringToken.value }); + index = stringToken.end; + continue; + } + + // Template literals and slash expressions need a full JavaScript lexer. + // Preserve the generic exec envelope instead of guessing when they are present. + if (char === '`' || char === '/') { + return null; + } + + if (/[A-Za-z_$]/.test(char)) { + let end = index + 1; + while (end < source.length && /[\w$]/.test(source[end] ?? '')) { + end += 1; + } + tokens.push({ kind: 'identifier', value: source.slice(index, end) }); + index = end; + continue; + } + + tokens.push({ kind: 'punctuation', value: char }); + index += 1; + } + + return tokens; +} + +function readJavaScriptStringToken( + source: string, + startIndex: number, +): { value: string; end: number } | null { + const quote = source[startIndex]; + if (quote !== '"' && quote !== "'") return null; + + for (let index = startIndex + 1; index < source.length; index += 1) { + if (source[index] === '\\') { + index += 1; + continue; + } + if (source[index] !== quote) continue; + + const literal = source.slice(startIndex, index + 1); + if (quote === '"') { + try { + const parsed = JSON.parse(literal) as unknown; + return typeof parsed === 'string' ? { value: parsed, end: index + 1 } : null; + } catch { + return null; + } + } + + return { + value: decodeSingleQuotedString(literal.slice(1, -1)), + end: index + 1, + }; + } + + return null; +} + +function findExecEnvelopeToolCalls( + tokens: readonly JavaScriptToken[], +): ExecEnvelopeToolCall[] | null { + const calls: ExecEnvelopeToolCall[] = []; + + for (let index = 0; index < tokens.length; index += 1) { + const toolsToken = tokens[index]; + const previousToken = tokens[index - 1]; + if ( + toolsToken?.kind !== 'identifier' + || toolsToken.value !== 'tools' + || previousToken?.value === '.' + ) { + continue; + } + + const dotToken = tokens[index + 1]; + const nameToken = tokens[index + 2]; + const openParenToken = tokens[index + 3]; + if ( + dotToken?.value !== '.' + || nameToken?.kind !== 'identifier' + || openParenToken?.value !== '(' + ) { + return null; + } + + calls.push({ + name: nameToken.value, + toolTokenIndex: index, + openParenTokenIndex: index + 3, + }); + } + + return calls; +} + +function extractExecCommand( + tokens: readonly JavaScriptToken[], + call: ExecEnvelopeToolCall, +): string | null { + const closeParenTokenIndex = findMatchingToken(tokens, call.openParenTokenIndex, '(', ')'); + if (closeParenTokenIndex === null) return null; + if (tokens[call.openParenTokenIndex + 1]?.value !== '{') return null; + + let objectDepth = 0; + for (let index = call.openParenTokenIndex + 1; index < closeParenTokenIndex; index += 1) { + const token = tokens[index]; + if (token?.value === '{') { + objectDepth += 1; + continue; + } + if (token?.value === '}') { + objectDepth -= 1; + continue; + } + if ( + objectDepth === 1 + && token?.value === 'cmd' + && tokens[index + 1]?.value === ':' + && tokens[index + 2]?.kind === 'string' + ) { + return tokens[index + 2]?.value ?? null; + } + } + + return null; +} + +function extractApplyPatch( + tokens: readonly JavaScriptToken[], + call: ExecEnvelopeToolCall, +): string | null { + const argumentToken = tokens[call.openParenTokenIndex + 1]; + if (argumentToken?.kind === 'string') return argumentToken.value; + if (argumentToken?.kind !== 'identifier') return null; + + let patch: string | null = null; + for (let index = 0; index <= call.toolTokenIndex - 4; index += 1) { + const declarationToken = tokens[index]; + if ( + declarationToken?.kind === 'identifier' + && (declarationToken.value === 'const' + || declarationToken.value === 'let' + || declarationToken.value === 'var') + && tokens[index + 1]?.value === argumentToken.value + && tokens[index + 2]?.value === '=' + && tokens[index + 3]?.kind === 'string' + ) { + patch = tokens[index + 3]?.value ?? null; + } + } + + return patch; +} + +function findMatchingToken( + tokens: readonly JavaScriptToken[], + openTokenIndex: number, + open: string, + close: string, +): number | null { + let depth = 0; + + for (let index = openTokenIndex; index < tokens.length; index += 1) { + const value = tokens[index]?.value; + if (value === open) { + depth += 1; + } else if (value === close) { + depth -= 1; + if (depth === 0) return index; + } + } + + return null; +} + +function decodeSingleQuotedString(value: string): string { + return value.replace(/\\([\\'"nrtbfv0])/g, (_match, escaped: string) => { + const escapes: Record = { + '\\': '\\', + "'": "'", + '"': '"', + n: '\n', + r: '\r', + t: '\t', + b: '\b', + f: '\f', + v: '\v', + 0: '\0', + }; + return escapes[escaped] ?? escaped; + }); +} + +// --------------------------------------------------------------------------- +// Tool input normalization +// --------------------------------------------------------------------------- + +export function normalizeCodexToolInput( + rawName: string | undefined, + input: Record, +): Record { + switch (rawName) { + case 'command_execution': + case 'shell_command': + case 'shell': + case 'exec_command': + return { command: normalizeCommandValue(input.command ?? input.cmd ?? '') }; + + case 'update_plan': + return { todos: normalizeUpdatePlanTodos(input) }; + + case 'request_user_input': + return { questions: normalizeQuestions(input) }; + + case 'view_image': + return { + ...input, + file_path: stringifyCodexValue(input.path ?? input.file_path), + }; + + case 'web_search': + case 'web_search_call': + return normalizeWebSearchInput(input); + + case 'apply_patch': + return normalizeApplyPatchInput(input); + + default: + return input; + } +} + +function normalizeUpdatePlanTodos(input: Record): Array> { + const plan = input.plan; + if (!Array.isArray(plan)) return []; + + return plan.map((entry: unknown) => { + if (!entry || typeof entry !== 'object') return { id: '', title: '', status: 'pending' }; + const item = entry as Record; + const text = stringifyCodexValue(item.step ?? item.title ?? item.content); + return { + id: stringifyCodexValue(item.id), + content: text, + activeForm: text, + status: stringifyCodexValue(item.status) || 'pending', + }; + }); +} + +function normalizeQuestions(input: Record): Array> { + const questions = input.questions; + if (!Array.isArray(questions)) return []; + + return questions.map((entry: unknown, index: number) => { + if (!entry || typeof entry !== 'object') { + return { + question: `Question ${index + 1}`, + header: `Q${index + 1}`, + options: [], + multiSelect: false, + }; + } + const item = entry as Record; + const options = Array.isArray(item.options) + ? item.options + .map((option: unknown) => { + if (typeof option === 'string') { + return { label: option, description: '' }; + } + if (!option || typeof option !== 'object') { + return null; + } + const raw = option as Record; + const label = typeof raw.label === 'string' ? raw.label : ''; + const description = typeof raw.description === 'string' ? raw.description : ''; + if (!label) return null; + return { label, description }; + }) + .filter((option): option is { label: string; description: string } => option !== null) + : []; + + return { + question: stringifyCodexValue(item.question) || `Question ${index + 1}`, + ...(item.id ? { id: stringifyCodexValue(item.id) } : {}), + header: typeof item.header === 'string' && item.header.trim() + ? String(item.header) + : `Q${index + 1}`, + options, + multiSelect: Boolean(item.multiSelect ?? item.multi_select), + }; + }); +} + +function normalizeCommandValue(value: unknown): string { + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value + .map(stringifyCodexValue) + .filter(Boolean) + .join(' ') + .trim(); + } + return stringifyCodexValue(value); +} + +function stringifyCodexValue(value: unknown): string { + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (value === null || value === undefined) return ''; + + try { + return JSON.stringify(value); + } catch { + return ''; + } +} + +function normalizeWebSearchInput(input: Record): Record { + const action = input.action && typeof input.action === 'object' + ? input.action as Record + : {}; + + const queries = normalizeStringArray(action.queries ?? input.queries); + const query = firstNonEmptyString(action.query, input.query, queries[0]); + const url = firstNonEmptyString(action.url, input.url); + const pattern = firstNonEmptyString(action.pattern, input.pattern); + const explicitType = firstNonEmptyString(action.type, input.actionType, input.action_type); + + const actionType = explicitType + || (url && pattern ? 'find_in_page' : url ? 'open_page' : (query || queries.length > 0) ? 'search' : ''); + + const normalized: Record = {}; + if (actionType) normalized.actionType = actionType; + if (query) normalized.query = query; + if (queries.length > 0) normalized.queries = queries; + if (url) normalized.url = url; + if (pattern) normalized.pattern = pattern; + return normalized; +} + +function normalizeApplyPatchInput(input: Record): Record { + const patch = firstNonEmptyString(input.patch, input.raw, input.value); + if (!patch) return input; + + const normalized: Record = { ...input, patch }; + delete normalized.raw; + delete normalized.value; + return normalized; +} + +function firstNonEmptyString(...values: unknown[]): string { + for (const value of values) { + if (typeof value === 'string' && value.trim()) { + return value; + } + } + return ''; +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + + const uniqueValues = new Set(); + for (const entry of value) { + if (typeof entry !== 'string') continue; + const trimmed = entry.trim(); + if (!trimmed) continue; + uniqueValues.add(trimmed); + } + + return [...uniqueValues]; +} + +// --------------------------------------------------------------------------- +// MCP tool normalization +// --------------------------------------------------------------------------- + +interface CodexMcpResultPart { + type?: string; + text?: string; +} + +interface CodexMcpResultPayload { + content?: CodexMcpResultPart[] | null; +} + +export interface NormalizedCodexMcpToolState { + isTerminal: boolean; + isError: boolean; + status: 'running' | 'completed' | 'error'; + result?: string; +} + +export function normalizeCodexMcpToolName(server: unknown, tool: unknown): string { + const serverName = typeof server === 'string' ? server : ''; + const toolName = typeof tool === 'string' ? tool : ''; + if (!serverName && !toolName) return 'tool'; + return `mcp__${serverName}__${toolName}`; +} + +export function normalizeCodexMcpToolInput(rawArguments: unknown): Record { + if (typeof rawArguments === 'string') { + return parseCodexArguments(rawArguments); + } + + if (rawArguments && typeof rawArguments === 'object' && !Array.isArray(rawArguments)) { + return rawArguments as Record; + } + + return {}; +} + +export function normalizeCodexMcpToolState( + rawStatus: unknown, + resultPayload?: unknown, + rawError?: unknown, +): NormalizedCodexMcpToolState { + const status = typeof rawStatus === 'string' ? rawStatus : ''; + const error = typeof rawError === 'string' ? rawError : ''; + const resultText = extractCodexMcpResultText(resultPayload); + const isTerminalStatus = status === 'completed' + || status === 'failed' + || status === 'error' + || status === 'cancelled'; + const isTerminal = isTerminalStatus || Boolean(error) || Boolean(resultText); + const isError = Boolean(error) || status === 'failed' || status === 'error' || status === 'cancelled'; + + let result = error || resultText; + if (!result && isTerminalStatus) { + result = status === 'completed' ? 'Completed' : 'Failed'; + } + + return { + isTerminal, + isError, + status: isTerminal ? (isError ? 'error' : 'completed') : 'running', + ...(result ? { result } : {}), + }; +} + +function extractCodexMcpResultText(resultPayload?: unknown): string { + if (!resultPayload || typeof resultPayload !== 'object') return ''; + + const content = (resultPayload as CodexMcpResultPayload).content; + if (!Array.isArray(content)) return ''; + + return content + .map(item => (typeof item?.text === 'string' ? item.text : '')) + .filter(Boolean) + .join('\n'); +} + +// --------------------------------------------------------------------------- +// Tool result normalization +// --------------------------------------------------------------------------- + +/** + * Tools whose results should get terminal-style unwrapping. + * Uses normalized names only — callers always pass through normalizeCodexToolName first. + */ +const TERMINAL_RESULT_TOOLS = new Set([ + 'Bash', + 'write_stdin', +]); + +export function normalizeCodexToolResult( + normalizedName: string, + rawResult: string, +): string { + if (!rawResult) return rawResult; + if (!TERMINAL_RESULT_TOOLS.has(normalizedName)) return rawResult; + return unwrapTerminalResult(rawResult); +} + +export function stringifyCodexToolOutput(value: unknown): string { + if (typeof value === 'string') return value; + if (value === undefined) return ''; + + if (Array.isArray(value)) { + const textParts = value + .map(part => { + if (!part || typeof part !== 'object' || Array.isArray(part)) return ''; + const text = (part as Record).text; + return typeof text === 'string' ? text : ''; + }) + .filter(Boolean); + if (textParts.length > 0) return textParts.join(''); + } + + try { + const result = JSON.stringify(value); + return typeof result === 'string' ? result : String(value); + } catch { + return String(value); + } +} + +export function extractCodexExecCellId(output: string): string | undefined { + const match = output.trimStart().match(/^Script running with cell ID\s+([^\n]+)/i); + return match?.[1]?.trim() || undefined; +} + +export function readCodexExecCellIdArgument( + input: Record, +): string | undefined { + const value = input.cell_id ?? input.cellId; + if (typeof value === 'string' && value) return value; + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + return undefined; +} + +export function appendCodexCommandOutput(previous: string | undefined, next: string): string { + if (!next) return previous ?? ''; + if (!previous) return next; + if (previous.endsWith('\n') || next.startsWith('\n')) return previous + next; + return `${previous}\n${next}`; +} + +function unwrapTerminalResult(raw: string): string { + let result = raw; + + // Unwrap JSON { output: "..." } wrapper + const trimmed = result.trim(); + if (trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed) as { output?: unknown }; + if (typeof parsed.output === 'string') { + result = parsed.output; + } + } catch { /* not JSON, keep as-is */ } + } + + // Strip "Output:\n" prefix + const outputMarker = 'Output:\n'; + const markerIndex = result.indexOf(outputMarker); + if (markerIndex >= 0) { + result = result.slice(markerIndex + outputMarker.length); + } + + return result; +} + +// --------------------------------------------------------------------------- +// Error detection +// --------------------------------------------------------------------------- + +export function isCodexToolOutputError(output: string): boolean { + const exitCodeMatch = output.match(/(?:Exit code:|Process exited with code)\s*(\d+)/i); + if (exitCodeMatch) { + return Number(exitCodeMatch[1]) !== 0; + } + + const trimmed = output.trim(); + + // Detect "Error:" / "error:" prefix + if (/^[Ee]rror:/.test(trimmed)) return true; + + // Detect JSON { "error": ... } wrapper + if (trimmed.startsWith('{')) { + try { + const parsed = JSON.parse(trimmed) as Record; + if ('error' in parsed) return true; + } catch { /* not JSON */ } + } + + return false; +} + +// --------------------------------------------------------------------------- +// Argument parsing +// --------------------------------------------------------------------------- + +export function parseCodexArguments(raw: string | undefined): Record { + if (!raw) return {}; + + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + return { value: parsed }; + } catch { + return { raw }; + } +} diff --git a/src/providers/codex/prompt/encodeCodexTurn.ts b/src/providers/codex/prompt/encodeCodexTurn.ts new file mode 100644 index 0000000..6dda117 --- /dev/null +++ b/src/providers/codex/prompt/encodeCodexTurn.ts @@ -0,0 +1,57 @@ +import type { ChatTurnRequest, PreparedChatTurn } from '../../../core/runtime/types'; + +function isCompactCommand(text: string): boolean { + return /^\/compact(\s|$)/i.test(text); +} + +export function encodeCodexTurn(request: ChatTurnRequest): PreparedChatTurn { + const isCompact = isCompactCommand(request.text); + + if (isCompact) { + return { + request, + persistedContent: request.text, + prompt: request.text, + isCompact: true, + mcpMentions: new Set(), + }; + } + + const sections: string[] = []; + sections.push(request.text); + + if (request.currentNotePath) { + sections.push(`\n[Current note: ${request.currentNotePath}]`); + } + + if (request.editorSelection?.selectedText) { + sections.push( + `\n[Editor selection from ${request.editorSelection.notePath || 'current note'}:\n${request.editorSelection.selectedText}\n]`, + ); + } + + if (request.browserSelection?.selectedText) { + sections.push( + `\n[Browser selection from ${request.browserSelection.url ?? 'unknown page'}:\n${request.browserSelection.selectedText}\n]`, + ); + } + + if (request.canvasSelection) { + const nodeList = request.canvasSelection.nodeIds.join(', '); + if (nodeList) { + sections.push( + `\n[Canvas selection from ${request.canvasSelection.canvasPath}:\n${nodeList}\n]`, + ); + } + } + + const prompt = sections.join(''); + + return { + request, + persistedContent: request.text, + prompt, + isCompact: false, + mcpMentions: new Set(), + }; +} diff --git a/src/providers/codex/registration.ts b/src/providers/codex/registration.ts new file mode 100644 index 0000000..328aa34 --- /dev/null +++ b/src/providers/codex/registration.ts @@ -0,0 +1,50 @@ +import type { ProviderModule } from '../../core/providers/types'; +import { codexWorkspaceRegistration } from './app/CodexWorkspaceServices'; +import { CodexInlineEditService } from './auxiliary/CodexInlineEditService'; +import { CodexInstructionRefineService } from './auxiliary/CodexInstructionRefineService'; +import { CodexTaskResultInterpreter } from './auxiliary/CodexTaskResultInterpreter'; +import { CodexTitleGenerationService } from './auxiliary/CodexTitleGenerationService'; +import { CODEX_PROVIDER_CAPABILITIES } from './capabilities'; +import { codexSettingsReconciler } from './env/CodexSettingsReconciler'; +import { CodexConversationHistoryService } from './history/CodexConversationHistoryService'; +import { codexSubagentLifecycleAdapter } from './normalization/codexSubagentNormalization'; +import { CodexChatRuntime } from './runtime/CodexChatRuntime'; +import { getCodexProviderSettings, normalizeCodexStoredConfig } from './settings'; +import { codexChatUIConfig } from './ui/CodexChatUIConfig'; + +export const codexProviderRegistration: ProviderModule = { + id: 'codex', + displayName: 'Codex', + blankTabOrder: 15, + isEnabled: (settings) => getCodexProviderSettings(settings).enabled, + capabilities: CODEX_PROVIDER_CAPABILITIES, + environmentKeyPatterns: [/^OPENAI_/i, /^CODEX_/i], + chatUIConfig: codexChatUIConfig, + settingsReconciler: codexSettingsReconciler, + settingsStorage: { + hostScopedFields: ['cliPathsByHost', 'installationMethodsByHost', 'wslDistroOverridesByHost'], + legacyTopLevelFields: [ + 'codexSafeMode', + 'codexCliPath', + 'codexCliPathsByHost', + 'codexReasoningSummary', + 'codexEnabled', + 'lastCodexEnvHash', + ], + runtimeOnlyFields: ['discoveredModels'], + normalizeStored(target, stored) { + const normalization = normalizeCodexStoredConfig(stored); + target.providerConfigs ??= {}; + (target.providerConfigs as Record).codex = normalization.config; + return normalization.changed; + }, + }, + createRuntime: ({ plugin }) => new CodexChatRuntime(plugin), + createTitleGenerationService: (plugin) => new CodexTitleGenerationService(plugin), + createInstructionRefineService: (plugin) => new CodexInstructionRefineService(plugin), + createInlineEditService: (plugin) => new CodexInlineEditService(plugin), + historyService: new CodexConversationHistoryService(), + taskResultInterpreter: new CodexTaskResultInterpreter(), + subagentLifecycleAdapter: codexSubagentLifecycleAdapter, + workspace: codexWorkspaceRegistration, +}; diff --git a/src/providers/codex/runtime/CodexAppServerProcess.ts b/src/providers/codex/runtime/CodexAppServerProcess.ts new file mode 100644 index 0000000..045d59f --- /dev/null +++ b/src/providers/codex/runtime/CodexAppServerProcess.ts @@ -0,0 +1,132 @@ +import { type ChildProcess, spawn } from 'child_process'; +import type { Readable, Writable } from 'stream'; + +import { + resolveWindowsCmdShimSpawnSpec, + terminateSpawnedProcess, + type WindowsCmdShimSpawnSpec, +} from '../../../utils/windowsCmdShim'; +import type { CodexLaunchSpec } from './codexLaunchTypes'; + +const SIGKILL_TIMEOUT_MS = 3_000; +const FINAL_SHUTDOWN_TIMEOUT_MS = 3_000; +const STDERR_BUFFER_LIMIT = 8_192; + +type ExitCallback = (code: number | null, signal: string | null) => void; + +export class CodexAppServerProcess { + private proc: ChildProcess | null = null; + private alive = false; + private exitCallbacks: ExitCallback[] = []; + private resolvedSpawnSpec: WindowsCmdShimSpawnSpec | null = null; + private stderrBuffer = ''; + + constructor( + private readonly launchSpec: Pick, + ) {} + + start(): void { + const resolvedSpawnSpec = resolveWindowsCmdShimSpawnSpec(this.launchSpec); + this.resolvedSpawnSpec = resolvedSpawnSpec; + + this.proc = spawn(resolvedSpawnSpec.command, resolvedSpawnSpec.args, { + stdio: ['pipe', 'pipe', 'pipe'], + cwd: this.launchSpec.spawnCwd, + env: this.launchSpec.env, + windowsHide: true, + ...(resolvedSpawnSpec.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + }); + + this.alive = true; + + this.proc.on('exit', () => { + this.alive = false; + }); + + this.proc.on('close', (code, signal) => { + this.alive = false; + for (const cb of this.exitCallbacks) { + cb(code, signal); + } + }); + + this.proc.on('error', () => { + this.alive = false; + }); + + this.proc.stderr?.on('data', (chunk: Buffer | string) => { + const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk); + this.stderrBuffer = `${this.stderrBuffer}${text}`.slice(-STDERR_BUFFER_LIMIT); + }); + } + + get stdin(): Writable { + if (!this.proc?.stdin) throw new Error('Process not started'); + return this.proc.stdin; + } + + get stdout(): Readable { + if (!this.proc?.stdout) throw new Error('Process not started'); + return this.proc.stdout; + } + + get stderr(): Readable { + if (!this.proc?.stderr) throw new Error('Process not started'); + return this.proc.stderr; + } + + isAlive(): boolean { + return this.alive; + } + + getStderrSnapshot(): string { + return this.stderrBuffer.trim(); + } + + onExit(callback: ExitCallback): void { + this.exitCallbacks.push(callback); + } + + offExit(callback: ExitCallback): void { + const idx = this.exitCallbacks.indexOf(callback); + if (idx !== -1) this.exitCallbacks.splice(idx, 1); + } + + async shutdown(): Promise { + if (!this.proc || !this.alive) return; + + return new Promise((resolve) => { + let killTimer: number | null = null; + let finalTimer: number | null = null; + const cleanup = () => { + if (killTimer !== null) window.clearTimeout(killTimer); + if (finalTimer !== null) window.clearTimeout(finalTimer); + this.proc?.off('exit', onExit); + }; + const finish = () => { + cleanup(); + resolve(); + }; + const onExit = () => { + finish(); + }; + + this.proc!.once('exit', onExit); + this.killProc('SIGTERM'); + + killTimer = window.setTimeout(() => { + if (this.alive) { + this.killProc('SIGKILL'); + } + finalTimer = window.setTimeout(finish, FINAL_SHUTDOWN_TIMEOUT_MS); + }, SIGKILL_TIMEOUT_MS); + }); + } + + private killProc(signal: NodeJS.Signals): boolean { + if (!this.proc) { + return false; + } + return terminateSpawnedProcess(this.proc, signal, spawn, this.resolvedSpawnSpec); + } +} diff --git a/src/providers/codex/runtime/CodexAuxQueryRunner.ts b/src/providers/codex/runtime/CodexAuxQueryRunner.ts new file mode 100644 index 0000000..4b50fb9 --- /dev/null +++ b/src/providers/codex/runtime/CodexAuxQueryRunner.ts @@ -0,0 +1,187 @@ +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import { getDefaultCodexModel } from '../models'; +import { toCodexRuntimeModelId } from '../modelSelection'; +import { getCodexProviderSettings } from '../settings'; +import { CodexAppServerProcess } from './CodexAppServerProcess'; +import { resolveCodexAppServerLaunchSpec } from './codexAppServerSupport'; +import type { + AgentMessageDeltaNotification, + ErrorNotification, + InitializeResult, + ThreadStartResult, + TurnCompletedNotification, + TurnStartResult, +} from './codexAppServerTypes'; +import type { CodexLaunchSpec } from './codexLaunchTypes'; +import { CodexRpcTransport } from './CodexRpcTransport'; +import { createCodexRuntimeContext } from './CodexRuntimeContext'; + +export interface CodexAuxQueryConfig { + systemPrompt: string; + model?: string; + abortController?: AbortController; + onTextChunk?: (accumulatedText: string) => void; +} + +/** + * Runs ephemeral Codex app-server queries for auxiliary tasks + * (title generation, instruction refinement, inline edit). + * Manages its own process lifecycle, separate from the main chat runtime. + * Supports multi-turn conversations within a single thread. + */ +export class CodexAuxQueryRunner { + private process: CodexAppServerProcess | null = null; + private transport: CodexRpcTransport | null = null; + private threadId: string | null = null; + private launchSpec: CodexLaunchSpec | null = null; + + constructor(private readonly plugin: ProviderHost) {} + + async query(config: CodexAuxQueryConfig, prompt: string): Promise { + if (!this.process || !this.transport) { + await this.startProcess(); + } + + if (!this.threadId) { + const model = config.model + ? toCodexRuntimeModelId(config.model) + : this.resolveProviderModel(); + const result = await this.transport!.request('thread/start', { + ...(model ? { model } : {}), + cwd: this.launchSpec?.targetCwd ?? process.cwd(), + approvalPolicy: 'never', + sandbox: 'read-only', + baseInstructions: config.systemPrompt, + experimentalRawEvents: true, + persistExtendedHistory: false, + }); + this.threadId = result.thread.id; + } + + let accumulatedText = ''; + let turnError: string | null = null; + let resolveWait: (() => void) | null = null; + + const donePromise = new Promise((resolve) => { + resolveWait = resolve; + }); + + this.transport!.onNotification('item/agentMessage/delta', (params) => { + const p = params as AgentMessageDeltaNotification; + accumulatedText += p.delta; + config.onTextChunk?.(accumulatedText); + }); + + this.transport!.onNotification('turn/completed', (params) => { + const p = params as TurnCompletedNotification; + if (p.turn.status === 'failed' && p.turn.error) { + turnError = p.turn.error.message; + } + resolveWait?.(); + }); + + this.transport!.onNotification('error', (params) => { + const p = params as ErrorNotification; + if (!p.willRetry) { + turnError = p.error.message; + resolveWait?.(); + } + }); + + // Resolve if process dies unexpectedly to avoid hanging forever + const exitHandler = (): void => { + if (!turnError) turnError = 'Codex app-server process exited unexpectedly'; + resolveWait?.(); + }; + this.process!.onExit(exitHandler); + + // Register abort handler before turn/start to avoid race condition + let turnId: string | null = null; + const abortHandler = (): void => { + if (this.transport && this.threadId && turnId) { + this.transport.request('turn/interrupt', { + threadId: this.threadId, + turnId, + }).catch(() => {}); + } + resolveWait?.(); + }; + + config.abortController?.signal.addEventListener('abort', abortHandler, { once: true }); + + // Check if already aborted before starting the turn + if (config.abortController?.signal.aborted) { + config.abortController.signal.removeEventListener('abort', abortHandler); + this.process?.offExit(exitHandler); + throw new Error('Cancelled'); + } + + const turnResult = await this.transport!.request('turn/start', { + threadId: this.threadId, + input: [{ type: 'text', text: prompt }], + model: config.model ? toCodexRuntimeModelId(config.model) : undefined, + }); + turnId = turnResult.turn.id; + + try { + await donePromise; + } finally { + config.abortController?.signal.removeEventListener('abort', abortHandler); + this.process?.offExit(exitHandler); + } + + if (config.abortController?.signal.aborted) { + throw new Error('Cancelled'); + } + + if (turnError) { + throw new Error(turnError); + } + + return accumulatedText; + } + + reset(): void { + this.threadId = null; + this.launchSpec = null; + if (this.transport) { + this.transport.dispose(); + this.transport = null; + } + if (this.process) { + this.process.shutdown().catch(() => {}); + this.process = null; + } + } + + private resolveProviderModel(): string | undefined { + const providerSettings = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + this.plugin.settings, + 'codex', + ); + const model = providerSettings.model; + if (typeof model === 'string' && model.trim()) { + return toCodexRuntimeModelId(model); + } + + return getDefaultCodexModel(getCodexProviderSettings(providerSettings).discoveredModels)?.model; + } + + private async startProcess(): Promise { + this.launchSpec = resolveCodexAppServerLaunchSpec(this.plugin, 'codex'); + this.process = new CodexAppServerProcess(this.launchSpec); + this.process.start(); + + this.transport = new CodexRpcTransport(this.process); + this.transport.start(); + + const initializeResult = await this.transport.request('initialize', { + clientInfo: { name: 'claudian-aux', version: '1.0.0' }, + capabilities: { experimentalApi: true }, + }); + + createCodexRuntimeContext(this.launchSpec, initializeResult); + this.transport.notify('initialized'); + } +} diff --git a/src/providers/codex/runtime/CodexBinaryLocator.ts b/src/providers/codex/runtime/CodexBinaryLocator.ts new file mode 100644 index 0000000..1ba29c0 --- /dev/null +++ b/src/providers/codex/runtime/CodexBinaryLocator.ts @@ -0,0 +1,163 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { findCliBinaryPath, resolveConfiguredCliPath } from '../../../utils/cliBinaryLocator'; +import { parseEnvironmentVariables } from '../../../utils/env'; +import { expandHomePath } from '../../../utils/path'; +import type { CodexInstallationMethod } from '../settings'; +import type { CodexExecutionTarget } from './codexLaunchTypes'; + +export function isWindowsStyleCliReference(value: string | null | undefined): boolean { + const trimmed = (value ?? '').trim(); + if (!trimmed) { + return false; + } + + return /^[A-Za-z]:[\\/]/.test(trimmed) + || trimmed.startsWith('\\\\') + || /\.(?:exe|cmd|bat|ps1)$/i.test(trimmed); +} + +export function findCodexBinaryPath( + additionalPath?: string, + platform: NodeJS.Platform = process.platform, +): string | null { + const explicitPathBinary = findCodexBinaryInDirs( + parsePathEntriesForPlatform(additionalPath, platform), + platform, + ); + if (explicitPathBinary) { + return explicitPathBinary; + } + + const preferredBinary = findCodexBinaryInDirs( + getPreferredCodexBinaryDirs(platform), + platform, + ); + if (preferredBinary) { + return preferredBinary; + } + + return findCliBinaryPath('codex', additionalPath, platform); +} + +function getCodexBinaryNames(platform: NodeJS.Platform): string[] { + return platform === 'win32' + ? ['codex.exe', 'codex.cmd', 'codex'] + : ['codex']; +} + +function findCodexBinaryInDirs(dirs: string[], platform: NodeJS.Platform): string | null { + const binaryNames = getCodexBinaryNames(platform); + + for (const dir of dirs) { + if (!dir) continue; + + for (const binaryName of binaryNames) { + const candidate = path.join(dir, binaryName); + if (isExistingFile(candidate)) { + return candidate; + } + } + } + + return null; +} + +function getPreferredCodexBinaryDirs(platform: NodeJS.Platform): string[] { + const home = getHomeDir(); + + if (platform === 'darwin') { + return [ + path.join(home, 'Applications', 'Codex.app', 'Contents', 'Resources'), + '/Applications/Codex.app/Contents/Resources', + path.join(home, 'Applications', 'Codex.app', 'Contents', 'MacOS'), + '/Applications/Codex.app/Contents/MacOS', + path.join(home, '.local', 'bin'), + ]; + } + + if (platform !== 'win32') { + return [ + path.join(home, '.local', 'bin'), + ]; + } + + return []; +} + +function getHomeDir(): string { + return process.env.HOME || process.env.USERPROFILE || os.homedir(); +} + +function isExistingFile(filePath: string): boolean { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +function parsePathEntriesForPlatform(pathValue: string | undefined, platform: NodeJS.Platform): string[] { + if (!pathValue) { + return []; + } + + const delimiter = platform === 'win32' ? ';' : ':'; + return pathValue + .split(delimiter) + .map(segment => stripSurroundingQuotes(segment.trim())) + .filter(segment => { + if (!segment) return false; + const upper = segment.toUpperCase(); + return upper !== '$PATH' && upper !== '${PATH}' && upper !== '%PATH%'; + }) + .map(segment => expandHomePath(segment)); +} + +function stripSurroundingQuotes(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; +} + +export function resolveCodexCliPath( + hostnamePath: string | undefined, + legacyPath: string | undefined, + envText: string, + options: { + executionTarget?: CodexExecutionTarget; + installationMethod?: CodexInstallationMethod; + hostPlatform?: NodeJS.Platform; + } = {}, +): string | null { + const hostPlatform = options.hostPlatform ?? process.platform; + const isWslTarget = options.executionTarget + ? options.executionTarget.method === 'wsl' + : hostPlatform === 'win32' && options.installationMethod === 'wsl'; + + if (isWslTarget) { + const configuredCommand = [hostnamePath, legacyPath] + .map(value => (value ?? '').trim()) + .find(value => value.length > 0 && !isWindowsStyleCliReference(value)); + return configuredCommand || 'codex'; + } + + const configuredHostnamePath = resolveConfiguredCliPath(hostnamePath); + if (configuredHostnamePath) { + return configuredHostnamePath; + } + + const configuredLegacyPath = resolveConfiguredCliPath(legacyPath); + if (configuredLegacyPath) { + return configuredLegacyPath; + } + + const customEnv = parseEnvironmentVariables(envText || ''); + return findCodexBinaryPath(customEnv.PATH, hostPlatform); +} diff --git a/src/providers/codex/runtime/CodexChatRuntime.ts b/src/providers/codex/runtime/CodexChatRuntime.ts new file mode 100644 index 0000000..6972ea0 --- /dev/null +++ b/src/providers/codex/runtime/CodexChatRuntime.ts @@ -0,0 +1,1382 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + buildSystemPrompt, + computeSystemPromptKey, + type SystemPromptSettings, +} from '../../../core/prompt/mainAgent'; +import { getProviderSettingsSnapshotWithModel } from '../../../core/providers/conversationModel'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import type { ProviderCapabilities, ProviderId } from '../../../core/providers/types'; +import type { ChatRuntime } from '../../../core/runtime/ChatRuntime'; +import type { + ApprovalCallback, + AskUserQuestionCallback, + AutoTurnCallback, + ChatRewindMode, + ChatRewindResult, + ChatRuntimeConversationState, + ChatRuntimeEnsureReadyOptions, + ChatRuntimeQueryOptions, + ChatTurnMetadata, + ChatTurnRequest, + ExitPlanModeCallback, + PreparedChatTurn, + SessionUpdateResult, + SubagentRuntimeState, +} from '../../../core/runtime/types'; +import type { ChatMessage, Conversation, ForkSource, SlashCommand, StreamChunk } from '../../../core/types'; +import { getVaultPath } from '../../../utils/path'; +import { buildContextFromHistory } from '../../../utils/session'; +import { CODEX_PROVIDER_CAPABILITIES } from '../capabilities'; +import { + deriveCodexMemoriesDirFromSessionsRoot, + deriveCodexSessionsRootFromSessionPath, + findCodexSessionFile, +} from '../history/CodexHistoryStore'; +import { findCodexModel, getDefaultCodexModel } from '../models'; +import { toCodexRuntimeModelId } from '../modelSelection'; +import { encodeCodexTurn } from '../prompt/encodeCodexTurn'; +import { + type CodexSafeMode, + getCodexProviderSettings, + getEffectiveCodexReasoningSummary, +} from '../settings'; +import { + extractExplicitCodexSkillNames, + findPreferredCodexSkillByName, +} from '../skills/CodexSkillListingService'; +import { type CodexProviderState, getCodexState } from '../types'; +import { CodexAppServerProcess } from './CodexAppServerProcess'; +import { + initializeCodexAppServerTransport, + resolveCodexAppServerLaunchSpec, +} from './codexAppServerSupport'; +import type { + SandboxPolicy, + ServerRequestResolvedNotification, + SkillInput, + SkillsListResult, + ThreadCompactStartResult, + ThreadForkResult, + ThreadResumeResult, + ThreadRollbackResult, + ThreadStartResult, + TurnStartedNotification, + TurnStartResult, + TurnSteerResult, + UserInput, +} from './codexAppServerTypes'; +import type { CodexLaunchSpec } from './codexLaunchTypes'; +import { CodexNotificationRouter } from './CodexNotificationRouter'; +import { CodexRpcTransport } from './CodexRpcTransport'; +import { type CodexRuntimeContext, createCodexRuntimeContext } from './CodexRuntimeContext'; +import { CodexServerRequestRouter } from './CodexServerRequestRouter'; +import { CodexSessionManager } from './CodexSessionManager'; + +function resolveCodexSandboxConfig( + permissionMode: string, + codexSafeMode: CodexSafeMode = 'workspace-write', +): { approvalPolicy: string; sandbox: string } { + if (permissionMode === 'yolo') { + return { approvalPolicy: 'never', sandbox: 'danger-full-access' }; + } + if (permissionMode === 'plan') { + return { approvalPolicy: 'on-request', sandbox: 'workspace-write' }; + } + // normal — resolve through the user's configured safe mode + return { approvalPolicy: 'on-request', sandbox: codexSafeMode }; +} + +function resolveCodexServiceTier( + serviceTier: unknown, + modelId: string | undefined, + settings: Record, +): string | null { + const model = findCodexModel(getCodexProviderSettings(settings).discoveredModels, modelId); + if (!model) { + return null; + } + + if (typeof serviceTier === 'string') { + if (model.serviceTiers.some(tier => tier.id === serviceTier)) { + return serviceTier; + } + if (serviceTier === 'fast') { + return model.serviceTiers.find(tier => tier.name.toLowerCase() === 'fast')?.id ?? null; + } + } + + return model.defaultServiceTier; +} + +export class CodexChatRuntime implements ChatRuntime { + readonly providerId: ProviderId = 'codex'; + + private plugin: ProviderHost; + private session = new CodexSessionManager(); + private process: CodexAppServerProcess | null = null; + private transport: CodexRpcTransport | null = null; + private launchSpec: CodexLaunchSpec | null = null; + private runtimeContext: CodexRuntimeContext | null = null; + private notificationRouter: CodexNotificationRouter | null = null; + private serverRequestRouter = new CodexServerRequestRouter(); + private ready = false; + private readinessFlight: { key: string; promise: Promise } | null = null; + private disposed = false; + private lifecycleGeneration = 0; + private readyListeners = new Set<(ready: boolean) => void>(); + private clientConfigKey: string | null = null; + private currentTurnId: string | null = null; + private currentQueryThreadId: string | null = null; + private loadedThreadId: string | null = null; + private currentThreadPath: string | null = null; + private pendingTurnNotifications: Array<{ method: string; params: unknown }> = []; + + // Chunk buffer: notifications push here, query() drains + private chunkBuffer: StreamChunk[] = []; + private chunkResolve: (() => void) | null = null; + + private approvalCallback: ApprovalCallback | null = null; + private approvalDismisser: (() => void) | null = null; + private askUserCallback: AskUserQuestionCallback | null = null; + private exitPlanModeCallback: ExitPlanModeCallback | null = null; + private permissionModeSyncCallback: ((sdkMode: string) => void) | null = null; + private subagentHookProvider: (() => SubagentRuntimeState) | null = null; + private autoTurnCallback: AutoTurnCallback | null = null; + private resumeCheckpoint: string | undefined; + private activeInputBundles = new Set(); + private currentConversationModel: string | null = null; + + // Fork state + private pendingFork: ForkSource | null = null; + + // Cancellation + private canceled = false; + private turnMetadata: ChatTurnMetadata = {}; + + constructor(plugin: ProviderHost) { + this.plugin = plugin; + } + + getCapabilities(): Readonly { + return CODEX_PROVIDER_CAPABILITIES; + } + + prepareTurn(request: ChatTurnRequest): PreparedChatTurn { + return encodeCodexTurn(request); + } + + consumeTurnMetadata(): ChatTurnMetadata { + const metadata = { ...this.turnMetadata }; + this.turnMetadata = {}; + return metadata; + } + + onReadyStateChange(listener: (ready: boolean) => void): () => void { + this.readyListeners.add(listener); + return () => { + this.readyListeners.delete(listener); + }; + } + + setResumeCheckpoint(checkpointId: string | undefined): void { + this.resumeCheckpoint = checkpointId; + } + + syncConversationState( + conversation: ChatRuntimeConversationState | null, + _externalContextPaths?: string[], + ): void { + if (!conversation) { + this.currentConversationModel = null; + this.session.reset(); + this.loadedThreadId = null; + this.currentThreadPath = null; + this.pendingFork = null; + return; + } + + this.setCurrentConversationModel(conversation.selectedModel); + const state = getCodexState(conversation.providerState); + + // Pending fork: store fork metadata, don't set the source thread as our session + if (state.forkSource && !state.threadId && !conversation.sessionId) { + this.pendingFork = state.forkSource; + this.session.reset(); + this.loadedThreadId = null; + this.currentThreadPath = null; + return; + } + + this.pendingFork = null; + const threadId = state.threadId ?? conversation.sessionId ?? null; + + if (!threadId) { + this.session.reset(); + this.loadedThreadId = null; + this.currentThreadPath = null; + return; + } + + this.session.setThread(threadId, state.sessionFilePath); + } + + async reloadMcpServers(): Promise { + // No-op: Codex handles MCP internally + } + + async ensureReady(options?: ChatRuntimeEnsureReadyOptions): Promise { + if (this.disposed) { + throw new Error('Codex runtime has been disposed.'); + } + const key = JSON.stringify(options ?? {}); + if (this.readinessFlight) { + if (this.readinessFlight.key === key) { + return this.readinessFlight.promise; + } + await this.readinessFlight.promise.catch(() => undefined); + return this.ensureReady(options); + } + + const generation = this.lifecycleGeneration; + const promise = this.ensureReadyInternal(options, generation); + this.readinessFlight = { key, promise }; + return promise.finally(() => { + if (this.readinessFlight?.promise === promise) { + this.readinessFlight = null; + } + }); + } + + private async ensureReadyInternal( + options: ChatRuntimeEnsureReadyOptions | undefined, + generation: number, + ): Promise { + this.assertLifecycleCurrent(generation); + const promptSettings = this.getSystemPromptSettings(); + const promptKey = computeSystemPromptKey(promptSettings); + const launchSpec = resolveCodexAppServerLaunchSpec(this.plugin, this.providerId); + const clientConfigKey = [promptKey, JSON.stringify({ + command: launchSpec.command, + args: launchSpec.args, + spawnCwd: launchSpec.spawnCwd, + targetCwd: launchSpec.targetCwd, + target: launchSpec.target, + })].join('::'); + const shouldRebuild = !this.process + || !this.transport + || !this.process.isAlive() + || options?.force === true + || this.clientConfigKey !== clientConfigKey; + + if (shouldRebuild) { + await this.shutdownProcess(); + this.assertLifecycleCurrent(generation); + await this.startAppServer(launchSpec, clientConfigKey); + if (!this.isLifecycleCurrent(generation)) { + await this.shutdownProcess(); + this.assertLifecycleCurrent(generation); + } + } + + this.assertLifecycleCurrent(generation); + this.setReady(true); + return shouldRebuild; + } + + async *query( + originalTurn: PreparedChatTurn, + _conversationHistory?: ChatMessage[], + queryOptions?: ChatRuntimeQueryOptions, + ): AsyncGenerator { + if (queryOptions?.model) { + this.setCurrentConversationModel(queryOptions.model); + } + this.resetTurnMetadata(); + let turn = originalTurn; + await this.ensureReady(); + + this.canceled = false; + this.cleanupActiveInputBundles(); + this.chunkBuffer = []; + this.chunkResolve = null; + this.currentQueryThreadId = null; + this.pendingTurnNotifications = []; + + const providerSettings = this.getProviderSettings(); + const model = this.resolveModel(queryOptions, providerSettings); + const promptSettings = this.getSystemPromptSettings(); + const promptText = buildSystemPrompt(promptSettings); + + const enqueueChunk = (chunk: StreamChunk): void => { + this.chunkBuffer.push(chunk); + if (this.chunkResolve) { + this.chunkResolve(); + this.chunkResolve = null; + } + }; + + // Set up notification router to push chunks + this.notificationRouter = new CodexNotificationRouter( + (chunk) => enqueueChunk(chunk), + (update) => this.recordTurnMetadata(update), + ); + + this.wireTransportHandlers(); + + const compactValidationError = this.validateCompactTurn(originalTurn); + if (compactValidationError) { + yield { type: 'error', content: compactValidationError }; + yield { type: 'done' }; + return; + } + + try { + // Thread lifecycle + const existingThreadId = this.session.getThreadId(); + let threadId: string; + let threadPath: string | null = null; + let threadTargetPath: string | null = null; + let completedPendingFork = false; + + if (this.pendingFork) { + // Pending fork: fork the source thread, optionally roll back, then start a turn + const fork = this.pendingFork; + + const forkResult = await this.transport!.request('thread/fork', { + threadId: fork.sessionId, + }); + threadId = forkResult.thread.id; + threadTargetPath = forkResult.thread.path ?? null; + threadPath = this.toHostSessionPath(threadTargetPath); + + // Compute rollback: count turns after the resumeAt checkpoint + const forkTurns = forkResult.thread.turns ?? []; + const checkpointIndex = forkTurns.findIndex(t => t.id === fork.resumeAt); + if (checkpointIndex < 0) { + throw new Error(`Fork checkpoint not found: ${fork.resumeAt}`); + } + const numTurnsToRollback = forkTurns.length - checkpointIndex - 1; + + // Resume the forked thread (required before rollback and turn/start) + const permissionMode = this.resolveSandboxConfig(); + await this.transport!.request('thread/resume', { + threadId, + ...(model ? { model } : {}), + approvalPolicy: permissionMode.approvalPolicy, + sandbox: permissionMode.sandbox, + serviceTier: resolveCodexServiceTier(providerSettings.serviceTier, model, providerSettings), + baseInstructions: promptText, + experimentalRawEvents: true, + persistExtendedHistory: true, + }); + + if (numTurnsToRollback > 0) { + await this.transport!.request('thread/rollback', { + threadId, + numTurns: numTurnsToRollback, + }); + } + + this.loadedThreadId = threadId; + completedPendingFork = true; + + // Build replay suffix from conversation history after the checkpoint + if (_conversationHistory && _conversationHistory.length > 0) { + const checkpointIdx = _conversationHistory.findIndex( + m => m.assistantMessageId === fork.resumeAt, + ); + if (checkpointIdx >= 0 && checkpointIdx < _conversationHistory.length - 1) { + const suffix = _conversationHistory.slice(checkpointIdx + 1); + const replayContext = buildContextFromHistory(suffix); + if (replayContext.trim()) { + turn = { + ...turn, + prompt: `${replayContext}\n\nUser: ${turn.prompt}`, + }; + } + } + } + } else if (existingThreadId && existingThreadId !== this.loadedThreadId) { + // Resume a persisted thread not yet loaded in this daemon + const permissionMode = this.resolveSandboxConfig(); + const resumeResult = await this.transport!.request('thread/resume', { + threadId: existingThreadId, + ...(model ? { model } : {}), + approvalPolicy: permissionMode.approvalPolicy, + sandbox: permissionMode.sandbox, + serviceTier: resolveCodexServiceTier(providerSettings.serviceTier, model, providerSettings), + baseInstructions: promptText, + experimentalRawEvents: true, + persistExtendedHistory: true, + }); + threadId = resumeResult.thread.id; + threadTargetPath = resumeResult.thread.path ?? null; + threadPath = this.toHostSessionPath(threadTargetPath); + this.loadedThreadId = threadId; + } else if (existingThreadId && existingThreadId === this.loadedThreadId) { + // Thread already loaded — just start a new turn + threadId = existingThreadId; + } else { + // New thread + const permissionMode = this.resolveSandboxConfig(); + const startResult = await this.transport!.request('thread/start', { + ...(model ? { model } : {}), + cwd: this.launchSpec?.targetCwd ?? getVaultPath(this.plugin.app) ?? undefined, + approvalPolicy: permissionMode.approvalPolicy, + sandbox: permissionMode.sandbox, + serviceTier: resolveCodexServiceTier(providerSettings.serviceTier, model, providerSettings), + baseInstructions: promptText, + experimentalRawEvents: true, + persistExtendedHistory: true, + }); + threadId = startResult.thread.id; + threadTargetPath = startResult.thread.path ?? null; + threadPath = this.toHostSessionPath(threadTargetPath); + this.loadedThreadId = threadId; + } + + // Update session with thread info + this.session.setThread(threadId, threadPath ?? this.currentThreadPath ?? undefined); + if (threadPath) this.currentThreadPath = threadPath; + this.currentQueryThreadId = threadId; + if (completedPendingFork) { + this.pendingFork = null; + } + + if (turn.isCompact) { + // --- Manual compact path: thread/compact/start --- + this.notificationRouter?.beginTurn({ isPlanTurn: false }); + + await this.transport!.request( + 'thread/compact/start', + { threadId }, + ); + this.recordTurnMetadata({ wasSent: true }); + // currentTurnId will be set by turn/started notification + } else { + // --- Normal turn path --- + const sessionFilePathHint = threadPath ?? this.session.getSessionFilePath() ?? null; + + // Build input + const skillInputs = await this.resolveSkillInputs(turn.request.text); + const turnInputBundle = this.buildInput(turn.prompt, turn.request.images, skillInputs); + this.registerActiveInputBundle(turnInputBundle); + + // Start turn + const selectedEffort = typeof providerSettings.effortLevel === 'string' + ? providerSettings.effortLevel.trim() + : ''; + const effort = selectedEffort || 'medium'; + const resolvedModel = model; + const isPlanMode = providerSettings.permissionMode === 'plan'; + const externalContextPaths = this.resolveExternalContextPaths(turn, queryOptions); + const permissionMode = this.resolveSandboxConfig(); + const transcriptRootTarget = this.runtimeContext?.sessionsDirTarget + ?? deriveCodexSessionsRootFromSessionPath(threadTargetPath) + ?? this.resolveTranscriptRootTarget(sessionFilePathHint); + const sandboxPolicy = this.buildTurnSandboxPolicy( + externalContextPaths, + permissionMode.sandbox, + transcriptRootTarget, + sessionFilePathHint, + ); + + const collaborationMode = resolvedModel ? { + mode: isPlanMode ? 'plan' as const : 'default' as const, + settings: { + model: resolvedModel, + reasoning_effort: effort, + developer_instructions: null, + }, + } : undefined; + + const summary = getEffectiveCodexReasoningSummary(providerSettings, resolvedModel); + const serviceTier = resolveCodexServiceTier(providerSettings.serviceTier, resolvedModel, providerSettings); + + // Configure router plan state before turn/start so buffered notifications + // that arrive before currentTurnId is set already see the correct state. + this.notificationRouter?.beginTurn({ isPlanTurn: isPlanMode }); + + const turnResult = await this.transport!.request('turn/start', { + threadId, + input: turnInputBundle.input, + approvalPolicy: permissionMode.approvalPolicy, + ...(resolvedModel ? { model: resolvedModel } : {}), + serviceTier, + effort, + summary, + sandboxPolicy, + collaborationMode, + }); + this.currentTurnId = turnResult.turn.id; + this.recordTurnMetadata({ + userMessageId: turnResult.turn.id, + wasSent: true, + }); + this.flushPendingTurnNotifications(); + } + + // Yield chunks until done or canceled + while (true) { + if (this.canceled) { + // Drain remaining chunks before exiting + while (this.chunkBuffer.length > 0) { + const chunk = this.chunkBuffer.shift()!; + yield chunk; + if (chunk.type === 'done') return; + } + yield { type: 'done' }; + return; + } + + if (this.chunkBuffer.length === 0) { + await new Promise((resolve) => { + this.chunkResolve = resolve; + if (this.chunkBuffer.length > 0 || this.canceled) { + resolve(); + this.chunkResolve = null; + } + }); + } + + while (this.chunkBuffer.length > 0) { + const chunk = this.chunkBuffer.shift()!; + yield chunk; + if (chunk.type === 'done') { + return; + } + } + } + } catch (err: unknown) { + if (this.canceled) { + yield { type: 'done' }; + return; + } + const message = err instanceof Error ? err.message : 'Unknown Codex error'; + yield { type: 'error', content: message }; + yield { type: 'done' }; + return; + } finally { + this.notificationRouter?.endTurn(); + + this.cleanupActiveInputBundles(); + this.currentTurnId = null; + this.currentQueryThreadId = null; + this.pendingTurnNotifications = []; + + // Session file discovery fallback + if (!this.session.getSessionFilePath()) { + const threadId = this.session.getThreadId(); + if (threadId) { + const sessionFilePath = findCodexSessionFile( + threadId, + this.resolveTranscriptRootHost(this.session.getSessionFilePath() ?? this.currentThreadPath) ?? undefined, + ); + if (sessionFilePath) { + this.session.setThread(threadId, sessionFilePath); + } + } + } + } + } + + async steer(turn: PreparedChatTurn): Promise { + if (turn.isCompact || this.canceled) { + return false; + } + + const transport = this.transport; + const threadId = this.currentQueryThreadId; + const turnId = this.currentTurnId; + if (!transport || !threadId || !turnId) { + return false; + } + + const skillInputs = await this.resolveSkillInputs(turn.request.text); + const inputBundle = this.buildInput(turn.prompt, turn.request.images, skillInputs); + this.registerActiveInputBundle(inputBundle); + + try { + const result = await transport.request('turn/steer', { + threadId, + input: inputBundle.input, + expectedTurnId: turnId, + }); + + if (result.turnId !== turnId) { + return false; + } + + return this.currentQueryThreadId === threadId + && this.currentTurnId === turnId + && !this.canceled; + } catch (error) { + this.disposeInputBundle(inputBundle); + throw error; + } + } + + cancel(): void { + this.canceled = true; + this.dismissAllPendingPrompts(); + + const threadId = this.session.getThreadId(); + const turnId = this.currentTurnId; + + if (this.transport && threadId && turnId) { + this.transport.request('turn/interrupt', { threadId, turnId }).catch(() => { + // best-effort + }); + } + + // Unblock the chunk-wait loop + if (this.chunkResolve) { + this.chunkResolve(); + this.chunkResolve = null; + } + } + + resetSession(): void { + this.teardownState(); + } + + getSessionId(): string | null { + return this.session.getThreadId(); + } + + consumeSessionInvalidation(): boolean { + return this.session.consumeInvalidation(); + } + + isReady(): boolean { + return this.ready; + } + + private resetTurnMetadata(): void { + this.turnMetadata = {}; + } + + private recordTurnMetadata(update: Partial): void { + this.turnMetadata = { + ...this.turnMetadata, + ...update, + }; + } + + async getSupportedCommands(): Promise { + return []; + } + + cleanup(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.lifecycleGeneration += 1; + this.cancel(); + this.teardownState(); + this.readyListeners.clear(); + } + + async rewind( + _userMessageId: string, + _assistantMessageId: string | undefined, + _mode?: ChatRewindMode, + ): Promise { + return { canRewind: false, error: 'Codex does not support rewind' }; + } + + setApprovalCallback(callback: ApprovalCallback | null): void { + this.approvalCallback = callback; + this.serverRequestRouter.setApprovalCallback(callback); + } + + setApprovalDismisser(dismisser: (() => void) | null): void { + this.approvalDismisser = dismisser; + } + + setAskUserQuestionCallback(callback: AskUserQuestionCallback | null): void { + this.askUserCallback = callback; + this.serverRequestRouter.setAskUserCallback(callback); + } + + setExitPlanModeCallback(callback: ExitPlanModeCallback | null): void { + this.exitPlanModeCallback = callback; + } + + setPermissionModeSyncCallback(callback: ((sdkMode: string) => void) | null): void { + this.permissionModeSyncCallback = callback; + } + + setSubagentHookProvider(getState: () => SubagentRuntimeState): void { + this.subagentHookProvider = getState; + } + + setAutoTurnCallback(callback: AutoTurnCallback | null): void { + this.autoTurnCallback = callback; + } + + buildSessionUpdates(params: { + conversation: Conversation | null; + sessionInvalidated: boolean; + }): SessionUpdateResult { + const threadId = this.session.getThreadId(); + const sessionFilePath = this.session.getSessionFilePath() ?? this.currentThreadPath; + const transcriptRootPath = this.resolveTranscriptRootHost(sessionFilePath); + + // Preserve forkSource from existing conversation state + const existingState = params.conversation + ? getCodexState(params.conversation.providerState) + : null; + + const providerState: CodexProviderState = { + ...(threadId ? { threadId } : {}), + ...(sessionFilePath ? { sessionFilePath } : {}), + ...( + transcriptRootPath || existingState?.transcriptRootPath + ? { transcriptRootPath: transcriptRootPath ?? existingState?.transcriptRootPath } + : {} + ), + ...(existingState?.forkSource ? { forkSource: existingState.forkSource } : {}), + ...( + existingState?.forkSourceSessionFilePath + ? { forkSourceSessionFilePath: existingState.forkSourceSessionFilePath } + : {} + ), + ...( + existingState?.forkSourceTranscriptRootPath + ? { forkSourceTranscriptRootPath: existingState.forkSourceTranscriptRootPath } + : {} + ), + }; + + const updates: Partial = { + sessionId: threadId, + providerState: providerState as Record, + }; + + if (params.sessionInvalidated && params.conversation) { + updates.sessionId = null; + updates.providerState = undefined; + } + + return { updates }; + } + + resolveSessionIdForFork(conversation: Conversation | null): string | null { + const threadId = this.session.getThreadId(); + if (threadId) return threadId; + + if (!conversation) return null; + const state = getCodexState(conversation.providerState); + return state.threadId ?? conversation.sessionId ?? state.forkSource?.sessionId ?? null; + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + private teardownState(): void { + this.cleanupActiveInputBundles(); + this.session.reset(); + this.launchSpec = null; + this.runtimeContext = null; + this.loadedThreadId = null; + this.currentThreadPath = null; + this.currentTurnId = null; + this.currentQueryThreadId = null; + this.pendingTurnNotifications = []; + this.pendingFork = null; + this.clientConfigKey = null; + this.shutdownProcess().catch(() => {}); + this.setReady(false); + } + + private dismissApprovalUI(): void { + if (this.approvalDismisser) { + this.approvalDismisser(); + } + } + + private dismissAllPendingPrompts(): void { + this.dismissApprovalUI(); + this.serverRequestRouter.abortPendingAskUser(); + } + + private registerActiveInputBundle(bundle: CodexInputBundle): void { + this.activeInputBundles.add(bundle); + } + + private disposeInputBundle(bundle: CodexInputBundle): void { + if (this.activeInputBundles.delete(bundle)) { + bundle.cleanup(); + return; + } + + bundle.cleanup(); + } + + private cleanupActiveInputBundles(): void { + for (const bundle of this.activeInputBundles) { + bundle.cleanup(); + } + this.activeInputBundles.clear(); + } + + private setReady(ready: boolean): void { + this.ready = ready; + for (const listener of this.readyListeners) { + listener(ready); + } + } + + private isLifecycleCurrent(generation: number): boolean { + return !this.disposed && generation === this.lifecycleGeneration; + } + + private assertLifecycleCurrent(generation: number): void { + if (!this.isLifecycleCurrent(generation)) { + throw new Error('Codex runtime has been disposed.'); + } + } + + private getSystemPromptSettings(): SystemPromptSettings { + const settings = this.plugin.settings; + return { + mediaFolder: settings.mediaFolder, + customPrompt: settings.systemPrompt, + vaultPath: getVaultPath(this.plugin.app) ?? undefined, + userName: settings.userName, + }; + } + + private getProviderSettings(): Record { + return this.currentConversationModel + ? getProviderSettingsSnapshotWithModel( + this.plugin.settings, + this.providerId, + this.currentConversationModel, + ) + : ProviderSettingsCoordinator.getProviderSettingsSnapshot( + this.plugin.settings, + this.providerId, + ); + } + + getAuxiliaryModel(): string | null { + return this.currentConversationModel ?? this.resolveModel() ?? null; + } + + private setCurrentConversationModel(model: unknown): void { + const selectedModel = typeof model === 'string' ? model.trim() : ''; + this.currentConversationModel = selectedModel || null; + } + + private resolveModel( + queryOptions?: ChatRuntimeQueryOptions, + providerSettings: Record = this.getProviderSettings(), + ): string | undefined { + const model = queryOptions?.model ?? providerSettings.model as string | undefined; + if (model) { + return toCodexRuntimeModelId(model); + } + + return getDefaultCodexModel(getCodexProviderSettings(providerSettings).discoveredModels)?.model; + } + + private resolveSandboxConfig(): { approvalPolicy: string; sandbox: string } { + const providerSettings = this.getProviderSettings(); + return resolveCodexSandboxConfig( + providerSettings.permissionMode as string, + getCodexProviderSettings(providerSettings).safeMode, + ); + } + + private async startAppServer(launchSpec: CodexLaunchSpec, clientConfigKey: string): Promise { + this.launchSpec = launchSpec; + this.process = new CodexAppServerProcess(launchSpec); + this.process.start(); + + this.transport = new CodexRpcTransport(this.process); + this.transport.start(); + + const initializeResult = await initializeCodexAppServerTransport(this.transport); + this.runtimeContext = createCodexRuntimeContext(launchSpec, initializeResult); + this.clientConfigKey = clientConfigKey; + } + + private wireTransportHandlers(): void { + if (!this.transport || !this.notificationRouter) return; + + const router = this.notificationRouter; + const methods = [ + 'item/agentMessage/delta', + 'item/started', + 'item/completed', + 'item/plan/delta', + 'item/reasoning/textDelta', + 'item/reasoning/summaryTextDelta', + 'item/reasoning/summaryPartAdded', + 'thread/tokenUsage/updated', + 'turn/plan/updated', + 'turn/completed', + 'error', + 'thread/started', + 'thread/status/changed', + 'turn/started', + 'serverRequest/resolved', + 'item/commandExecution/outputDelta', + 'item/fileChange/outputDelta', + 'item/fileChange/patchUpdated', + 'rawResponseItem/completed', + 'event_msg', + ]; + + for (const method of methods) { + this.transport.onNotification(method, (params) => { + if (method === 'serverRequest/resolved') { + this.handleServerRequestResolved(params as ServerRequestResolvedNotification); + return; + } + if (!this.routeNotification(method, params)) { + return; + } + router.handleNotification(method, params); + }); + } + + // Server requests (approvals, ask-user) + const requestMethods = [ + 'item/commandExecution/requestApproval', + 'item/fileChange/requestApproval', + 'item/permissions/requestApproval', + 'item/tool/requestUserInput', + ]; + + for (const method of requestMethods) { + this.transport.onServerRequest(method, (requestId, params) => { + return this.serverRequestRouter.handleServerRequest(requestId, method, params); + }); + } + } + + private async shutdownProcess(): Promise { + if (this.transport) { + this.transport.dispose(); + this.transport = null; + } + if (this.process) { + await this.process.shutdown(); + this.process = null; + } + this.launchSpec = null; + this.runtimeContext = null; + this.notificationRouter = null; + this.currentTurnId = null; + this.currentQueryThreadId = null; + this.pendingTurnNotifications = []; + this.loadedThreadId = null; + } + + private resolveExternalContextPaths( + turn: PreparedChatTurn, + queryOptions?: ChatRuntimeQueryOptions, + ): string[] { + const externalContextPaths = turn.request.externalContextPaths ?? queryOptions?.externalContextPaths ?? []; + return [...new Set(externalContextPaths.filter((value): value is string => typeof value === 'string' && value.trim().length > 0))]; + } + + private buildTurnSandboxPolicy( + externalContextPaths: string[], + sandboxMode: string, + transcriptRootTargetHint?: string | null, + sessionFilePathHint?: string | null, + ): SandboxPolicy | undefined { + if (sandboxMode === 'danger-full-access') { + return { type: 'dangerFullAccess' }; + } + + if (sandboxMode === 'read-only') { + return { + type: 'readOnly', + access: { type: 'fullAccess' }, + networkAccess: false, + }; + } + + if (sandboxMode !== 'workspace-write') { + return undefined; + } + + const mappedExternalContextPaths = this.mapRequiredHostPathsToTarget( + externalContextPaths, + 'external context path', + ); + const memoriesDirTarget = deriveCodexMemoriesDirFromSessionsRoot(transcriptRootTargetHint) + ?? this.resolveMemoriesDirTarget(sessionFilePathHint) + ?? ( + this.launchSpec?.target.method === 'wsl' + ? null + : path.join(os.homedir(), '.codex', 'memories') + ); + + const writableRoots = [ + this.launchSpec?.targetCwd ?? getVaultPath(this.plugin.app), + ...mappedExternalContextPaths, + memoriesDirTarget, + this.mapHostPathToTarget(os.tmpdir()), + this.launchSpec?.target.platformFamily === 'unix' ? '/tmp' : null, + this.mapHostPathToTarget(process.env.TMPDIR), + ].filter((value): value is string => typeof value === 'string' && value.trim().length > 0); + + return { + type: 'workspaceWrite', + writableRoots: [...new Set(writableRoots)], + readOnlyAccess: { type: 'fullAccess' }, + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }; + } + + private handleServerRequestResolved(params: ServerRequestResolvedNotification): void { + if (this.serverRequestRouter.hasPendingApprovalRequest(params.requestId, params.threadId)) { + this.dismissApprovalUI(); + return; + } + + this.serverRequestRouter.abortPendingAskUser(params.requestId, params.threadId); + } + + private routeNotification( + method: string, + params: unknown, + ): boolean { + // turn/started can establish the active turn ID when the query didn't + // receive one from the RPC response (e.g. thread/compact/start). + if (method === 'turn/started') { + this.handleTurnStartedNotification(params); + return false; + } + + const scope = this.extractNotificationScope(method, params); + if (!scope) { + return true; + } + + if (!this.currentQueryThreadId || scope.threadId !== this.currentQueryThreadId) { + return false; + } + + if (!this.currentTurnId) { + this.pendingTurnNotifications.push({ method, params }); + return false; + } + + if (scope.turnId !== this.currentTurnId) { + return false; + } + + return true; + } + + private handleTurnStartedNotification(params: unknown): void { + if (!params || typeof params !== 'object') return; + + const notification = params as TurnStartedNotification; + const threadId = notification.threadId; + const turnId = notification.turn?.id; + + if (!threadId || !turnId) return; + if (threadId !== this.currentQueryThreadId) return; + + // Only establish the turn ID if the current query doesn't have one yet. + // Normal turn/start responses already set it; this path covers + // thread/compact/start which returns {} without a turn. + if (!this.currentTurnId) { + this.currentTurnId = turnId; + this.flushPendingTurnNotifications(); + } + } + + private validateCompactTurn(turn: PreparedChatTurn): string | null { + if (!turn.isCompact) { + return null; + } + + if (turn.request.text.trim() !== '/compact') { + return '/compact does not accept arguments'; + } + + return null; + } + + private flushPendingTurnNotifications(): void { + if (!this.notificationRouter || !this.currentTurnId) { + this.pendingTurnNotifications = []; + return; + } + + const pending = this.pendingTurnNotifications; + this.pendingTurnNotifications = []; + + for (const notification of pending) { + const scope = this.extractNotificationScope(notification.method, notification.params); + if (!scope) { + this.notificationRouter.handleNotification(notification.method, notification.params); + continue; + } + + if ( + scope.threadId === this.currentQueryThreadId + && scope.turnId === this.currentTurnId + ) { + this.notificationRouter.handleNotification(notification.method, notification.params); + } + } + } + + private extractNotificationScope( + method: string, + params: unknown, + ): { threadId: string; turnId: string } | null { + if (!params || typeof params !== 'object') { + return null; + } + + const notification = params as Record; + const threadId = typeof notification.threadId === 'string' ? notification.threadId : null; + + if (method === 'turn/completed') { + const turn = notification.turn; + const turnId = turn && typeof turn === 'object' && typeof (turn as Record).id === 'string' + ? (turn as Record).id as string + : null; + + return threadId && turnId ? { threadId, turnId } : null; + } + + const turnId = typeof notification.turnId === 'string' + ? notification.turnId + : typeof notification.turn_id === 'string' + ? notification.turn_id + : null; + return threadId && turnId ? { threadId, turnId } : null; + } + + private async resolveSkillInputs(text: string): Promise { + const skillNames = extractExplicitCodexSkillNames(text); + if (skillNames.length === 0 || !this.transport) { + return []; + } + + try { + const cwd = this.launchSpec?.targetCwd ?? getVaultPath(this.plugin.app) ?? process.cwd(); + const result = await this.transport.request('skills/list', { + cwds: [cwd], + }); + const skills = result.data.find(entry => entry.cwd === cwd)?.skills ?? result.data[0]?.skills ?? []; + const resolvedInputs: SkillInput[] = []; + + for (const skillName of skillNames) { + const resolvedSkill = findPreferredCodexSkillByName(skills, skillName); + if (!resolvedSkill) { + continue; + } + + resolvedInputs.push({ + type: 'skill', + name: resolvedSkill.name, + path: resolvedSkill.path, + }); + } + + return resolvedInputs; + } catch { + return []; + } + } + + private buildInput(text: string, images?: ImageAttachment[], skills?: SkillInput[]): CodexInputBundle { + const input: UserInput[] = []; + let tempDir: string | null = null; + + const cleanup = (): void => { + if (!tempDir) { + return; + } + + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // best-effort cleanup + } + }; + + try { + if (images && images.length > 0) { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claudian-codex-images-')); + for (let i = 0; i < images.length; i++) { + const img = images[i]; + if (!img.mediaType.startsWith('image/')) continue; + + const filename = toAttachmentFilename(img, i); + const filePath = path.join(tempDir, `${i + 1}-${filename}`); + fs.writeFileSync(filePath, Buffer.from(img.data, 'base64')); + const targetFilePath = this.mapHostPathToTarget(filePath); + if (!targetFilePath) { + throw new Error(`Codex cannot access image attachment path from the selected target: ${filePath}`); + } + input.push({ type: 'localImage', path: targetFilePath }); + } + } + + if (text) { + input.push({ type: 'text', text, text_elements: [] }); + } + + if (skills && skills.length > 0) { + input.push(...skills); + } + + return { input, cleanup }; + } catch (error) { + cleanup(); + throw error; + } + } + + private toHostSessionPath(targetPath: string | null | undefined): string | null { + if (!targetPath) { + return null; + } + + return this.launchSpec?.pathMapper.toHostPath(targetPath) ?? targetPath; + } + + private toTargetSessionPath(sessionPath: string | null | undefined): string | null { + if (!sessionPath) { + return null; + } + + if (!this.launchSpec) { + return sessionPath; + } + + if (this.launchSpec.target.platformFamily === 'unix' && sessionPath.startsWith('/')) { + return sessionPath; + } + + if ( + this.launchSpec.target.platformFamily === 'windows' + && (/^[A-Za-z]:[\\/]/.test(sessionPath) || sessionPath.startsWith('\\\\')) + ) { + return sessionPath; + } + + return this.launchSpec.pathMapper.toTargetPath(sessionPath) ?? sessionPath; + } + + private mapHostPathToTarget(hostPath: string | null | undefined): string | null { + if (!hostPath) { + return null; + } + + return this.launchSpec?.pathMapper.toTargetPath(hostPath) ?? hostPath; + } + + private mapRequiredHostPathsToTarget(hostPaths: string[], label: string): string[] { + if (!this.launchSpec) { + return hostPaths; + } + + return hostPaths.map((hostPath) => { + const targetPath = this.launchSpec!.pathMapper.toTargetPath(hostPath); + if (!targetPath) { + throw new Error(`Codex cannot access ${label} from the selected target: ${hostPath}`); + } + return targetPath; + }); + } + + private resolveTranscriptRootHost(sessionFilePath?: string | null): string | null { + return this.runtimeContext?.sessionsDirHost + ?? deriveCodexSessionsRootFromSessionPath( + sessionFilePath ?? this.session.getSessionFilePath() ?? this.currentThreadPath, + ); + } + + private resolveTranscriptRootTarget(sessionFilePath?: string | null): string | null { + if (this.runtimeContext?.sessionsDirTarget) { + return this.runtimeContext.sessionsDirTarget; + } + + const targetSessionPath = this.toTargetSessionPath( + sessionFilePath ?? this.session.getSessionFilePath() ?? this.currentThreadPath, + ); + return deriveCodexSessionsRootFromSessionPath(targetSessionPath); + } + + private resolveMemoriesDirTarget(sessionFilePath?: string | null): string | null { + if (this.runtimeContext?.memoriesDirTarget) { + return this.runtimeContext.memoriesDirTarget; + } + + return deriveCodexMemoriesDirFromSessionsRoot( + this.resolveTranscriptRootTarget(sessionFilePath), + ); + } +} + +// --------------------------------------------------------------------------- +// Image attachment helpers +// --------------------------------------------------------------------------- + +interface ImageAttachment { + data: string; + mediaType: string; + filename?: string; +} + +interface CodexInputBundle { + input: UserInput[]; + cleanup: () => void; +} + +function toAttachmentFilename(attachment: ImageAttachment, index: number): string { + const base = (attachment.filename ?? '').trim().replace(/[^A-Za-z0-9._-]/g, '_') || `image-${index + 1}`; + if (base.includes('.')) return base; + const subtype = attachment.mediaType.split('/')[1] ?? 'img'; + const extension = subtype === 'jpeg' ? 'jpg' : subtype; + return `${base}.${extension}`; +} + +export { toAttachmentFilename as _toAttachmentFilename }; + +// --------------------------------------------------------------------------- +// Interrupt kind classification (preserved for history parsing) +// --------------------------------------------------------------------------- + +export type CodexInterruptKind = 'user_request' | 'tool_use' | 'compaction_canceled'; + +export function mapCodexAbortReasonToInterruptKind(reason: string): CodexInterruptKind | undefined { + const normalized = reason.trim().toLowerCase(); + if (!normalized) return undefined; + + if (normalized === 'interrupted' || normalized === 'cancelled' || normalized === 'canceled') { + return 'user_request'; + } + if (normalized.includes('tool')) { + return 'tool_use'; + } + if (normalized.includes('compact')) { + return 'compaction_canceled'; + } + + return undefined; +} diff --git a/src/providers/codex/runtime/CodexCliResolver.ts b/src/providers/codex/runtime/CodexCliResolver.ts new file mode 100644 index 0000000..493e1ea --- /dev/null +++ b/src/providers/codex/runtime/CodexCliResolver.ts @@ -0,0 +1,102 @@ +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import type { ProviderCliResolutionContext } from '../../../core/providers/types'; +import type { HostnameCliPaths } from '../../../core/types/settings'; +import { getHostnameKey } from '../../../utils/env'; +import type { CodexInstallationMethod } from '../settings'; +import { getCodexProviderSettings } from '../settings'; +import { resolveCodexCliPath } from './CodexBinaryLocator'; +import { resolveCodexExecutionTarget } from './CodexExecutionTargetResolver'; +import type { CodexExecutionTarget } from './codexLaunchTypes'; + +export class CodexCliResolver { + private resolvedPath: string | null = null; + private lastHostnamePath = ''; + private lastLegacyPath = ''; + private lastEnvText = ''; + private lastExecutionTargetKey = ''; + private readonly cachedHostname = getHostnameKey(); + + resolveFromSettings( + settings: Record, + context: ProviderCliResolutionContext = {}, + ): string | null { + const codexSettings = getCodexProviderSettings(settings); + const hostnamePath = (codexSettings.cliPathsByHost[this.cachedHostname] ?? '').trim(); + const legacyPath = codexSettings.cliPath.trim(); + const envText = getRuntimeEnvironmentText(settings, 'codex'); + const executionTarget = getCodexExecutionTargetFromContext(context) + ?? resolveCodexExecutionTarget({ settings }); + const executionTargetKey = getCodexExecutionTargetCacheKey(executionTarget); + + if ( + this.resolvedPath && + hostnamePath === this.lastHostnamePath && + legacyPath === this.lastLegacyPath && + envText === this.lastEnvText && + executionTargetKey === this.lastExecutionTargetKey + ) { + return this.resolvedPath; + } + + this.lastHostnamePath = hostnamePath; + this.lastLegacyPath = legacyPath; + this.lastEnvText = envText; + this.lastExecutionTargetKey = executionTargetKey; + + this.resolvedPath = resolveCodexCliPath(hostnamePath, legacyPath, envText, { + executionTarget, + }); + return this.resolvedPath; + } + + resolve( + hostnamePaths: HostnameCliPaths | undefined, + legacyPath: string | undefined, + envText: string, + options: { + installationMethod?: CodexInstallationMethod; + executionTarget?: CodexExecutionTarget; + hostPlatform?: NodeJS.Platform; + } = {}, + ): string | null { + const hostnamePath = (hostnamePaths?.[this.cachedHostname] ?? '').trim(); + const normalizedLegacyPath = (legacyPath ?? '').trim(); + return resolveCodexCliPath(hostnamePath, normalizedLegacyPath, envText, options); + } + + reset(): void { + this.resolvedPath = null; + this.lastHostnamePath = ''; + this.lastLegacyPath = ''; + this.lastEnvText = ''; + this.lastExecutionTargetKey = ''; + } +} + +function isCodexExecutionTarget(value: unknown): value is CodexExecutionTarget { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial; + return candidate.method === 'host-native' + || candidate.method === 'native-windows' + || candidate.method === 'wsl'; +} + +function getCodexExecutionTargetFromContext( + context: ProviderCliResolutionContext, +): CodexExecutionTarget | null { + return isCodexExecutionTarget(context.executionTarget) + ? context.executionTarget + : null; +} + +function getCodexExecutionTargetCacheKey(target: CodexExecutionTarget): string { + return [ + target.method, + target.platformFamily, + target.platformOs, + target.distroName ?? '', + ].join(':'); +} diff --git a/src/providers/codex/runtime/CodexExecutionTargetResolver.ts b/src/providers/codex/runtime/CodexExecutionTargetResolver.ts new file mode 100644 index 0000000..fbb4b86 --- /dev/null +++ b/src/providers/codex/runtime/CodexExecutionTargetResolver.ts @@ -0,0 +1,138 @@ +import { execFileSync } from 'child_process'; + +import { getCodexProviderSettings } from '../settings'; +import type { + CodexExecutionPlatformFamily, + CodexExecutionPlatformOs, + CodexExecutionTarget, +} from './codexLaunchTypes'; + +export interface ResolveCodexExecutionTargetOptions { + settings: Record; + hostPlatform?: NodeJS.Platform; + hostVaultPath?: string | null; + resolveDefaultWslDistro?: () => string | undefined; +} + +function resolveHostPlatformOs(hostPlatform: NodeJS.Platform): CodexExecutionPlatformOs { + if (hostPlatform === 'win32') { + return 'windows'; + } + + if (hostPlatform === 'darwin') { + return 'macos'; + } + + return 'linux'; +} + +function resolveHostPlatformFamily(hostPlatform: NodeJS.Platform): CodexExecutionPlatformFamily { + return hostPlatform === 'win32' ? 'windows' : 'unix'; +} + +export function inferWslDistroFromWindowsPath(hostPath: string | null | undefined): string | undefined { + if (!hostPath) { + return undefined; + } + + const normalized = hostPath.replace(/\//g, '\\'); + const match = normalized.match(/^\\\\wsl\$\\([^\\]+)(?:\\|$)/i); + return match?.[1] || undefined; +} + +function looksLikeUtf16Le(output: Buffer): boolean { + const sampleLength = Math.min(output.length - (output.length % 2), 512); + if (sampleLength < 4) { + return false; + } + + let evenNullBytes = 0; + let oddNullBytes = 0; + for (let index = 0; index < sampleLength; index += 2) { + if (output[index] === 0) { + evenNullBytes += 1; + } + if (output[index + 1] === 0) { + oddNullBytes += 1; + } + } + + const bytePairs = sampleLength / 2; + return oddNullBytes / bytePairs >= 0.2 && oddNullBytes > evenNullBytes * 2; +} + +function decodeWslListOutput(output: string | Buffer): string { + if (typeof output === 'string') { + return output; + } + + const hasUtf16LeBom = output.length >= 2 && output[0] === 0xFF && output[1] === 0xFE; + if (hasUtf16LeBom || looksLikeUtf16Le(output)) { + return output.toString('utf16le'); + } + + return output.toString('utf8'); +} + +export function parseDefaultWslDistroListOutput(output: string | Buffer): string | undefined { + const decodedOutput = decodeWslListOutput(output); + for (const line of decodedOutput.replace(/\uFEFF/g, '').split(/\r?\n/)) { + const trimmed = line.trimStart(); + if (!trimmed.startsWith('*')) { + continue; + } + + const candidate = trimmed.slice(1).trimStart().split(/\s{2,}/)[0]?.trim(); + if (candidate) { + return candidate; + } + } + + return undefined; +} + +function resolveDefaultWslDistroName(): string | undefined { + try { + const output = execFileSync('wsl.exe', ['--list', '--verbose'], { + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }); + return parseDefaultWslDistroListOutput(output); + } catch { + return undefined; + } +} + +export function resolveCodexExecutionTarget( + options: ResolveCodexExecutionTargetOptions, +): CodexExecutionTarget { + const hostPlatform = options.hostPlatform ?? process.platform; + if (hostPlatform !== 'win32') { + return { + method: 'host-native', + platformFamily: resolveHostPlatformFamily(hostPlatform), + platformOs: resolveHostPlatformOs(hostPlatform), + }; + } + + const codexSettings = getCodexProviderSettings(options.settings); + if (codexSettings.installationMethod === 'wsl') { + const distroName = codexSettings.wslDistroOverride + || inferWslDistroFromWindowsPath(options.hostVaultPath) + || options.resolveDefaultWslDistro?.() + || resolveDefaultWslDistroName(); + + return { + method: 'wsl', + platformFamily: 'unix', + platformOs: 'linux', + distroName, + }; + } + + return { + method: 'native-windows', + platformFamily: 'windows', + platformOs: 'windows', + }; +} diff --git a/src/providers/codex/runtime/CodexLaunchSpecBuilder.ts b/src/providers/codex/runtime/CodexLaunchSpecBuilder.ts new file mode 100644 index 0000000..6c64543 --- /dev/null +++ b/src/providers/codex/runtime/CodexLaunchSpecBuilder.ts @@ -0,0 +1,86 @@ +import { + inferWslDistroFromWindowsPath, + resolveCodexExecutionTarget, +} from './CodexExecutionTargetResolver'; +import type { CodexExecutionTarget, CodexLaunchSpec } from './codexLaunchTypes'; +import { createCodexPathMapper } from './CodexPathMapper'; + +export interface BuildCodexLaunchSpecOptions { + settings: Record; + resolvedCliCommand: string | null; + hostVaultPath: string | null; + env: Record; + executionTarget?: CodexExecutionTarget; + hostPlatform?: NodeJS.Platform; + resolveDefaultWslDistro?: () => string | undefined; +} + +const CODEX_APP_SERVER_ARGS = Object.freeze(['app-server', '--listen', 'stdio://']); + +export function buildCodexLaunchSpec( + options: BuildCodexLaunchSpecOptions, +): CodexLaunchSpec { + const target = options.executionTarget ?? resolveCodexExecutionTarget({ + settings: options.settings, + hostPlatform: options.hostPlatform, + hostVaultPath: options.hostVaultPath, + resolveDefaultWslDistro: options.resolveDefaultWslDistro, + }); + const pathMapper = createCodexPathMapper(target); + const spawnCwd = options.hostVaultPath ?? process.cwd(); + + const workspaceDistro = inferWslDistroFromWindowsPath(options.hostVaultPath); + if ( + target.method === 'wsl' + && target.distroName + && workspaceDistro + && target.distroName.toLowerCase() !== workspaceDistro.toLowerCase() + ) { + throw new Error( + `WSL distro override "${target.distroName}" does not match workspace distro "${workspaceDistro}"`, + ); + } + + if (target.method === 'wsl' && !target.distroName) { + throw new Error( + 'Unable to determine the WSL distro. Set WSL distro override or configure a default WSL distro.', + ); + } + + const targetCwd = pathMapper.toTargetPath(spawnCwd); + + if (!targetCwd) { + throw new Error('WSL mode only supports Windows drive paths and \\\\wsl$ workspace paths'); + } + + const resolvedCliCommand = options.resolvedCliCommand?.trim() || 'codex'; + if (target.method === 'wsl') { + const args = [ + ...(target.distroName ? ['--distribution', target.distroName] : []), + '--cd', + targetCwd, + resolvedCliCommand, + ...CODEX_APP_SERVER_ARGS, + ]; + + return { + target, + command: 'wsl.exe', + args, + spawnCwd, + targetCwd, + env: options.env, + pathMapper, + }; + } + + return { + target, + command: resolvedCliCommand, + args: [...CODEX_APP_SERVER_ARGS], + spawnCwd, + targetCwd, + env: options.env, + pathMapper, + }; +} diff --git a/src/providers/codex/runtime/CodexModelDiscoveryService.ts b/src/providers/codex/runtime/CodexModelDiscoveryService.ts new file mode 100644 index 0000000..eb9c262 --- /dev/null +++ b/src/providers/codex/runtime/CodexModelDiscoveryService.ts @@ -0,0 +1,74 @@ +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { + type CodexDiscoveredModel, + normalizeCodexDiscoveredModels, +} from '../models'; +import { CodexAppServerProcess } from './CodexAppServerProcess'; +import { + initializeCodexAppServerTransport, + resolveCodexAppServerLaunchSpec, +} from './codexAppServerSupport'; +import type { ModelListResult } from './codexAppServerTypes'; +import { CodexRpcTransport } from './CodexRpcTransport'; + +export interface CodexModelDiscoveryResult { + diagnostics?: string; + models: CodexDiscoveredModel[]; +} + +const MODEL_LIST_PAGE_SIZE = 100; + +export class CodexModelDiscoveryService { + constructor(private readonly plugin: ProviderHost) {} + + async discoverModels(): Promise { + let process: CodexAppServerProcess | null = null; + let transport: CodexRpcTransport | null = null; + + try { + const launchSpec = resolveCodexAppServerLaunchSpec(this.plugin, 'codex'); + process = new CodexAppServerProcess(launchSpec); + process.start(); + transport = new CodexRpcTransport(process); + transport.start(); + await initializeCodexAppServerTransport(transport); + + const entries: unknown[] = []; + const seenCursors = new Set(); + let cursor: string | null = null; + do { + const result: ModelListResult = await transport.request('model/list', { + ...(cursor ? { cursor } : {}), + includeHidden: false, + limit: MODEL_LIST_PAGE_SIZE, + }); + entries.push(...result.data); + + const nextCursor: string | null = typeof result.nextCursor === 'string' && result.nextCursor.trim() + ? result.nextCursor + : null; + if (nextCursor && seenCursors.has(nextCursor)) { + throw new Error('Codex model/list returned a repeated cursor'); + } + if (nextCursor) { + seenCursors.add(nextCursor); + } + cursor = nextCursor; + } while (cursor); + + return { models: normalizeCodexDiscoveredModels(entries) }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Codex model discovery failed'; + const stderr = process?.getStderrSnapshot() ?? ''; + return { + diagnostics: stderr ? `${message}\n\n${stderr}` : message, + models: [], + }; + } finally { + transport?.dispose(); + if (process) { + await process.shutdown().catch(() => {}); + } + } + } +} diff --git a/src/providers/codex/runtime/CodexNotificationRouter.ts b/src/providers/codex/runtime/CodexNotificationRouter.ts new file mode 100644 index 0000000..4801f42 --- /dev/null +++ b/src/providers/codex/runtime/CodexNotificationRouter.ts @@ -0,0 +1,1126 @@ +import type { ChatTurnMetadata } from '../../../core/runtime/types'; +import type { StreamChunk, UsageInfo } from '../../../core/types'; +import { extractCodexUserVisibleText, joinCodexUserTextParts } from '../codexUserText'; +import { + appendCodexCommandOutput, + extractCodexExecCellId, + isCodexToolOutputError, + normalizeCodexToolCall, + normalizeCodexToolInput, + normalizeCodexToolName, + normalizeCodexToolResult, + parseCodexArguments, + readCodexExecCellIdArgument, + stringifyCodexToolOutput, +} from '../normalization/codexToolNormalization'; +import type { + AgentMessageDeltaNotification, + AgentMessageItem, + CollabAgentToolCallItem, + CommandExecutionItem, + ContextCompactionItem, + ErrorNotification, + FileChangeItem, + FileChangePatchUpdatedNotification, + ImageViewItem, + ItemCompletedNotification, + ItemStartedNotification, + McpToolCallItem, + PlanDeltaNotification, + ReasoningSummaryTextDeltaNotification, + ReasoningTextDeltaNotification, + TokenUsageUpdatedNotification, + TurnCompletedNotification, + TurnPlanUpdatedNotification, + UserInput, + UserMessageItem, + WebSearchItem, +} from './codexAppServerTypes'; + +type ChunkEmitter = (chunk: StreamChunk) => void; +type TurnMetadataListener = (update: Partial) => void; + +interface RawToolResult { + content: string; + isError: boolean; +} + +interface WrappedWaitCall { + commandCallId: string; + cellId: string; +} + +const COLLAB_AGENT_TOOL_MAP: Record = { + spawnAgent: 'spawn_agent', + wait: 'wait', + sendInput: 'send_input', + resumeAgent: 'resume_agent', + closeAgent: 'close_agent', +}; + +export class CodexNotificationRouter { + private seenWebSearchIds = new Set(); + private planUpdateCounter = 0; + private isPlanTurn = false; + private sawPlanDelta = false; + private startedUserMessageIds = new Set(); + private startedAgentMessageIds = new Set(); + private agentMessageDeltaIds = new Set(); + private streamedAssistantTurnText = ''; + private currentAssistantSegmentId: string | undefined; + private currentAssistantSegmentText = ''; + private rawStartedCallIds = new Set(); + private rawToolNamesByCallId = new Map(); + private rawToolInputsByCallId = new Map>(); + private rawToolOutputsByCallId = new Map(); + private immediateRawOutputCallIds = new Set(); + private wrappedCommandCallIdsByCellId = new Map(); + private wrappedCommandOutputByCallId = new Map(); + private wrappedWaitCallsByCallId = new Map(); + private suppressedRawCallIds = new Set(); + private fileChangeInputsById = new Map>(); + + constructor( + private readonly emit: ChunkEmitter, + private readonly onTurnMetadata?: TurnMetadataListener, + ) {} + + private resetAssistantTextTracking(): void { + this.streamedAssistantTurnText = ''; + this.resetAssistantSegmentText(); + } + + private resetAssistantSegmentText(): void { + this.currentAssistantSegmentId = undefined; + this.currentAssistantSegmentText = ''; + } + + private beginAssistantSegment(itemId: string): void { + if (this.currentAssistantSegmentId === itemId) { + return; + } + + this.currentAssistantSegmentId = itemId; + this.currentAssistantSegmentText = ''; + } + + private claimAssistantSegment(itemId?: string): void { + if (!itemId) { + return; + } + + if (this.currentAssistantSegmentId && this.currentAssistantSegmentId !== itemId) { + this.beginAssistantSegment(itemId); + return; + } + + if (!this.currentAssistantSegmentId) { + this.currentAssistantSegmentId = itemId; + } + } + + private appendAssistantText(text: string, itemId?: string): void { + if (!text) { + return; + } + + this.claimAssistantSegment(itemId); + + this.currentAssistantSegmentText += text; + this.streamedAssistantTurnText += text; + } + + beginTurn(params: { isPlanTurn: boolean }): void { + this.isPlanTurn = params.isPlanTurn; + this.sawPlanDelta = false; + this.startedUserMessageIds.clear(); + this.startedAgentMessageIds.clear(); + this.agentMessageDeltaIds.clear(); + this.resetAssistantTextTracking(); + this.rawStartedCallIds.clear(); + this.rawToolNamesByCallId.clear(); + this.rawToolInputsByCallId.clear(); + this.rawToolOutputsByCallId.clear(); + this.immediateRawOutputCallIds.clear(); + this.wrappedCommandCallIdsByCellId.clear(); + this.wrappedCommandOutputByCallId.clear(); + this.wrappedWaitCallsByCallId.clear(); + this.suppressedRawCallIds.clear(); + this.fileChangeInputsById.clear(); + } + + endTurn(): void { + this.isPlanTurn = false; + this.sawPlanDelta = false; + this.startedUserMessageIds.clear(); + this.startedAgentMessageIds.clear(); + this.agentMessageDeltaIds.clear(); + this.resetAssistantTextTracking(); + this.rawStartedCallIds.clear(); + this.rawToolNamesByCallId.clear(); + this.rawToolInputsByCallId.clear(); + this.rawToolOutputsByCallId.clear(); + this.immediateRawOutputCallIds.clear(); + this.wrappedCommandCallIdsByCellId.clear(); + this.wrappedCommandOutputByCallId.clear(); + this.wrappedWaitCallsByCallId.clear(); + this.suppressedRawCallIds.clear(); + this.fileChangeInputsById.clear(); + } + + handleNotification(method: string, params: unknown): void { + switch (method) { + case 'item/agentMessage/delta': + this.onAgentMessageDelta(params as AgentMessageDeltaNotification); + break; + case 'item/started': + this.onItemStarted(params as ItemStartedNotification); + break; + case 'item/completed': + this.onItemCompleted(params as ItemCompletedNotification); + break; + case 'item/reasoning/summaryTextDelta': + this.onReasoningSummaryDelta(params as ReasoningSummaryTextDeltaNotification); + break; + case 'item/reasoning/textDelta': + this.onReasoningTextDelta(params as ReasoningTextDeltaNotification); + break; + case 'item/reasoning/summaryPartAdded': + break; + case 'item/plan/delta': + this.onPlanDelta(params as PlanDeltaNotification); + break; + case 'item/commandExecution/outputDelta': + case 'item/fileChange/outputDelta': + this.onOutputDelta(params as { itemId: string; delta: string }); + break; + case 'item/fileChange/patchUpdated': + this.onFileChangePatchUpdated(params as FileChangePatchUpdatedNotification); + break; + case 'rawResponseItem/completed': + this.onRawResponseItemCompleted(params); + break; + case 'event_msg': + this.onEventMsg(params); + break; + case 'thread/tokenUsage/updated': + this.onTokenUsageUpdated(params as TokenUsageUpdatedNotification); + break; + case 'turn/plan/updated': + this.onPlanUpdated(params as TurnPlanUpdatedNotification); + break; + case 'turn/completed': + this.onTurnCompleted(params as TurnCompletedNotification); + break; + case 'error': + this.onError(params as ErrorNotification); + break; + default: + break; + } + } + + private onAgentMessageDelta(params: AgentMessageDeltaNotification): void { + this.agentMessageDeltaIds.add(params.itemId); + this.appendAssistantText(params.delta, params.itemId); + this.emit({ type: 'text', content: params.delta }); + } + + private onReasoningSummaryDelta(params: ReasoningSummaryTextDeltaNotification): void { + this.emit({ type: 'thinking', content: params.delta }); + } + + private onReasoningTextDelta(params: ReasoningTextDeltaNotification): void { + this.emit({ type: 'thinking', content: params.delta }); + } + + private onPlanDelta(params: PlanDeltaNotification): void { + this.sawPlanDelta = true; + this.emit({ type: 'text', content: params.delta }); + } + + private onItemStarted(params: ItemStartedNotification): void { + const item = params.item; + const itemId = getItemId(item); + if (itemId && this.rawStartedCallIds.has(itemId)) { + return; + } + + switch (item.type) { + case 'userMessage': + this.emitUserMessageBoundary(item); + break; + + case 'agentMessage': + this.emitAgentMessageBoundary(item); + break; + + case 'reasoning': + break; + + case 'commandExecution': + this.emitToolUseFromCommand(item); + break; + + case 'fileChange': + this.emitToolUseFromFileChange(item); + break; + + case 'imageView': + this.emitToolUseFromImageView(item); + break; + + case 'webSearch': + this.emitToolUseFromWebSearch(item); + break; + + case 'collabAgentToolCall': + this.emitToolUseFromCollabAgent(item); + break; + + case 'mcpToolCall': + this.emitToolUseFromMcp(item); + break; + + default: + break; + } + } + + private onItemCompleted(params: ItemCompletedNotification): void { + const item = params.item; + const itemId = getItemId(item); + const rawResult = itemId ? this.consumeRawToolOutput(itemId) : undefined; + + switch (item.type) { + case 'userMessage': + if (!this.startedUserMessageIds.has(item.id)) { + this.emitUserMessageBoundary(item); + } + break; + + case 'agentMessage': + this.completeAgentMessage(item); + break; + + case 'commandExecution': + this.emitToolResultFromCommand(item, rawResult); + break; + + case 'fileChange': + this.emitToolUseFromFileChange(item); + this.emitToolResultFromFileChange(item); + break; + + case 'imageView': + this.emitToolResultFromImageView(item); + break; + + case 'webSearch': + this.emitToolResultFromWebSearch(item); + break; + + case 'collabAgentToolCall': + this.emitToolResultFromCollabAgent(item); + break; + + case 'mcpToolCall': + this.emitToolResultFromMcp(item); + break; + + case 'contextCompaction': + this.emitContextCompactionBoundary(item); + break; + + default: + if (itemId && rawResult) { + this.emit({ type: 'tool_result', id: itemId, ...rawResult }); + } + break; + } + } + + private onRawResponseItemCompleted(params: unknown): void { + const item = asRecord(asRecord(params)?.item); + const itemType = typeof item?.type === 'string' ? item.type : undefined; + if (!item || !itemType) { + return; + } + + switch (itemType) { + case 'function_call': + this.handleRawFunctionCall(item); + break; + + case 'custom_tool_call': + this.handleRawCustomToolCall(item); + break; + + case 'function_call_output': + case 'custom_tool_call_output': + this.handleRawToolOutput(item); + break; + + case 'agentMessage': + case 'message': + this.emitMissingRawAgentMessageText(item); + break; + + default: + break; + } + } + + private onEventMsg(params: unknown): void { + const payload = asRecord(params); + if (!payload) { + return; + } + + const payloadType = typeof payload.type === 'string' ? payload.type : undefined; + if (payloadType !== 'agent_message') { + return; + } + + const text = firstString(payload.text, payload.message); + this.emitMissingAssistantTurnText(text); + } + + private handleRawFunctionCall(item: Record): void { + const rawName = firstString(item.name, item.type); + const callId = readRawCallId(item); + if (!callId) { + return; + } + + const rawArguments = parseRawArguments(item); + if (rawName === 'wait') { + const cellId = readCodexExecCellIdArgument(rawArguments); + const commandCallId = cellId + ? this.wrappedCommandCallIdsByCellId.get(cellId) + : undefined; + if (cellId && commandCallId) { + this.wrappedWaitCallsByCallId.set(callId, { commandCallId, cellId }); + return; + } + } + + if (rawName === 'write_stdin' && isSilentWriteStdinInput(rawArguments)) { + this.suppressedRawCallIds.add(callId); + return; + } + + this.emitRawToolUse(callId, rawName, item, rawArguments); + } + + private handleRawCustomToolCall(item: Record): void { + const rawName = firstString(item.name, item.type); + const callId = readRawCallId(item); + if (!callId) { + return; + } + + if (rawName === 'apply_patch') { + const input = normalizeCodexToolInput(rawName, parseRawArguments(item)); + this.rememberFileChangeInput(callId, input); + this.suppressedRawCallIds.add(callId); + this.resetAssistantSegmentText(); + return; + } + + // Generic custom tools have no semantic item/completed notification; their raw output is terminal. + this.immediateRawOutputCallIds.add(callId); + this.emitRawToolUse(callId, rawName, item); + } + + private emitRawToolUse( + callId: string, + rawName: string, + item: Record, + rawArguments?: Record, + ): void { + const normalized = normalizeCodexToolCall( + rawName, + rawArguments ?? parseRawArguments(item), + ); + + if (this.rawStartedCallIds.has(callId)) { + this.rawToolNamesByCallId.set(callId, normalized.name); + this.rawToolInputsByCallId.set(callId, normalized.input); + return; + } + + this.rawStartedCallIds.add(callId); + this.rawToolNamesByCallId.set(callId, normalized.name); + this.rawToolInputsByCallId.set(callId, normalized.input); + + this.resetAssistantSegmentText(); + this.emit({ + type: 'tool_use', + id: callId, + name: normalized.name, + input: normalized.input, + }); + } + + private handleRawToolOutput(item: Record): void { + const callId = readRawCallId(item); + if (!callId) { + return; + } + + const wrappedWaitCall = this.wrappedWaitCallsByCallId.get(callId); + if (wrappedWaitCall) { + this.wrappedWaitCallsByCallId.delete(callId); + this.handleWrappedWaitOutput(wrappedWaitCall, item.output); + return; + } + + if (this.suppressedRawCallIds.delete(callId)) { + return; + } + + const normalizedName = this.rawToolNamesByCallId.get(callId); + if (!normalizedName) { + return; + } + + const rawOutput = item.output; + const rawOutputText = stringifyCodexToolOutput(rawOutput); + const content = normalizeRawToolOutput( + normalizedName, + rawOutput, + this.rawToolInputsByCallId.get(callId), + ); + const result = { + content, + isError: isCodexToolOutputError(rawOutputText), + }; + + if (this.immediateRawOutputCallIds.delete(callId)) { + const execCellId = normalizedName === 'Bash' + ? extractCodexExecCellId(rawOutputText) + : undefined; + if (execCellId) { + this.wrappedCommandCallIdsByCellId.set(execCellId, callId); + this.appendWrappedCommandOutput(callId, content); + return; + } + + this.emit({ type: 'tool_result', id: callId, ...result }); + return; + } + + this.rawToolOutputsByCallId.set(callId, result); + } + + private handleWrappedWaitOutput(waitCall: WrappedWaitCall, rawOutput: unknown): void { + const rawOutputText = stringifyCodexToolOutput(rawOutput); + const content = normalizeRawToolOutput( + 'Bash', + rawOutput, + this.rawToolInputsByCallId.get(waitCall.commandCallId), + ); + const nextCellId = extractCodexExecCellId(rawOutputText); + + if (nextCellId) { + this.wrappedCommandCallIdsByCellId.delete(waitCall.cellId); + this.wrappedCommandCallIdsByCellId.set(nextCellId, waitCall.commandCallId); + this.appendWrappedCommandOutput(waitCall.commandCallId, content); + return; + } + + const previousOutput = this.wrappedCommandOutputByCallId.get(waitCall.commandCallId); + const completeOutput = appendCodexCommandOutput(previousOutput, content); + this.wrappedCommandOutputByCallId.delete(waitCall.commandCallId); + this.wrappedCommandCallIdsByCellId.delete(waitCall.cellId); + this.emit({ + type: 'tool_result', + id: waitCall.commandCallId, + content: completeOutput, + isError: isCodexToolOutputError(rawOutputText), + }); + } + + private appendWrappedCommandOutput(callId: string, content: string): void { + if (!content) return; + + const previousOutput = this.wrappedCommandOutputByCallId.get(callId); + const completeOutput = appendCodexCommandOutput(previousOutput, content); + const delta = completeOutput.slice(previousOutput?.length ?? 0); + this.wrappedCommandOutputByCallId.set(callId, completeOutput); + if (delta) { + this.emit({ type: 'tool_output', id: callId, content: delta }); + } + } + + private emitMissingRawAgentMessageText(item: Record): void { + const text = item.type === 'message' + ? readAssistantMessageText(item) + : firstString(item.text, item.message); + this.emitMissingAssistantSegmentText(text); + } + + private emitMissingAssistantSegmentText(text: string, itemId?: string): void { + this.claimAssistantSegment(itemId); + const missingText = normalizeAgentMessageCompletionText( + text, + this.currentAssistantSegmentText, + ); + if (text) { + this.currentAssistantSegmentText = text; + } + if (!missingText) { + return; + } + + this.streamedAssistantTurnText += missingText; + this.emit({ type: 'text', content: missingText }); + } + + private emitMissingAssistantTurnText(text: string): void { + const missingText = normalizeAgentMessageCompletionText( + text, + this.streamedAssistantTurnText, + ); + if (!missingText) { + return; + } + + this.streamedAssistantTurnText += missingText; + this.currentAssistantSegmentText += missingText; + this.emit({ type: 'text', content: missingText }); + } + + private consumeRawToolOutput(callId: string): RawToolResult | undefined { + const result = this.rawToolOutputsByCallId.get(callId); + this.rawToolOutputsByCallId.delete(callId); + return result; + } + + private flushPendingRawToolOutputs(): void { + for (const [callId, result] of this.rawToolOutputsByCallId) { + this.emit({ type: 'tool_result', id: callId, ...result }); + } + this.rawToolOutputsByCallId.clear(); + } + + // -- commandExecution ------------------------------------------------------- + + private emitToolUseFromCommand(item: CommandExecutionItem): void { + const rawAction = item.commandActions?.[0]?.command ?? item.command; + const normalizedName = normalizeCodexToolName('command_execution'); + const input = normalizeCodexToolInput('command_execution', { command: rawAction }); + + this.resetAssistantSegmentText(); + this.emit({ type: 'tool_use', id: item.id, name: normalizedName, input }); + } + + private emitToolResultFromCommand(item: CommandExecutionItem, rawResult?: RawToolResult): void { + const normalizedName = normalizeCodexToolName('command_execution'); + const output = item.aggregatedOutput ?? ''; + const content = rawResult?.content ?? normalizeCodexToolResult(normalizedName, output); + const isError = item.exitCode !== null + ? item.exitCode !== 0 + : rawResult?.isError ?? isCodexToolOutputError(output); + + this.emit({ type: 'tool_result', id: item.id, content, isError }); + } + + // -- fileChange ------------------------------------------------------------- + + private emitToolUseFromFileChange(item: FileChangeItem): void { + const input = this.rememberFileChangeInput( + item.id, + buildFileChangeInput(item.changes ?? []), + ); + + this.resetAssistantSegmentText(); + this.emit({ + type: 'tool_use', + id: item.id, + name: normalizeCodexToolName('file_change'), + input, + }); + } + + private emitToolResultFromFileChange(item: FileChangeItem): void { + const input = this.rememberFileChangeInput( + item.id, + buildFileChangeInput(item.changes ?? []), + ); + const changes = Array.isArray(input.changes) ? input.changes : []; + const paths = changes + .map(change => formatFileChangeSummary(change)) + .filter(Boolean) + .join(', '); + this.emit({ + type: 'tool_result', + id: item.id, + content: paths || 'File change completed', + isError: item.status === 'failed' || item.status === 'declined', + }); + } + + private onFileChangePatchUpdated(params: FileChangePatchUpdatedNotification): void { + const itemId = firstString(params.itemId); + if (!itemId) { + return; + } + + const input = this.rememberFileChangeInput( + itemId, + buildFileChangeInput(params.changes ?? []), + ); + + this.resetAssistantSegmentText(); + this.emit({ + type: 'tool_use', + id: itemId, + name: normalizeCodexToolName('file_change'), + input, + }); + } + + private rememberFileChangeInput( + itemId: string, + input: Record, + ): Record { + const previous = this.fileChangeInputsById.get(itemId); + const merged = mergeApplyPatchInputs(previous, input); + this.fileChangeInputsById.set(itemId, merged); + return merged; + } + + // -- imageView -------------------------------------------------------------- + + private emitToolUseFromImageView(item: ImageViewItem): void { + this.resetAssistantSegmentText(); + this.emit({ + type: 'tool_use', + id: item.id, + name: normalizeCodexToolName('view_image'), + input: normalizeCodexToolInput('view_image', { path: item.path }), + }); + } + + private emitToolResultFromImageView(item: ImageViewItem): void { + this.emit({ type: 'tool_result', id: item.id, content: item.path, isError: false }); + } + + // -- webSearch -------------------------------------------------------------- + + private emitToolUseFromWebSearch(item: WebSearchItem): void { + if (this.seenWebSearchIds.has(item.id)) return; + this.seenWebSearchIds.add(item.id); + + this.resetAssistantSegmentText(); + this.emit({ + type: 'tool_use', + id: item.id, + name: 'WebSearch', + input: normalizeCodexToolInput('web_search', { + query: item.query ?? '', + queries: item.queries ?? [], + url: item.url ?? '', + pattern: item.pattern ?? '', + action: item.action ?? {}, + }), + }); + } + + private emitToolResultFromWebSearch(item: WebSearchItem): void { + this.emit({ + type: 'tool_result', + id: item.id, + content: 'Search complete', + isError: item.status === 'failed' || item.status === 'error', + }); + } + + // -- collabAgentToolCall ---------------------------------------------------- + + private emitToolUseFromCollabAgent(item: CollabAgentToolCallItem): void { + const toolName = COLLAB_AGENT_TOOL_MAP[item.tool] ?? item.tool; + this.resetAssistantSegmentText(); + this.emit({ + type: 'tool_use', + id: item.id, + name: toolName, + input: item.arguments ?? {}, + }); + } + + private emitToolResultFromCollabAgent(item: CollabAgentToolCallItem): void { + const resultText = item.result && typeof item.result === 'object' + ? JSON.stringify(item.result) + : item.status === 'completed' ? 'Completed' : item.status ?? 'Done'; + + this.emit({ + type: 'tool_result', + id: item.id, + content: resultText, + isError: item.status === 'failed' || item.status === 'error', + }); + } + + // -- mcpToolCall ------------------------------------------------------------ + + private emitToolUseFromMcp(item: McpToolCallItem): void { + this.resetAssistantSegmentText(); + this.emit({ + type: 'tool_use', + id: item.id, + name: `mcp__${item.server}__${item.tool}`, + input: item.arguments ?? {}, + }); + } + + private emitToolResultFromMcp(item: McpToolCallItem): void { + let content = ''; + if (item.error) { + content = item.error; + } else if (item.result?.content) { + content = item.result.content + .map(c => c.text ?? '') + .filter(Boolean) + .join('\n'); + } + if (!content) { + content = item.status === 'completed' ? 'Completed' : 'Failed'; + } + + this.emit({ + type: 'tool_result', + id: item.id, + content, + isError: item.status === 'failed' || item.status === 'error', + }); + } + + private emitContextCompactionBoundary(_item: ContextCompactionItem): void { + this.emit({ type: 'context_compacted' }); + } + + private emitUserMessageBoundary(item: UserMessageItem): void { + if (this.startedUserMessageIds.has(item.id)) { + return; + } + + const rawContent = this.extractUserMessageText(item.content); + const visibleContent = extractCodexUserVisibleText(rawContent); + this.startedUserMessageIds.add(item.id); + + if (visibleContent === null && rawContent.trim()) { + return; + } + + this.emit({ + type: 'user_message_start', + itemId: item.id, + content: visibleContent ?? rawContent, + }); + } + + private emitAgentMessageBoundary(item: AgentMessageItem): void { + if (this.startedAgentMessageIds.has(item.id)) { + return; + } + + this.startedAgentMessageIds.add(item.id); + this.claimAssistantSegment(item.id); + this.emit({ type: 'assistant_message_start', itemId: item.id }); + } + + private completeAgentMessage(item: AgentMessageItem): void { + if (!this.startedAgentMessageIds.has(item.id)) { + this.emitAgentMessageBoundary(item); + } + + if (this.agentMessageDeltaIds.has(item.id) || !item.text) { + return; + } + + this.emitMissingAssistantSegmentText(item.text, item.id); + } + + private extractUserMessageText(content: UserInput[]): string { + return joinCodexUserTextParts( + content.map((part) => (part.type === 'text' ? part.text : '')), + '\n\n', + ); + } + + // -- turn/plan/updated (update_plan) ---------------------------------------- + + private onPlanUpdated(params: TurnPlanUpdatedNotification): void { + this.planUpdateCounter += 1; + const syntheticId = `plan-update-${params.turnId ?? this.planUpdateCounter}`; + const PLAN_STATUS_MAP: Record = { + inProgress: 'in_progress', + in_progress: 'in_progress', + }; + + const todos = params.plan.map(item => ({ + id: '', + content: item.step, + activeForm: item.step, + status: PLAN_STATUS_MAP[item.status] ?? item.status, + })); + + this.resetAssistantSegmentText(); + this.emit({ type: 'tool_use', id: syntheticId, name: 'TodoWrite', input: { todos } }); + this.emit({ type: 'tool_result', id: syntheticId, content: 'Plan updated', isError: false }); + } + + // -- outputDelta (commandExecution + fileChange) ---------------------------- + + private onOutputDelta(params: { itemId: string; delta: string }): void { + this.emit({ type: 'tool_output', id: params.itemId, content: params.delta }); + } + + // -- tokenUsage / turnCompleted / error ------------------------------------- + + private onTokenUsageUpdated(params: TokenUsageUpdatedNotification): void { + const last = params.tokenUsage.last; + const contextTokens = last.inputTokens; + const contextWindow = params.tokenUsage.modelContextWindow; + + const usage: UsageInfo = { + inputTokens: last.inputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: last.cachedInputTokens, + contextWindow, + contextWindowIsAuthoritative: contextWindow > 0, + contextTokens, + percentage: contextWindow > 0 ? Math.min(100, Math.max(0, Math.round((contextTokens / contextWindow) * 100))) : 0, + }; + + this.emit({ type: 'usage', usage, sessionId: params.threadId }); + } + + private onTurnCompleted(params: TurnCompletedNotification): void { + const turn = params.turn; + + if (turn.status === 'failed' && turn.error) { + this.emit({ type: 'error', content: turn.error.message }); + } + + if (turn.status === 'completed') { + this.onTurnMetadata?.({ + assistantMessageId: turn.id, + ...(this.isPlanTurn && this.sawPlanDelta ? { planCompleted: true } : {}), + }); + } + + this.flushPendingRawToolOutputs(); + this.emit({ type: 'done' }); + } + + private onError(params: ErrorNotification): void { + if (params.willRetry) return; + this.emit({ type: 'error', content: params.error.message }); + } +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function firstString(...values: unknown[]): string { + for (const value of values) { + if (typeof value === 'string') { + return value; + } + } + return ''; +} + +function getItemId(item: { id?: string } | Record): string | undefined { + return typeof item.id === 'string' ? item.id : undefined; +} + +function readRawCallId(item: Record): string { + return firstString(item.call_id, item.id); +} + +function parseRawArguments(item: Record): Record { + const rawArgs = typeof item.arguments === 'string' + ? item.arguments + : typeof item.input === 'string' + ? item.input + : undefined; + return parseCodexArguments(rawArgs); +} + +function isSilentWriteStdinInput(input: Record): boolean { + return typeof input.chars !== 'string' || input.chars.length === 0; +} + +function normalizeRawToolOutput( + normalizedName: string, + rawOutput: unknown, + input?: Record, +): string { + if (Array.isArray(rawOutput) && normalizedName === 'Read') { + const filePath = firstString(input?.file_path, input?.path); + if (filePath) { + return filePath; + } + } + + return normalizeCodexToolResult(normalizedName, stringifyCodexToolOutput(rawOutput)); +} + +function buildFileChangeInput(changes: unknown): Record { + return { changes: normalizeFileChanges(changes) }; +} + +function mergeApplyPatchInputs( + previous: Record | undefined, + next: Record, +): Record { + if (!previous) { + return next; + } + + const patch = typeof next.patch === 'string' + ? next.patch + : typeof previous.patch === 'string' + ? previous.patch + : undefined; + const changes = mergeFileChanges(previous.changes, next.changes); + return { + ...previous, + ...next, + ...(patch ? { patch } : {}), + ...(changes.length > 0 ? { changes } : {}), + }; +} + +function normalizeFileChanges(changes: unknown): Record[] { + if (!Array.isArray(changes)) { + return []; + } + + return changes + .map(normalizeFileChange) + .filter((change): change is Record => change !== null); +} + +function normalizeFileChange(change: unknown): Record | null { + const record = asRecord(change); + const path = firstString(record?.path); + if (!record || !path) { + return null; + } + + const kindInfo = normalizeFileChangeKind(record.kind ?? record.type); + const diff = firstString(record.diff); + return { + ...record, + path, + kind: kindInfo.kind, + type: kindInfo.kind, + ...(kindInfo.movePath ? { movePath: kindInfo.movePath } : {}), + ...(diff ? { diff } : {}), + }; +} + +function normalizeFileChangeKind(value: unknown): { kind: string; movePath?: string } { + if (typeof value === 'string' && value) { + return { kind: value }; + } + + const record = asRecord(value); + const kind = firstString(record?.type) || 'change'; + const movePath = firstString(record?.move_path); + return { + kind, + ...(movePath ? { movePath } : {}), + }; +} + +function mergeFileChanges(previous: unknown, next: unknown): Record[] { + const previousChanges = normalizeFileChanges(previous); + const nextChanges = normalizeFileChanges(next); + if (previousChanges.length === 0) return nextChanges; + if (nextChanges.length === 0) return previousChanges; + + const merged = new Map>(); + for (const change of previousChanges) { + merged.set(fileChangeKey(change), change); + } + for (const change of nextChanges) { + const key = fileChangeKey(change); + const previousChange = merged.get(key); + merged.set(key, previousChange ? mergeFileChange(previousChange, change) : change); + } + return [...merged.values()]; +} + +function mergeFileChange( + previous: Record, + next: Record, +): Record { + return { + ...previous, + ...next, + ...(typeof next.diff === 'string' + ? { diff: next.diff } + : typeof previous.diff === 'string' + ? { diff: previous.diff } + : {}), + }; +} + +function fileChangeKey(change: Record): string { + return `${firstString(change.path)}\0${firstString(change.movePath)}`; +} + +function formatFileChangeSummary(change: unknown): string { + const record = asRecord(change); + const path = firstString(record?.path); + if (!record || !path) { + return ''; + } + + const kind = firstString(record.kind, record.type) || 'change'; + return `${kind}: ${path}`; +} + +function readContentText(value: unknown): string { + if (!Array.isArray(value)) { + return ''; + } + + return value + .map((entry) => firstString(asRecord(entry)?.text)) + .join(''); +} + +function readAssistantMessageText(item: Record): string { + if (firstString(item.role) !== 'assistant') { + return ''; + } + + return readContentText(item.content); +} + +function normalizeAgentMessageCompletionText( + text: string, + streamedAssistantText: string, +): string { + if (!text) { + return ''; + } + if (!streamedAssistantText) { + return text; + } + if (text.startsWith(streamedAssistantText)) { + return text.slice(streamedAssistantText.length); + } + return text; +} diff --git a/src/providers/codex/runtime/CodexPathMapper.ts b/src/providers/codex/runtime/CodexPathMapper.ts new file mode 100644 index 0000000..d96b281 --- /dev/null +++ b/src/providers/codex/runtime/CodexPathMapper.ts @@ -0,0 +1,155 @@ +import * as path from 'path'; + +import type { CodexExecutionTarget, CodexPathMapper } from './codexLaunchTypes'; + +function normalizeWindowsPath(value: string): string { + if (!value) { + return ''; + } + + let normalized = value.replace(/\//g, '\\'); + if (normalized.startsWith('\\\\?\\UNC\\')) { + normalized = `\\\\${normalized.slice('\\\\?\\UNC\\'.length)}`; + } else if (normalized.startsWith('\\\\?\\')) { + normalized = normalized.slice('\\\\?\\'.length); + } + + return path.win32.normalize(normalized); +} + +function normalizePosixPath(value: string): string { + if (!value) { + return ''; + } + + const normalized = path.posix.normalize(value.replace(/\\/g, '/')); + return normalized === '/' ? normalized : normalized.replace(/\/+$/, ''); +} + +function maybeMapWindowsDriveToWsl(hostPath: string): string | null { + const normalized = normalizeWindowsPath(hostPath); + const match = normalized.match(/^([A-Za-z]):(?:\\(.*))?$/); + if (!match) { + return null; + } + + const drive = match[1].toLowerCase(); + const tail = (match[2] ?? '').replace(/\\/g, '/'); + return tail ? `/mnt/${drive}/${tail}` : `/mnt/${drive}`; +} + +function maybeMapWslUncToLinux(hostPath: string, distroName?: string): string | null { + const normalized = normalizeWindowsPath(hostPath); + const match = normalized.match(/^\\\\wsl\$\\([^\\]+)(?:\\(.*))?$/i); + if (!match) { + return null; + } + + const uncDistro = match[1]; + if (distroName && uncDistro.toLowerCase() !== distroName.toLowerCase()) { + return null; + } + + const tail = match[2] ? match[2].replace(/\\/g, '/') : ''; + return tail ? `/${tail}` : '/'; +} + +function maybeMapLinuxToWindowsDrive(targetPath: string): string | null { + const normalized = normalizePosixPath(targetPath); + const match = normalized.match(/^\/mnt\/([a-zA-Z])(?:\/(.*))?$/); + if (!match) { + return null; + } + + const drive = match[1].toUpperCase(); + const tail = match[2] ? match[2].replace(/\//g, '\\') : ''; + return tail ? `${drive}:\\${tail}` : `${drive}:\\`; +} + +function maybeMapLinuxToWslUnc(targetPath: string, distroName?: string): string | null { + if (!distroName) { + return null; + } + + const normalized = normalizePosixPath(targetPath); + if (!normalized.startsWith('/')) { + return null; + } + + const tail = normalized === '/' ? '' : normalized.slice(1).replace(/\//g, '\\'); + return tail ? `\\\\wsl$\\${distroName}\\${tail}` : `\\\\wsl$\\${distroName}`; +} + +function createIdentityMapper(target: CodexExecutionTarget): CodexPathMapper { + const toTargetPath = (hostPath: string): string | null => { + if (!hostPath) { + return null; + } + + return target.platformFamily === 'windows' + ? normalizeWindowsPath(hostPath) + : normalizePosixPath(hostPath); + }; + const toHostPath = (targetPath: string): string | null => { + if (!targetPath) { + return null; + } + + return target.platformFamily === 'windows' + ? normalizeWindowsPath(targetPath) + : normalizePosixPath(targetPath); + }; + + return { + target, + toTargetPath, + toHostPath, + mapTargetPathList(hostPaths: string[]): string[] { + return hostPaths + .map(toTargetPath) + .filter((value): value is string => typeof value === 'string' && value.length > 0); + }, + canRepresentHostPath(hostPath: string): boolean { + return toTargetPath(hostPath) !== null; + }, + }; +} + +function createWslPathMapper(target: CodexExecutionTarget): CodexPathMapper { + const toTargetPath = (hostPath: string): string | null => { + if (!hostPath) { + return null; + } + + return maybeMapWslUncToLinux(hostPath, target.distroName) + ?? maybeMapWindowsDriveToWsl(hostPath); + }; + const toHostPath = (targetPath: string): string | null => { + if (!targetPath) { + return null; + } + + return maybeMapLinuxToWindowsDrive(targetPath) + ?? maybeMapLinuxToWslUnc(targetPath, target.distroName); + }; + + return { + target, + toTargetPath, + toHostPath, + mapTargetPathList(hostPaths: string[]): string[] { + return hostPaths + .map(toTargetPath) + .filter((value): value is string => typeof value === 'string' && value.length > 0); + }, + canRepresentHostPath(hostPath: string): boolean { + return toTargetPath(hostPath) !== null; + }, + }; +} + +export function createCodexPathMapper(target: CodexExecutionTarget): CodexPathMapper { + return target.method === 'wsl' + ? createWslPathMapper(target) + : createIdentityMapper(target); +} diff --git a/src/providers/codex/runtime/CodexRpcTransport.ts b/src/providers/codex/runtime/CodexRpcTransport.ts new file mode 100644 index 0000000..48f5f0b --- /dev/null +++ b/src/providers/codex/runtime/CodexRpcTransport.ts @@ -0,0 +1,188 @@ +import { createInterface } from 'readline'; + +import type { CodexAppServerProcess } from './CodexAppServerProcess'; +import type { JsonRpcError } from './codexAppServerTypes'; + +const DEFAULT_TIMEOUT_MS = 30_000; + +interface PendingRequest { + resolve: (result: unknown) => void; + reject: (error: Error) => void; + timer: number | null; +} + +type NotificationHandler = (params: unknown) => void; +type ServerRequestHandler = (requestId: string | number, params: unknown) => Promise; + +export class CodexRpcTransport { + private nextId = 1; + private pending = new Map(); + private notificationHandlers = new Map(); + private serverRequestHandlers = new Map(); + private disposed = false; + + constructor(private readonly proc: CodexAppServerProcess) {} + + start(): void { + const rl = createInterface({ input: this.proc.stdout }); + rl.on('line', (line) => this.handleLine(line)); + + this.proc.onExit(() => { + this.rejectAllPending(new Error(this.buildProcessExitMessage())); + }); + } + + request(method: string, params: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + if (this.disposed) { + return Promise.reject(new Error('Transport disposed')); + } + const id = this.nextId++; + const msg = { jsonrpc: '2.0' as const, id, method, params }; + + return new Promise((resolve, reject) => { + const timer = timeoutMs > 0 + ? window.setTimeout(() => { + this.pending.delete(id); + reject(new Error(`Request timeout: ${method} (${timeoutMs}ms)`)); + }, timeoutMs) + : null; + + const resolvePending = (result: unknown): void => { + resolve(result as T); + }; + + this.pending.set(id, { + resolve: resolvePending, + reject, + timer, + }); + + this.sendRaw(msg); + }); + } + + notify(method: string, params?: unknown): void { + const msg: Record = { jsonrpc: '2.0', method }; + if (params !== undefined) msg.params = params; + this.sendRaw(msg); + } + + onNotification(method: string, handler: NotificationHandler): void { + this.notificationHandlers.set(method, handler); + } + + onServerRequest(method: string, handler: ServerRequestHandler): void { + this.serverRequestHandlers.set(method, handler); + } + + dispose(): void { + this.disposed = true; + this.rejectAllPending(new Error('Transport disposed')); + } + + // ----------------------------------------------------------------------- + // Private + // ----------------------------------------------------------------------- + + private sendRaw(msg: unknown): void { + if (this.disposed) return; + this.proc.stdin.write(JSON.stringify(msg) + '\n'); + } + + private handleLine(line: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(line) as unknown; + } catch { + return; // malformed line + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return; + } + const msg = parsed as Record; + + const id = msg.id as string | number | undefined; + const method = msg.method as string | undefined; + + // Server response to our request + if (typeof id === 'number' && !method) { + this.handleResponse(id, msg); + return; + } + + // Server notification (no id, has method) + if (method && id === undefined) { + this.handleNotification(method, msg.params); + return; + } + + // Server-initiated request (has both id and method) + if (method && id !== undefined) { + this.handleServerRequest(id, method, msg.params); + return; + } + } + + private handleResponse(id: number, msg: Record): void { + const pending = this.pending.get(id); + if (!pending) return; + + this.pending.delete(id); + if (pending.timer) window.clearTimeout(pending.timer); + + if (msg.error) { + const err = msg.error as JsonRpcError; + pending.reject(new Error(err.message)); + } else { + pending.resolve(msg.result); + } + } + + private handleNotification(method: string, params: unknown): void { + const handler = this.notificationHandlers.get(method); + if (!handler) return; + try { + handler(params); + } catch { + // Notification failures are non-fatal to the transport. + } + } + + private handleServerRequest(id: string | number, method: string, params: unknown): void { + const handler = this.serverRequestHandlers.get(method); + if (!handler) { + this.sendRaw({ + jsonrpc: '2.0', + id, + error: { code: -32601, message: `Unhandled server request: ${method}` }, + }); + return; + } + + Promise.resolve().then(() => handler(id, params)).then( + (result) => { + this.sendRaw({ jsonrpc: '2.0', id, result }); + }, + (err) => { + this.sendRaw({ + jsonrpc: '2.0', + id, + error: { code: -32603, message: err instanceof Error ? err.message : 'Internal error' }, + }); + }, + ); + } + + private rejectAllPending(error: Error): void { + for (const [, pending] of this.pending) { + if (pending.timer) window.clearTimeout(pending.timer); + pending.reject(error); + } + this.pending.clear(); + } + + private buildProcessExitMessage(): string { + const stderr = this.proc.getStderrSnapshot(); + return stderr ? `App-server process exited\n\n${stderr}` : 'App-server process exited'; + } +} diff --git a/src/providers/codex/runtime/CodexRuntimeContext.ts b/src/providers/codex/runtime/CodexRuntimeContext.ts new file mode 100644 index 0000000..a5c28e9 --- /dev/null +++ b/src/providers/codex/runtime/CodexRuntimeContext.ts @@ -0,0 +1,109 @@ +import * as path from 'path'; + +import type { InitializeResult } from './codexAppServerTypes'; +import type { CodexLaunchSpec } from './codexLaunchTypes'; + +export interface CodexRuntimeContext { + launchSpec: CodexLaunchSpec; + initializeResult: InitializeResult; + codexHomeTarget: string | null; + codexHomeHost: string | null; + sessionsDirTarget: string | null; + sessionsDirHost: string | null; + memoriesDirTarget: string | null; +} + +function normalizeTargetPath(launchSpec: CodexLaunchSpec, value: string): string { + return launchSpec.target.platformFamily === 'windows' + ? path.win32.normalize(value) + : path.posix.normalize(value.replace(/\\/g, '/')); +} + +function joinTargetPath(launchSpec: CodexLaunchSpec, ...parts: string[]): string { + return launchSpec.target.platformFamily === 'windows' + ? path.win32.join(...parts) + : path.posix.join(...parts.map(part => part.replace(/\\/g, '/'))); +} + +function normalizeOptionalTargetPath( + launchSpec: CodexLaunchSpec, + value: string | null | undefined, +): string | null { + const trimmed = typeof value === 'string' ? value.trim() : ''; + return trimmed ? normalizeTargetPath(launchSpec, trimmed) : null; +} + +function resolveFallbackCodexHomeTarget(launchSpec: CodexLaunchSpec): string | null { + const rawCodexHome = typeof launchSpec.env.CODEX_HOME === 'string' + ? launchSpec.env.CODEX_HOME.trim() + : ''; + const envCodexHome = launchSpec.target.method === 'wsl' + ? normalizeOptionalTargetPath( + launchSpec, + rawCodexHome.startsWith('/') ? rawCodexHome : launchSpec.pathMapper.toTargetPath(rawCodexHome), + ) + : normalizeOptionalTargetPath(launchSpec, rawCodexHome); + if (envCodexHome) { + return envCodexHome; + } + + if (launchSpec.target.method === 'wsl') { + return null; + } + + const homeVar = launchSpec.target.platformFamily === 'windows' + ? launchSpec.env.USERPROFILE + : launchSpec.env.HOME; + const homeDir = normalizeOptionalTargetPath(launchSpec, homeVar); + return homeDir ? joinTargetPath(launchSpec, homeDir, '.codex') : null; +} + +function resolveCodexHomeTarget( + launchSpec: CodexLaunchSpec, + initializeResult: InitializeResult, +): string | null { + return normalizeOptionalTargetPath(launchSpec, initializeResult.codexHome) + ?? resolveFallbackCodexHomeTarget(launchSpec); +} + +function validateInitializeTarget( + launchSpec: CodexLaunchSpec, + initializeResult: InitializeResult, +): void { + if (initializeResult.platformOs !== launchSpec.target.platformOs) { + throw new Error( + `Codex target mismatch: expected ${launchSpec.target.platformOs}, received ${initializeResult.platformOs}`, + ); + } + + if (initializeResult.platformFamily !== launchSpec.target.platformFamily) { + throw new Error( + `Codex target mismatch: expected ${launchSpec.target.platformFamily}, received ${initializeResult.platformFamily}`, + ); + } +} + +export function createCodexRuntimeContext( + launchSpec: CodexLaunchSpec, + initializeResult: InitializeResult, +): CodexRuntimeContext { + validateInitializeTarget(launchSpec, initializeResult); + + const codexHomeTarget = resolveCodexHomeTarget(launchSpec, initializeResult); + const sessionsDirTarget = codexHomeTarget + ? joinTargetPath(launchSpec, codexHomeTarget, 'sessions') + : null; + const memoriesDirTarget = codexHomeTarget + ? joinTargetPath(launchSpec, codexHomeTarget, 'memories') + : null; + + return { + launchSpec, + initializeResult, + codexHomeTarget, + codexHomeHost: codexHomeTarget ? launchSpec.pathMapper.toHostPath(codexHomeTarget) : null, + sessionsDirTarget, + sessionsDirHost: sessionsDirTarget ? launchSpec.pathMapper.toHostPath(sessionsDirTarget) : null, + memoriesDirTarget, + }; +} diff --git a/src/providers/codex/runtime/CodexServerRequestRouter.ts b/src/providers/codex/runtime/CodexServerRequestRouter.ts new file mode 100644 index 0000000..567d485 --- /dev/null +++ b/src/providers/codex/runtime/CodexServerRequestRouter.ts @@ -0,0 +1,331 @@ +import type { + ApprovalCallback, + ApprovalDecisionOption, + AskUserQuestionCallback, +} from '../../../core/runtime/types'; +import type { ApprovalDecision } from '../../../core/types'; +import { normalizeCodexToolName } from '../normalization/codexToolNormalization'; +import type { + CommandApprovalRequest, + CommandExecutionApprovalDecision, + CommandExecutionApprovalResponse, + FileChangeApprovalDecision, + FileChangeApprovalRequest, + FileChangeApprovalResponse, + PermissionsApprovalRequest, + PermissionsApprovalResponse, + RequestId, + UserInputRequest, + UserInputResponse, +} from './codexAppServerTypes'; + +export class CodexServerRequestRouter { + private approvalCallback: ApprovalCallback | null = null; + private askUserCallback: AskUserQuestionCallback | null = null; + private pendingApprovalRequests = new Map(); + private askUserAbortController: AbortController | null = null; + private pendingAskUserRequestId: RequestId | null = null; + private pendingAskUserThreadId: string | null = null; + + setApprovalCallback(callback: ApprovalCallback | null): void { + this.approvalCallback = callback; + } + + setAskUserCallback(callback: AskUserQuestionCallback | null): void { + this.askUserCallback = callback; + } + + async handleServerRequest( + requestIdOrMethod: RequestId | string, + methodOrParams: unknown, + maybeParams?: unknown, + ): Promise { + const hasExplicitRequestId = maybeParams !== undefined; + const requestId = hasExplicitRequestId ? requestIdOrMethod : undefined; + const method = (hasExplicitRequestId ? methodOrParams : requestIdOrMethod) as string; + const params = (hasExplicitRequestId ? maybeParams : methodOrParams); + + switch (method) { + case 'item/commandExecution/requestApproval': + return this.handleCommandApproval(requestId, params as CommandApprovalRequest); + + case 'item/fileChange/requestApproval': + return this.handleFileChangeApproval(requestId, params as FileChangeApprovalRequest); + + case 'item/permissions/requestApproval': + return this.handlePermissionsApproval(requestId, params as PermissionsApprovalRequest); + + case 'item/tool/requestUserInput': + return this.handleUserInputRequest(requestId, params as UserInputRequest); + + default: + throw new Error(`Unsupported server request: ${method}`); + } + } + + hasPendingApprovalRequest(requestId: RequestId, threadId: string): boolean { + return this.pendingApprovalRequests.get(requestId) === threadId; + } + + private async handleCommandApproval( + requestId: RequestId | undefined, + params: CommandApprovalRequest, + ): Promise { + if (!this.approvalCallback) return { decision: 'decline' }; + + const command = params.command ?? ''; + const toolName = normalizeCodexToolName('command_execution'); + const input = { + command, + cwd: params.cwd ?? null, + reason: params.reason ?? null, + commandActions: params.commandActions ?? null, + approvalId: params.approvalId ?? null, + networkApprovalContext: params.networkApprovalContext ?? null, + additionalPermissions: params.additionalPermissions ?? null, + skillMetadata: params.skillMetadata ?? null, + proposedExecpolicyAmendment: params.proposedExecpolicyAmendment ?? null, + proposedNetworkPolicyAmendments: params.proposedNetworkPolicyAmendments ?? null, + }; + const description = describeCommandApproval(params); + + if (requestId !== undefined) { + this.pendingApprovalRequests.set(requestId, params.threadId); + } + + try { + const decision = await this.approvalCallback(toolName, input, description, { + ...(params.reason ? { decisionReason: params.reason } : {}), + ...(params.networkApprovalContext ? { networkApprovalContext: params.networkApprovalContext } : {}), + ...(params.additionalPermissions ? { additionalPermissions: params.additionalPermissions } : {}), + decisionOptions: buildCommandApprovalDecisionOptions(params), + }); + return { decision: mapCommandApprovalDecision(decision) }; + } finally { + if (requestId !== undefined) { + this.pendingApprovalRequests.delete(requestId); + } + } + } + + private async handleFileChangeApproval( + requestId: RequestId | undefined, + params: FileChangeApprovalRequest, + ): Promise { + if (!this.approvalCallback) return { decision: 'decline' }; + + const reason = params.reason ?? undefined; + const toolName = normalizeCodexToolName('file_change'); + const input: Record = { reason: reason ?? null }; + const description = reason ? `File change: ${reason}` : 'File change'; + + if (requestId !== undefined) { + this.pendingApprovalRequests.set(requestId, params.threadId); + } + + try { + const decision = await this.approvalCallback(toolName, input, description, {}); + return { decision: mapFileChangeApprovalDecision(decision) }; + } finally { + if (requestId !== undefined) { + this.pendingApprovalRequests.delete(requestId); + } + } + } + + private async handlePermissionsApproval( + requestId: RequestId | undefined, + params: PermissionsApprovalRequest, + ): Promise { + if (!this.approvalCallback) return { permissions: {}, scope: 'turn' }; + + const requestedPermissions = params.permissions as Record | undefined ?? {}; + const reason = params.reason ?? undefined; + const toolName = 'permissions'; + const description = reason ? `Permission request: ${reason}` : 'Permission request'; + + if (requestId !== undefined) { + this.pendingApprovalRequests.set(requestId, params.threadId); + } + + let decision: ApprovalDecision; + try { + decision = await this.approvalCallback(toolName, requestedPermissions, description, {}); + } finally { + if (requestId !== undefined) { + this.pendingApprovalRequests.delete(requestId); + } + } + + if (decision === 'allow') { + return { permissions: requestedPermissions, scope: 'turn' }; + } + if (decision === 'allow-always') { + return { permissions: requestedPermissions, scope: 'session' }; + } + + return { permissions: {}, scope: 'turn' }; + } + + abortPendingAskUser(requestId?: RequestId, threadId?: string): boolean { + if (!this.askUserAbortController) { + return false; + } + + if (requestId !== undefined && requestId !== this.pendingAskUserRequestId) { + return false; + } + + if (threadId !== undefined && threadId !== this.pendingAskUserThreadId) { + return false; + } + + this.askUserAbortController.abort(); + this.askUserAbortController = null; + this.pendingAskUserRequestId = null; + this.pendingAskUserThreadId = null; + return true; + } + + private async handleUserInputRequest( + requestId: RequestId | undefined, + params: UserInputRequest, + ): Promise { + if (!this.askUserCallback) return { answers: {} }; + + const questions = params.questions ?? []; + const input: Record = { questions }; + + this.askUserAbortController = new AbortController(); + this.pendingAskUserRequestId = requestId ?? null; + this.pendingAskUserThreadId = params.threadId; + + let userAnswers: Record | null; + try { + userAnswers = await this.askUserCallback(input, this.askUserAbortController.signal); + } finally { + this.askUserAbortController = null; + this.pendingAskUserRequestId = null; + this.pendingAskUserThreadId = null; + } + + if (!userAnswers) return { answers: {} }; + + const answers: Record = {}; + for (const [key, value] of Object.entries(userAnswers)) { + answers[key] = { answers: normalizeAnswers(value) }; + } + + return { answers }; + } +} + +function describeCommandApproval(params: CommandApprovalRequest): string { + const networkContext = params.networkApprovalContext; + if (networkContext) { + return `Allow ${networkContext.protocol} access to ${networkContext.host}`; + } + + const command = params.command ?? ''; + return command ? `Execute: ${command}` : 'Execute command'; +} + +function buildCommandApprovalDecisionOptions( + params: CommandApprovalRequest, +): ApprovalDecisionOption[] { + const availableDecisions = params.availableDecisions ?? ['accept', 'acceptForSession', 'decline']; + + return availableDecisions.map((decision) => mapDecisionOption(decision, params)); +} + +function mapDecisionOption( + decision: CommandExecutionApprovalDecision, + params: CommandApprovalRequest, +): ApprovalDecisionOption { + if (decision === 'accept') { + return { label: 'Allow once', value: 'allow-once', decision: 'allow' }; + } + if (decision === 'acceptForSession') { + return { label: 'Always allow', value: 'allow-always', decision: 'allow-always' }; + } + if (decision === 'decline') { + return { label: 'Deny', value: 'deny', decision: 'deny' }; + } + if (decision === 'cancel') { + return { label: 'Cancel', value: 'cancel', decision: 'cancel' }; + } + if ('acceptWithExecpolicyAmendment' in decision) { + return { + label: 'Allow similar commands', + description: 'Approve and store an exec policy amendment.', + value: encodeCommandApprovalDecision(decision), + }; + } + + const networkPolicyAmendment = decision.applyNetworkPolicyAmendment.network_policy_amendment; + const host = networkPolicyAmendment.host || params.networkApprovalContext?.host || 'host'; + const action = networkPolicyAmendment.action === 'deny' ? 'Deny' : 'Allow'; + return { + label: `${action} ${host} for this session`, + description: `Apply a ${networkPolicyAmendment.action} rule for ${host}.`, + value: encodeCommandApprovalDecision(decision), + }; +} + +function normalizeAnswers(value: string | string[]): string[] { + if (Array.isArray(value)) { + return value + .map((item) => (typeof item === 'string' ? item : String(item))) + .filter((item) => item.trim().length > 0); + } + + return [value]; +} + +function mapCommandApprovalDecision(decision: ApprovalDecision): CommandExecutionApprovalDecision { + switch (decision) { + case 'allow': + return 'accept'; + case 'allow-always': + return 'acceptForSession'; + case 'deny': + return 'decline'; + case 'cancel': + return 'cancel'; + default: + if (typeof decision === 'object' && decision !== null && decision.type === 'select-option') { + const decoded = decodeCommandApprovalDecision(decision.value); + if (decoded) { + return decoded; + } + } + return 'decline'; + } +} + +function mapFileChangeApprovalDecision(decision: ApprovalDecision): FileChangeApprovalDecision { + switch (decision) { + case 'allow': + return 'accept'; + case 'allow-always': + return 'acceptForSession'; + case 'deny': + return 'decline'; + case 'cancel': + return 'cancel'; + default: + return 'decline'; + } +} + +function encodeCommandApprovalDecision(decision: CommandExecutionApprovalDecision): string { + return JSON.stringify(decision); +} + +function decodeCommandApprovalDecision(value: string): CommandExecutionApprovalDecision | null { + try { + return JSON.parse(value) as CommandExecutionApprovalDecision; + } catch { + return null; + } +} diff --git a/src/providers/codex/runtime/CodexSessionManager.ts b/src/providers/codex/runtime/CodexSessionManager.ts new file mode 100644 index 0000000..46273e2 --- /dev/null +++ b/src/providers/codex/runtime/CodexSessionManager.ts @@ -0,0 +1,39 @@ +export class CodexSessionManager { + private threadId: string | null = null; + private sessionFilePath: string | null = null; + private sessionInvalidated = false; + + getThreadId(): string | null { + return this.threadId; + } + + getSessionFilePath(): string | null { + return this.sessionFilePath; + } + + setThread(threadId: string, sessionFilePath?: string): void { + const threadChanged = this.threadId !== threadId; + this.threadId = threadId; + if (sessionFilePath) { + this.sessionFilePath = sessionFilePath; + } else if (threadChanged) { + this.sessionFilePath = null; + } + } + + reset(): void { + this.threadId = null; + this.sessionFilePath = null; + this.sessionInvalidated = false; + } + + invalidateSession(): void { + this.sessionInvalidated = true; + } + + consumeInvalidation(): boolean { + const was = this.sessionInvalidated; + this.sessionInvalidated = false; + return was; + } +} diff --git a/src/providers/codex/runtime/codexAppServerSupport.ts b/src/providers/codex/runtime/codexAppServerSupport.ts new file mode 100644 index 0000000..f292148 --- /dev/null +++ b/src/providers/codex/runtime/codexAppServerSupport.ts @@ -0,0 +1,66 @@ +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import type { ProviderId } from '../../../core/providers/types'; +import { getEnhancedPath, parseEnvironmentVariables } from '../../../utils/env'; +import { getVaultPath } from '../../../utils/path'; +import type { InitializeResult } from './codexAppServerTypes'; +import { resolveCodexExecutionTarget } from './CodexExecutionTargetResolver'; +import { buildCodexLaunchSpec } from './CodexLaunchSpecBuilder'; +import type { CodexLaunchSpec } from './codexLaunchTypes'; +import type { CodexRpcTransport } from './CodexRpcTransport'; + +const CODEX_APP_SERVER_CLIENT_INFO = Object.freeze({ + name: 'claudian', + version: '1.0.0', +}); + +export function getCodexAppServerWorkingDirectory(plugin: ProviderHost): string { + return getVaultPath(plugin.app) ?? process.cwd(); +} + +export function buildCodexAppServerEnvironment( + plugin: ProviderHost, + providerId: ProviderId = 'codex', +): Record { + const customEnv = parseEnvironmentVariables(plugin.getActiveEnvironmentVariables(providerId)); + const baseEnv = Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + const enhancedPath = getEnhancedPath(customEnv.PATH); + + return { + ...baseEnv, + ...customEnv, + PATH: enhancedPath, + }; +} + +export function resolveCodexAppServerLaunchSpec( + plugin: ProviderHost, + providerId: ProviderId = 'codex', +): CodexLaunchSpec { + const hostVaultPath = getCodexAppServerWorkingDirectory(plugin); + const executionTarget = resolveCodexExecutionTarget({ + settings: plugin.settings, + hostVaultPath, + }); + + return buildCodexLaunchSpec({ + settings: plugin.settings, + resolvedCliCommand: plugin.getResolvedProviderCliPath(providerId, { executionTarget }), + hostVaultPath, + env: buildCodexAppServerEnvironment(plugin, providerId), + executionTarget, + }); +} + +export async function initializeCodexAppServerTransport( + transport: CodexRpcTransport, +): Promise { + const result = await transport.request('initialize', { + clientInfo: CODEX_APP_SERVER_CLIENT_INFO, + capabilities: { experimentalApi: true }, + }); + + transport.notify('initialized'); + return result; +} diff --git a/src/providers/codex/runtime/codexAppServerTypes.ts b/src/providers/codex/runtime/codexAppServerTypes.ts new file mode 100644 index 0000000..8893b87 --- /dev/null +++ b/src/providers/codex/runtime/codexAppServerTypes.ts @@ -0,0 +1,740 @@ +// Local protocol subset for Codex app-server stdio JSON-RPC. +// Field names match the wire format (camelCase). +// Validated against the generated codex-cli 0.144.1 schema on 2026-07-10. + +// --------------------------------------------------------------------------- +// JSON-RPC base +// --------------------------------------------------------------------------- + +export interface JsonRpcRequest { + jsonrpc: '2.0'; + id: number; + method: string; + params?: unknown; +} + +export interface JsonRpcNotification { + jsonrpc: '2.0'; + method: string; + params?: unknown; +} + +export interface JsonRpcResponse { + jsonrpc: '2.0'; + id: number; + result?: unknown; + error?: JsonRpcError; +} + +export interface JsonRpcError { + code: number; + message: string; + data?: unknown; +} + +// --------------------------------------------------------------------------- +// Initialize +// --------------------------------------------------------------------------- + +export interface InitializeParams { + clientInfo: { name: string; version: string }; + capabilities: { experimentalApi?: boolean }; +} + +export interface InitializeResult { + userAgent: string; + codexHome?: string; + platformFamily: string; + platformOs: string; +} + +// --------------------------------------------------------------------------- +// Thread +// --------------------------------------------------------------------------- + +export interface Thread { + id: string; + preview: string; + ephemeral: boolean; + path: string; + cwd: string; + cliVersion: string; + status: ThreadStatus; + turns: Turn[]; + createdAt: number; + updatedAt: number; + name: string | null; + modelProvider: string; + source: string; + agentNickname: string | null; + agentRole: string | null; + gitInfo: GitInfo | null; +} + +export interface ThreadStatus { + type: 'idle' | 'active' | 'systemError'; + activeFlags?: string[]; +} + +export interface GitInfo { + sha: string; + branch: string; + originUrl: string; +} + +export interface Turn { + id: string; + items: ThreadItem[]; + status: 'inProgress' | 'completed' | 'failed' | 'interrupted'; + error: TurnError | null; +} + +export interface TurnError { + message: string; + codexErrorInfo: string | Record; + additionalDetails: string | null; +} + +// --------------------------------------------------------------------------- +// Thread items +// --------------------------------------------------------------------------- + +export type ThreadItem = + | UserMessageItem + | AgentMessageItem + | PlanItem + | ReasoningItem + | CommandExecutionItem + | FileChangeItem + | ImageViewItem + | WebSearchItem + | CollabAgentToolCallItem + | McpToolCallItem + | ContextCompactionItem; + +export interface UserMessageItem { + type: 'userMessage'; + id: string; + content: UserInput[]; +} + +export interface AgentMessageItem { + type: 'agentMessage'; + id: string; + text: string; + phase: string; + memoryCitation: unknown; +} + +export interface PlanItem { + type: 'plan'; + id: string; + text: string; +} + +export interface ReasoningItem { + type: 'reasoning'; + id: string; + summary: string[]; + content: string[]; +} + +export interface CommandExecutionItem { + type: 'commandExecution'; + id: string; + command: string; + cwd: string; + processId: string; + source: string; + status: string; + commandActions: CommandAction[]; + aggregatedOutput: string | null; + exitCode: number | null; + durationMs: number | null; +} + +export interface CommandAction { + type: string; + command: string; +} + +export interface FileChangeItem { + type: 'fileChange'; + id: string; + changes: FileChangeEntry[]; + status?: string; +} + +export interface FileChangeEntry { + path: string; + type?: string; + kind?: string | { type?: string; move_path?: string | null }; + diff?: string; +} + +export interface FileChangePatchUpdatedNotification { + threadId: string; + turnId: string; + itemId: string; + changes: FileChangeEntry[]; +} + +export interface ImageViewItem { + type: 'imageView'; + id: string; + path: string; +} + +export interface WebSearchItem { + type: 'webSearch'; + id: string; + query?: string; + queries?: string[]; + url?: string; + pattern?: string; + action?: { + type?: string; + query?: string; + queries?: string[]; + url?: string; + pattern?: string; + }; + status?: string; +} + +export interface CollabAgentToolCallItem { + type: 'collabAgentToolCall'; + id: string; + tool: string; + status?: string; + arguments?: Record; + result?: unknown; +} + +export interface McpToolCallItem { + type: 'mcpToolCall'; + id: string; + server: string; + tool: string; + status?: string; + arguments?: Record; + result?: { content?: Array<{ type?: string; text?: string }> } | null; + error?: string | null; + durationMs?: number | null; +} + +export interface ContextCompactionItem { + type: 'contextCompaction'; + id: string; +} + +// --------------------------------------------------------------------------- +// User input +// --------------------------------------------------------------------------- + +export interface TextInput { + type: 'text'; + text: string; + text_elements?: unknown[]; +} + +export interface LocalImageInput { + type: 'localImage'; + path: string; +} + +export interface SkillInput { + type: 'skill'; + name: string; + path: string; +} + +export interface MentionInput { + type: 'mention'; + name: string; + path: string; +} + +export type UserInput = TextInput | LocalImageInput | SkillInput | MentionInput; + +// --------------------------------------------------------------------------- +// skills/list +// --------------------------------------------------------------------------- + +export type SkillScope = 'user' | 'repo' | 'system' | 'admin'; + +export interface SkillInterface { + displayName?: string; + shortDescription?: string; +} + +export interface SkillMetadata { + name: string; + description: string; + shortDescription?: string; + interface?: SkillInterface; + path: string; + scope: SkillScope; + enabled: boolean; +} + +export interface SkillErrorInfo { + path: string; + message: string; +} + +export interface SkillsListEntry { + cwd: string; + skills: SkillMetadata[]; + errors: SkillErrorInfo[]; +} + +export interface SkillsListParams { + cwds?: string[]; + forceReload?: boolean; + perCwdExtraUserRoots?: Array<{ + cwd: string; + extraUserRoots: string[]; + }> | null; +} + +export interface SkillsListResult { + data: SkillsListEntry[]; +} + +// --------------------------------------------------------------------------- +// model/list +// --------------------------------------------------------------------------- + +export interface ModelReasoningEffortOption { + reasoningEffort: string; + description: string; +} + +export interface ModelServiceTier { + id: string; + name: string; + description: string; +} + +export interface AppServerModel { + id: string; + model: string; + displayName: string; + description: string; + hidden: boolean; + supportedReasoningEfforts: ModelReasoningEffortOption[]; + defaultReasoningEffort: string; + inputModalities?: Array<'text' | 'image'>; + supportsPersonality?: boolean; + serviceTiers?: ModelServiceTier[]; + defaultServiceTier?: string | null; + isDefault: boolean; +} + +export interface ModelListResult { + data: AppServerModel[]; + nextCursor?: string | null; +} + +// --------------------------------------------------------------------------- +// thread/start +// --------------------------------------------------------------------------- + +export interface ThreadStartParams { + model?: string; + cwd?: string; + approvalPolicy?: string; + sandbox?: string; + serviceTier?: string | null; + baseInstructions?: string; + experimentalRawEvents?: boolean; + persistExtendedHistory?: boolean; + sandboxPolicy?: SandboxPolicy; +} + +export interface ThreadStartResult { + thread: Thread; + model: string; + modelProvider: string; + serviceTier: string | null; + cwd: string; + approvalPolicy: string; + approvalsReviewer: string; + sandbox: SandboxPolicy; + reasoningEffort: string; +} + +export type SandboxPolicy = + | { type: 'dangerFullAccess' } + | { + type: 'workspaceWrite'; + writableRoots: string[]; + readOnlyAccess: { type: string }; + networkAccess: boolean; + excludeTmpdirEnvVar: boolean; + excludeSlashTmp: boolean; + } + | { + type: 'readOnly'; + access: { type: string }; + networkAccess: boolean; + } + | { + type: 'externalSandbox'; + networkAccess: string; + }; + +// --------------------------------------------------------------------------- +// thread/resume +// --------------------------------------------------------------------------- + +export interface ThreadResumeParams { + threadId: string; + model?: string; + approvalPolicy?: string; + sandbox?: string; + serviceTier?: string | null; + baseInstructions?: string; + experimentalRawEvents?: boolean; + persistExtendedHistory?: boolean; +} + +export type ThreadResumeResult = ThreadStartResult; + +// --------------------------------------------------------------------------- +// thread/fork +// --------------------------------------------------------------------------- + +export interface ThreadForkParams { + threadId: string; +} + +export type ThreadForkResult = ThreadStartResult; + +// --------------------------------------------------------------------------- +// thread/rollback +// --------------------------------------------------------------------------- + +export interface ThreadRollbackParams { + threadId: string; + numTurns: number; +} + +export interface ThreadRollbackResult { + thread: Thread; +} + +// --------------------------------------------------------------------------- +// thread/compact/start +// --------------------------------------------------------------------------- + +export interface ThreadCompactStartParams { + threadId: string; +} + +export type ThreadCompactStartResult = Record; + +// --------------------------------------------------------------------------- +// turn/start +// --------------------------------------------------------------------------- + +export interface CollaborationModeSettings { + model: string; + reasoning_effort: string | null; + developer_instructions: string | null; +} + +export interface CollaborationMode { + mode: 'plan' | 'default'; + settings: CollaborationModeSettings; +} + +export interface TurnStartParams { + threadId: string; + input: UserInput[]; + cwd?: string; + approvalPolicy?: string; + approvalsReviewer?: string; + model?: string; + serviceTier?: string | null; + effort?: string; + summary?: 'auto' | 'concise' | 'detailed' | 'none'; + sandboxPolicy?: SandboxPolicy | null; + personality?: string; + outputSchema?: unknown; + collaborationMode?: CollaborationMode; +} + +export interface TurnStartResult { + turn: Turn; +} + +// --------------------------------------------------------------------------- +// turn/steer +// --------------------------------------------------------------------------- + +export interface TurnSteerParams { + threadId: string; + input: UserInput[]; + expectedTurnId: string; +} + +export interface TurnSteerResult { + turnId: string; +} + +// --------------------------------------------------------------------------- +// turn/interrupt +// --------------------------------------------------------------------------- + +export interface TurnInterruptParams { + threadId: string; + turnId: string; +} + +// --------------------------------------------------------------------------- +// Server notifications +// --------------------------------------------------------------------------- + +export interface ThreadStartedNotification { + thread: Thread; +} + +export interface ThreadStatusChangedNotification { + threadId: string; + status: ThreadStatus; +} + +export interface TurnStartedNotification { + threadId: string; + turn: Turn; +} + +export interface TurnCompletedNotification { + threadId: string; + turn: Turn; +} + +export interface ItemStartedNotification { + item: ThreadItem; + threadId: string; + turnId: string; +} + +export interface ItemCompletedNotification { + item: ThreadItem; + threadId: string; + turnId: string; +} + +export interface AgentMessageDeltaNotification { + threadId: string; + turnId: string; + itemId: string; + delta: string; +} + +export interface TokenUsage { + totalTokens: number; + inputTokens: number; + cachedInputTokens: number; + outputTokens: number; + reasoningOutputTokens: number; +} + +export interface TokenUsageUpdatedNotification { + threadId: string; + turnId: string; + tokenUsage: { + total: TokenUsage; + last: TokenUsage; + modelContextWindow: number; + }; +} + +export interface PlanStep { + step: string; + status: string; +} + +export interface TurnPlanUpdatedNotification { + threadId: string; + turnId: string; + explanation: string | null; + plan: PlanStep[]; +} + +export interface ErrorNotification { + error: TurnError; + willRetry: boolean; + threadId: string; + turnId: string; +} + +export interface ReasoningSummaryPartAddedNotification { + threadId: string; + turnId: string; + itemId: string; + summaryIndex: number; +} + +export interface ReasoningSummaryTextDeltaNotification { + threadId: string; + turnId: string; + itemId: string; + summaryIndex: number; + delta: string; +} + +export interface ReasoningTextDeltaNotification { + threadId: string; + turnId: string; + itemId: string; + contentIndex: number; + delta: string; +} + +export interface PlanDeltaNotification { + threadId: string; + turnId: string; + itemId: string; + delta: string; +} + +export type RequestId = string | number; + +export interface ServerRequestResolvedNotification { + threadId: string; + requestId: RequestId; +} + +// --------------------------------------------------------------------------- +// Server requests (require client response) +// --------------------------------------------------------------------------- + +// -- Command execution approval (item/commandExecution/requestApproval) ------ + +export interface CommandApprovalRequest { + threadId: string; + turnId: string; + itemId: string; + command: string | null; + cwd: string | null; + reason?: string | null; + commandActions?: CommandAction[] | null; + approvalId?: string | null; + networkApprovalContext?: { + host: string; + protocol: string; + } | null; + additionalPermissions?: AdditionalPermissionProfile | null; + skillMetadata?: { pathToSkillsMd: string } | null; + proposedExecpolicyAmendment?: string[] | null; + proposedNetworkPolicyAmendments?: Array<{ + host: string; + action: 'allow' | 'deny'; + }> | null; + availableDecisions?: CommandExecutionApprovalDecision[] | null; +} + +export type CommandExecutionApprovalDecision = + | 'accept' + | 'acceptForSession' + | { acceptWithExecpolicyAmendment: { execpolicy_amendment: string[] } } + | { applyNetworkPolicyAmendment: { network_policy_amendment: { host: string; action: 'allow' | 'deny' } } } + | 'decline' + | 'cancel'; + +export interface CommandExecutionApprovalResponse { + decision: CommandExecutionApprovalDecision; +} + +// -- File change approval (item/fileChange/requestApproval) ------------------ + +export interface FileChangeApprovalRequest { + threadId: string; + turnId: string; + itemId: string; + reason?: string | null; + grantRoot?: string | null; +} + +export type FileChangeApprovalDecision = + | 'accept' + | 'acceptForSession' + | 'decline' + | 'cancel'; + +export interface FileChangeApprovalResponse { + decision: FileChangeApprovalDecision; +} + +// -- Permissions approval (item/permissions/requestApproval) ----------------- + +export interface AdditionalFileSystemPermissions { + read?: string[] | null; + write?: string[] | null; +} + +export interface AdditionalNetworkPermissions { + enabled?: boolean | null; +} + +export interface AdditionalPermissionProfile { + fileSystem?: AdditionalFileSystemPermissions | null; + network?: AdditionalNetworkPermissions | null; + macos?: Record | null; +} + +export interface RequestPermissionProfile { + fileSystem?: AdditionalFileSystemPermissions | null; + network?: AdditionalNetworkPermissions | null; +} + +export interface GrantedPermissionProfile { + fileSystem?: AdditionalFileSystemPermissions | null; + network?: AdditionalNetworkPermissions | null; +} + +export type PermissionGrantScope = 'turn' | 'session'; + +export interface PermissionsApprovalRequest { + threadId: string; + turnId: string; + itemId: string; + permissions: RequestPermissionProfile; + reason?: string | null; +} + +export interface PermissionsApprovalResponse { + permissions: GrantedPermissionProfile; + scope?: PermissionGrantScope; +} + +// -- Tool request user input (item/tool/requestUserInput) -------------------- + +export interface UserInputQuestionOption { + label: string; + description: string; +} + +export interface UserInputQuestion { + id: string; + header: string; + question: string; + options: UserInputQuestionOption[] | null; + isOther: boolean; + isSecret: boolean; +} + +export interface UserInputRequest { + threadId: string; + turnId: string; + itemId: string; + questions: UserInputQuestion[]; +} + +export interface UserInputResponse { + answers: Record; +} diff --git a/src/providers/codex/runtime/codexLaunchTypes.ts b/src/providers/codex/runtime/codexLaunchTypes.ts new file mode 100644 index 0000000..7b0aaf9 --- /dev/null +++ b/src/providers/codex/runtime/codexLaunchTypes.ts @@ -0,0 +1,30 @@ +import type { CodexInstallationMethod } from '../settings'; + +export type CodexExecutionMethod = 'host-native' | CodexInstallationMethod; +export type CodexExecutionPlatformOs = 'windows' | 'linux' | 'macos'; +export type CodexExecutionPlatformFamily = 'windows' | 'unix'; + +export interface CodexExecutionTarget { + method: CodexExecutionMethod; + platformFamily: CodexExecutionPlatformFamily; + platformOs: CodexExecutionPlatformOs; + distroName?: string; +} + +export interface CodexPathMapper { + target: CodexExecutionTarget; + toTargetPath(hostPath: string): string | null; + toHostPath(targetPath: string): string | null; + mapTargetPathList(hostPaths: string[]): string[]; + canRepresentHostPath(hostPath: string): boolean; +} + +export interface CodexLaunchSpec { + target: CodexExecutionTarget; + command: string; + args: string[]; + spawnCwd: string; + targetCwd: string; + env: Record; + pathMapper: CodexPathMapper; +} diff --git a/src/providers/codex/settings.ts b/src/providers/codex/settings.ts new file mode 100644 index 0000000..dda89ef --- /dev/null +++ b/src/providers/codex/settings.ts @@ -0,0 +1,607 @@ +import { getProviderConfig, setProviderConfig } from '../../core/providers/providerConfig'; +import { getProviderEnvironmentVariables } from '../../core/providers/providerEnvironment'; +import { DEFAULT_REASONING_VALUE } from '../../core/providers/reasoning'; +import type { HostnameCliPaths } from '../../core/types/settings'; +import { + getHostnameKey, + getLegacyHostnameKey, + migrateLegacyHostnameKeyedMap, +} from '../../utils/env'; +import { + type CodexDiscoveredModel, + findCodexModel, + getCodexDefaultReasoningEffort, + getDefaultCodexModel, + normalizeCodexDiscoveredModels, +} from './models'; +import { toCodexRuntimeModelId } from './modelSelection'; +import { CODEX_SPARK_MODEL } from './types/models'; + +export type CodexSafeMode = 'workspace-write' | 'read-only'; +export type CodexReasoningSummary = 'auto' | 'concise' | 'detailed' | 'none'; +export type CodexInstallationMethod = 'native-windows' | 'wsl'; +export type HostnameInstallationMethods = Record; + +export interface CodexProviderConfig { + enabled: boolean; + safeMode: CodexSafeMode; + cliPath: string; + cliPathsByHost: HostnameCliPaths; + customModels: string; + discoveredModels: CodexDiscoveredModel[]; + modelAliases: Record; + visibleModels: string[] | null; + reasoningSummary: CodexReasoningSummary; + environmentVariables: string; + environmentHash: string; + installationMethodsByHost: HostnameInstallationMethods; + wslDistroOverridesByHost: HostnameCliPaths; +} + +export interface NormalizeCodexStoredConfigContext { + platform?: NodeJS.Platform; + hostnameKey?: string; + legacyHostnameKey?: string; +} + +export interface NormalizeCodexStoredConfigResult { + config: CodexProviderConfig & Record; + changed: boolean; +} + +function normalizeCodexInstallationMethod(value: unknown): CodexInstallationMethod { + return value === 'wsl' ? 'wsl' : 'native-windows'; +} + +function normalizeOptionalString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function shouldPersistCodexInstallationSettings(): boolean { + return process.platform === 'win32'; +} + +function omitCurrentHost(entries: Record, hostnameKey: string): Record { + const next = { ...entries }; + delete next[hostnameKey]; + delete next[getLegacyHostnameKey()]; + return next; +} + +type CodexProjectionKey = + | 'savedProviderEffort' + | 'savedProviderModel' + | 'savedProviderServiceTier'; + +function ensureCodexProjectionMap( + settings: Record, + key: CodexProjectionKey, +): Record { + const current = settings[key]; + if (current && typeof current === 'object' && !Array.isArray(current)) { + return current as Record; + } + + const next: Record = {}; + settings[key] = next; + return next; +} + +export interface CodexProviderSettings { + enabled: CodexProviderConfig['enabled']; + safeMode: CodexProviderConfig['safeMode']; + cliPath: CodexProviderConfig['cliPath']; + cliPathsByHost: CodexProviderConfig['cliPathsByHost']; + customModels: CodexProviderConfig['customModels']; + discoveredModels: CodexProviderConfig['discoveredModels']; + modelAliases: CodexProviderConfig['modelAliases']; + visibleModels: CodexProviderConfig['visibleModels']; + reasoningSummary: CodexProviderConfig['reasoningSummary']; + environmentVariables: CodexProviderConfig['environmentVariables']; + environmentHash: CodexProviderConfig['environmentHash']; + installationMethod: CodexInstallationMethod; + installationMethodsByHost: CodexProviderConfig['installationMethodsByHost']; + wslDistroOverride: string; + wslDistroOverridesByHost: CodexProviderConfig['wslDistroOverridesByHost']; +} + +export const DEFAULT_CODEX_PROVIDER_CONFIG: Readonly = Object.freeze({ + enabled: false, + safeMode: 'workspace-write', + cliPath: '', + cliPathsByHost: {}, + customModels: '', + discoveredModels: [], + modelAliases: {}, + visibleModels: null, + reasoningSummary: 'detailed', + environmentVariables: '', + environmentHash: '', + installationMethodsByHost: {}, + wslDistroOverridesByHost: {}, +}); + +export const DEFAULT_CODEX_PROVIDER_SETTINGS: Readonly = Object.freeze({ + ...DEFAULT_CODEX_PROVIDER_CONFIG, + installationMethod: 'native-windows', + wslDistroOverride: '', +}); + +export function shouldDisableCodexReasoningSummary(model: string | undefined): boolean { + return model ? toCodexRuntimeModelId(model) === CODEX_SPARK_MODEL : false; +} + +export function getEffectiveCodexReasoningSummary( + settings: Record, + model: string | undefined, +): CodexReasoningSummary { + if (shouldDisableCodexReasoningSummary(model)) { + return 'none'; + } + + return getCodexProviderSettings(settings).reasoningSummary; +} + +export function applyCodexModelDefaults( + model: string, + settings: Record, +): void { + const modelMetadata = findCodexModel(getCodexProviderSettings(settings).discoveredModels, model); + settings.effortLevel = modelMetadata + ? getCodexDefaultReasoningEffort(modelMetadata) + : DEFAULT_REASONING_VALUE; + if (shouldDisableCodexReasoningSummary(model)) { + updateCodexProviderSettings(settings, { reasoningSummary: 'none' }); + } +} + +function normalizeHostnameCliPaths(value: unknown): HostnameCliPaths { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const result: HostnameCliPaths = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string' && entry.trim()) { + result[key] = entry.trim(); + } + } + return result; +} + +export function normalizeCodexVisibleModels( + value: unknown, + discoveredModels: CodexDiscoveredModel[] = [], +): string[] | null { + if (value === null || value === undefined) { + return null; + } + if (!Array.isArray(value)) { + return null; + } + + const knownModelIds = new Set(discoveredModels.map(model => model.model)); + const visibleModels: string[] = []; + const seen = new Set(); + for (const entry of value) { + if (typeof entry !== 'string') { + continue; + } + + const modelId = entry.trim(); + if ( + !modelId + || seen.has(modelId) + || (knownModelIds.size > 0 && !knownModelIds.has(modelId)) + ) { + continue; + } + + seen.add(modelId); + visibleModels.push(modelId); + } + + return visibleModels; +} + +export function normalizeCodexModelAliases( + value: unknown, + discoveredModels: CodexDiscoveredModel[] = [], +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const knownModelIds = new Set(discoveredModels.map(model => model.model)); + const normalized: Record = {}; + for (const [rawModelId, rawAlias] of Object.entries(value as Record)) { + if (typeof rawAlias !== 'string') { + continue; + } + + const modelId = rawModelId.trim(); + const alias = rawAlias.trim(); + if (!modelId || !alias || (knownModelIds.size > 0 && !knownModelIds.has(modelId))) { + continue; + } + normalized[modelId] = alias; + } + return normalized; +} + +export function createCodexVisibleModelFilter( + value: unknown, + discoveredModels: CodexDiscoveredModel[], +): string[] | null { + const normalized = normalizeCodexVisibleModels(value, discoveredModels); + return normalized !== null + && discoveredModels.length > 0 + && normalized.length === discoveredModels.length + ? null + : normalized; +} + +export function getVisibleCodexModelIds( + visibleModels: string[] | null, + discoveredModels: CodexDiscoveredModel[], +): string[] { + return visibleModels === null + ? discoveredModels.map(model => model.model) + : normalizeCodexVisibleModels(visibleModels, discoveredModels) ?? []; +} + +function pruneCodexModelAliases( + aliases: Record, + visibleModelIds: string[] | null, +): Record { + if (visibleModelIds === null) { + return aliases; + } + + const visible = new Set(visibleModelIds); + return Object.fromEntries( + Object.entries(aliases).filter(([modelId]) => visible.has(modelId)), + ); +} + +function getCodexAliasModelIds( + visibleModels: string[] | null, + discoveredModels: CodexDiscoveredModel[], +): string[] | null { + if (discoveredModels.length === 0 && visibleModels === null) { + return null; + } + return getVisibleCodexModelIds(visibleModels, discoveredModels); +} + +function retargetRemovedCodexSelections( + settings: Record, + next: CodexProviderSettings, +): void { + if (next.visibleModels === null) { + return; + } + + const visibleModelIds = new Set(next.visibleModels); + if (visibleModelIds.size === 0) { + if (findCodexModel(next.discoveredModels, settings.titleGenerationModel as string | undefined)) { + settings.titleGenerationModel = ''; + } + return; + } + + const fallbackModel = getDefaultCodexModel( + next.discoveredModels.filter(model => visibleModelIds.has(model.model)), + ); + if (!fallbackModel) { + return; + } + + const maybeRetarget = (value: unknown): string | null => { + if (typeof value !== 'string') { + return null; + } + + const model = findCodexModel(next.discoveredModels, value); + return model && !visibleModelIds.has(model.model) ? fallbackModel.model : null; + }; + const fallbackServiceTier = fallbackModel.defaultServiceTier ?? 'default'; + + const existingSavedModels = settings.savedProviderModel; + const savedCodexModel = existingSavedModels + && typeof existingSavedModels === 'object' + && !Array.isArray(existingSavedModels) + ? (existingSavedModels as Record).codex + : undefined; + const nextSavedModel = maybeRetarget(savedCodexModel); + if (nextSavedModel) { + ensureCodexProjectionMap(settings, 'savedProviderModel').codex = nextSavedModel; + ensureCodexProjectionMap(settings, 'savedProviderEffort').codex = getCodexDefaultReasoningEffort(fallbackModel); + ensureCodexProjectionMap(settings, 'savedProviderServiceTier').codex = fallbackServiceTier; + } + + const nextTopLevelModel = maybeRetarget(settings.model); + if (nextTopLevelModel) { + settings.model = nextTopLevelModel; + settings.effortLevel = getCodexDefaultReasoningEffort(fallbackModel); + settings.serviceTier = fallbackServiceTier; + } + + const nextTitleGenerationModel = maybeRetarget(settings.titleGenerationModel); + if (nextTitleGenerationModel) { + settings.titleGenerationModel = nextTitleGenerationModel; + } +} + +function normalizeInstallationMethodsByHost(value: unknown): HostnameInstallationMethods { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const result: HostnameInstallationMethods = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof key === 'string' && key.trim()) { + result[key] = normalizeCodexInstallationMethod(entry); + } + } + return result; +} + +function hasOwnEntry(entries: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(entries, key); +} + +function getCodexStoredConfig( + settings: Record, + hostnameKey: string, + legacyHostnameKey: string, +): CodexProviderConfig { + const config = getProviderConfig(settings, 'codex'); + const normalizedCliPathsByHost = normalizeHostnameCliPaths(config.cliPathsByHost ?? settings.codexCliPathsByHost); + const normalizedInstallationMethodsByHost = normalizeInstallationMethodsByHost(config.installationMethodsByHost); + const normalizedWslDistroOverridesByHost = normalizeHostnameCliPaths(config.wslDistroOverridesByHost); + const cliPathsByHost = migrateLegacyHostnameKeyedMap(normalizedCliPathsByHost, hostnameKey, legacyHostnameKey); + const installationMethodsByHost = migrateLegacyHostnameKeyedMap( + normalizedInstallationMethodsByHost, + hostnameKey, + legacyHostnameKey, + ); + const wslDistroOverridesByHost = migrateLegacyHostnameKeyedMap( + normalizedWslDistroOverridesByHost, + hostnameKey, + legacyHostnameKey, + ); + const discoveredModels = normalizeCodexDiscoveredModels(config.discoveredModels); + const visibleModels = normalizeCodexVisibleModels(config.visibleModels, discoveredModels); + + return { + enabled: (config.enabled as boolean | undefined) + ?? (settings.codexEnabled as boolean | undefined) + ?? DEFAULT_CODEX_PROVIDER_CONFIG.enabled, + safeMode: (config.safeMode as CodexSafeMode | undefined) + ?? (settings.codexSafeMode as CodexSafeMode | undefined) + ?? DEFAULT_CODEX_PROVIDER_CONFIG.safeMode, + cliPath: (config.cliPath as string | undefined) + ?? (settings.codexCliPath as string | undefined) + ?? DEFAULT_CODEX_PROVIDER_CONFIG.cliPath, + cliPathsByHost, + customModels: (config.customModels as string | undefined) + ?? DEFAULT_CODEX_PROVIDER_CONFIG.customModels, + discoveredModels, + modelAliases: pruneCodexModelAliases( + normalizeCodexModelAliases(config.modelAliases, discoveredModels), + getCodexAliasModelIds(visibleModels, discoveredModels), + ), + visibleModels, + reasoningSummary: (config.reasoningSummary as CodexReasoningSummary | undefined) + ?? (settings.codexReasoningSummary as CodexReasoningSummary | undefined) + ?? DEFAULT_CODEX_PROVIDER_CONFIG.reasoningSummary, + environmentVariables: (config.environmentVariables as string | undefined) + ?? getProviderEnvironmentVariables(settings, 'codex') + ?? DEFAULT_CODEX_PROVIDER_CONFIG.environmentVariables, + environmentHash: (config.environmentHash as string | undefined) + ?? (settings.lastCodexEnvHash as string | undefined) + ?? DEFAULT_CODEX_PROVIDER_CONFIG.environmentHash, + installationMethodsByHost, + wslDistroOverridesByHost, + }; +} + +function getNormalizedCodexStoredConfigContext( + context: NormalizeCodexStoredConfigContext, +): Required { + return { + platform: context.platform ?? process.platform, + hostnameKey: context.hostnameKey ?? getHostnameKey(), + legacyHostnameKey: context.legacyHostnameKey ?? getLegacyHostnameKey(), + }; +} + +function projectStoredCodexConfigNormalization( + originalConfig: Record, + normalizedConfig: Record, +): Record { + const projected = { ...originalConfig }; + for (const key of Object.keys(DEFAULT_CODEX_PROVIDER_CONFIG)) { + if (key in originalConfig) { + projected[key] = normalizedConfig[key]; + } + } + delete projected.installationMethod; + delete projected.wslDistroOverride; + return projected; +} + +export function normalizeCodexStoredConfig( + settings: Record, + context: NormalizeCodexStoredConfigContext = {}, +): NormalizeCodexStoredConfigResult { + const originalConfig = getProviderConfig(settings, 'codex'); + const { + platform, + hostnameKey, + legacyHostnameKey, + } = getNormalizedCodexStoredConfigContext(context); + const storedConfig = getCodexStoredConfig(settings, hostnameKey, legacyHostnameKey); + const installationMethodsByHost = { ...storedConfig.installationMethodsByHost }; + const wslDistroOverridesByHost = { ...storedConfig.wslDistroOverridesByHost }; + + if (platform === 'win32') { + if (!hasOwnEntry(installationMethodsByHost, hostnameKey) && 'installationMethod' in originalConfig) { + installationMethodsByHost[hostnameKey] = normalizeCodexInstallationMethod(originalConfig.installationMethod); + } + + if (!hasOwnEntry(wslDistroOverridesByHost, hostnameKey) && 'wslDistroOverride' in originalConfig) { + const normalizedDistroOverride = normalizeOptionalString(originalConfig.wslDistroOverride); + if (normalizedDistroOverride) { + wslDistroOverridesByHost[hostnameKey] = normalizedDistroOverride; + } + } + } else { + delete installationMethodsByHost[hostnameKey]; + delete installationMethodsByHost[legacyHostnameKey]; + delete wslDistroOverridesByHost[hostnameKey]; + delete wslDistroOverridesByHost[legacyHostnameKey]; + } + + const normalizedConfig: CodexProviderConfig & Record = { + ...originalConfig, + ...storedConfig, + installationMethodsByHost, + wslDistroOverridesByHost, + }; + delete normalizedConfig.installationMethod; + delete normalizedConfig.wslDistroOverride; + + const projectedConfig = projectStoredCodexConfigNormalization(originalConfig, normalizedConfig); + return { + config: normalizedConfig, + changed: JSON.stringify(projectedConfig) !== JSON.stringify(originalConfig), + }; +} + +export function getCodexProviderSettings( + settings: Record, +): CodexProviderSettings { + const config = getProviderConfig(settings, 'codex'); + const hostnameKey = getHostnameKey(); + const legacyHostnameKey = getLegacyHostnameKey(); + const storedConfig = getCodexStoredConfig(settings, hostnameKey, legacyHostnameKey); + const hasHostScopedInstallationMethods = Object.keys(storedConfig.installationMethodsByHost).length > 0; + const hasHostScopedWslDistroOverrides = Object.keys(storedConfig.wslDistroOverridesByHost).length > 0; + const legacyInstallationMethod = normalizeCodexInstallationMethod(config.installationMethod); + const legacyWslDistroOverride = normalizeOptionalString(config.wslDistroOverride); + + return { + ...storedConfig, + installationMethod: storedConfig.installationMethodsByHost[hostnameKey] + ?? ( + hasHostScopedInstallationMethods + ? DEFAULT_CODEX_PROVIDER_SETTINGS.installationMethod + : legacyInstallationMethod + ), + wslDistroOverride: storedConfig.wslDistroOverridesByHost[hostnameKey] + ?? ( + hasHostScopedWslDistroOverrides + ? DEFAULT_CODEX_PROVIDER_SETTINGS.wslDistroOverride + : legacyWslDistroOverride + ), + }; +} + +export function updateCodexProviderSettings( + settings: Record, + updates: Partial, +): CodexProviderSettings { + const current = getCodexProviderSettings(settings); + const hostnameKey = getHostnameKey(); + const persistInstallationSettings = shouldPersistCodexInstallationSettings(); + const updatedInstallationMethodsByHost = 'installationMethodsByHost' in updates + ? normalizeInstallationMethodsByHost(updates.installationMethodsByHost) + : { ...current.installationMethodsByHost }; + const updatedWslDistroOverridesByHost = 'wslDistroOverridesByHost' in updates + ? normalizeHostnameCliPaths(updates.wslDistroOverridesByHost) + : { ...current.wslDistroOverridesByHost }; + const installationMethodsByHost = persistInstallationSettings + ? updatedInstallationMethodsByHost + : omitCurrentHost(updatedInstallationMethodsByHost, hostnameKey); + const wslDistroOverridesByHost = persistInstallationSettings + ? updatedWslDistroOverridesByHost + : omitCurrentHost(updatedWslDistroOverridesByHost, hostnameKey); + const discoveredModels = normalizeCodexDiscoveredModels( + updates.discoveredModels ?? current.discoveredModels, + ); + const visibleModels = normalizeCodexVisibleModels( + 'visibleModels' in updates ? updates.visibleModels : current.visibleModels, + discoveredModels, + ); + const modelAliases = pruneCodexModelAliases( + normalizeCodexModelAliases(updates.modelAliases ?? current.modelAliases, discoveredModels), + getCodexAliasModelIds(visibleModels, discoveredModels), + ); + + if ( + persistInstallationSettings + && Object.keys(installationMethodsByHost).length === 0 + && current.installationMethod !== DEFAULT_CODEX_PROVIDER_SETTINGS.installationMethod + ) { + installationMethodsByHost[hostnameKey] = current.installationMethod; + } + + if ( + persistInstallationSettings + && Object.keys(wslDistroOverridesByHost).length === 0 + && current.wslDistroOverride + ) { + wslDistroOverridesByHost[hostnameKey] = current.wslDistroOverride; + } + + if (persistInstallationSettings && 'installationMethod' in updates) { + installationMethodsByHost[hostnameKey] = normalizeCodexInstallationMethod(updates.installationMethod); + } + + if (persistInstallationSettings && 'wslDistroOverride' in updates) { + const normalizedDistroOverride = normalizeOptionalString(updates.wslDistroOverride); + if (normalizedDistroOverride) { + wslDistroOverridesByHost[hostnameKey] = normalizedDistroOverride; + } else { + delete wslDistroOverridesByHost[hostnameKey]; + } + } + + const next: CodexProviderSettings = { + ...current, + ...updates, + discoveredModels, + modelAliases, + visibleModels, + installationMethod: persistInstallationSettings + ? installationMethodsByHost[hostnameKey] ?? DEFAULT_CODEX_PROVIDER_SETTINGS.installationMethod + : DEFAULT_CODEX_PROVIDER_SETTINGS.installationMethod, + installationMethodsByHost, + wslDistroOverride: persistInstallationSettings + ? wslDistroOverridesByHost[hostnameKey] ?? DEFAULT_CODEX_PROVIDER_SETTINGS.wslDistroOverride + : DEFAULT_CODEX_PROVIDER_SETTINGS.wslDistroOverride, + wslDistroOverridesByHost, + }; + + setProviderConfig(settings, 'codex', { + enabled: next.enabled, + safeMode: next.safeMode, + cliPath: next.cliPath, + cliPathsByHost: next.cliPathsByHost, + customModels: next.customModels, + discoveredModels: next.discoveredModels, + modelAliases: next.modelAliases, + visibleModels: next.visibleModels, + reasoningSummary: next.reasoningSummary, + environmentVariables: next.environmentVariables, + environmentHash: next.environmentHash, + installationMethodsByHost, + wslDistroOverridesByHost, + }); + if ('visibleModels' in updates) { + retargetRemovedCodexSelections(settings, next); + } + return next; +} diff --git a/src/providers/codex/skills/CodexSkillListingService.ts b/src/providers/codex/skills/CodexSkillListingService.ts new file mode 100644 index 0000000..1fc655c --- /dev/null +++ b/src/providers/codex/skills/CodexSkillListingService.ts @@ -0,0 +1,173 @@ +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { CodexAppServerProcess } from '../runtime/CodexAppServerProcess'; +import { + initializeCodexAppServerTransport, + resolveCodexAppServerLaunchSpec, +} from '../runtime/codexAppServerSupport'; +import type { + SkillMetadata, + SkillScope, + SkillsListResult, +} from '../runtime/codexAppServerTypes'; +import { CodexRpcTransport } from '../runtime/CodexRpcTransport'; +import { createCodexRuntimeContext } from '../runtime/CodexRuntimeContext'; + +export interface CodexSkillListProvider { + listSkills(options?: { forceReload?: boolean }): Promise; + invalidate(): void; +} + +interface CodexSkillListingServiceOptions { + ttlMs?: number; + now?: () => number; +} + +const DEFAULT_SKILL_LIST_TTL_MS = 5_000; + +const SKILL_SCOPE_PRIORITY: Record = { + repo: 0, + user: 1, + system: 2, + admin: 3, +}; + +export function compareCodexSkillPriority( + left: Pick, + right: Pick, +): number { + const scopeDelta = SKILL_SCOPE_PRIORITY[left.scope] - SKILL_SCOPE_PRIORITY[right.scope]; + if (scopeDelta !== 0) { + return scopeDelta; + } + + const nameDelta = left.name.localeCompare(right.name); + if (nameDelta !== 0) { + return nameDelta; + } + + return left.path.localeCompare(right.path); +} + +export function extractExplicitCodexSkillNames(text: string): string[] { + const matches = text.matchAll(/(^|\s)\$([A-Za-z0-9_-]+)/g); + const names: string[] = []; + const seen = new Set(); + + for (const match of matches) { + const name = match[2]; + if (!name) { + continue; + } + + const normalized = name.toLowerCase(); + if (seen.has(normalized)) { + continue; + } + + seen.add(normalized); + names.push(name); + } + + return names; +} + +export function getCodexSkillDescription( + skill: Pick, +): string | undefined { + return skill.interface?.shortDescription + ?? skill.shortDescription + ?? skill.description + ?? undefined; +} + +export function findPreferredCodexSkillByName( + skills: SkillMetadata[], + name: string, +): SkillMetadata | null { + const normalized = name.toLowerCase(); + const candidates = skills + .filter(skill => skill.enabled && skill.name.toLowerCase() === normalized) + .sort(compareCodexSkillPriority); + + return candidates[0] ?? null; +} + +export class CodexSkillListingService implements CodexSkillListProvider { + private cache: SkillMetadata[] | null = null; + private cacheExpiresAt = 0; + private pending: Promise | null = null; + private readonly ttlMs: number; + private readonly now: () => number; + + constructor( + private readonly plugin: ProviderHost, + options: CodexSkillListingServiceOptions = {}, + ) { + this.ttlMs = options.ttlMs ?? DEFAULT_SKILL_LIST_TTL_MS; + this.now = options.now ?? (() => Date.now()); + } + + async listSkills(options?: { forceReload?: boolean }): Promise { + if (options?.forceReload) { + const skills = await this.fetchSkills(true); + this.storeCache(skills); + return skills; + } + + if (this.cache && this.now() < this.cacheExpiresAt) { + return this.cache; + } + + if (this.pending) { + return this.pending; + } + + this.pending = this.fetchSkills(false) + .then((skills) => { + this.storeCache(skills); + return skills; + }) + .finally(() => { + this.pending = null; + }); + + return this.pending; + } + + invalidate(): void { + this.cache = null; + this.cacheExpiresAt = 0; + } + + private async fetchSkills(forceReload: boolean): Promise { + const launchSpec = resolveCodexAppServerLaunchSpec(this.plugin, 'codex'); + const process = new CodexAppServerProcess(launchSpec); + process.start(); + + const transport = new CodexRpcTransport(process); + transport.start(); + + try { + const initializeResult = await initializeCodexAppServerTransport(transport); + createCodexRuntimeContext(launchSpec, initializeResult); + const result = await transport.request('skills/list', { + cwds: [launchSpec.targetCwd], + ...(forceReload ? { forceReload: true } : {}), + }); + + const entry = result.data.find(candidate => candidate.cwd === launchSpec.targetCwd) ?? result.data[0]; + return (entry?.skills ?? []).map(skill => ({ + ...skill, + path: launchSpec.pathMapper.toHostPath(skill.path) ?? skill.path, + })); + } finally { + transport.dispose(); + await process.shutdown(); + } + } + + private storeCache(skills: SkillMetadata[]): void { + this.cache = skills; + this.cacheExpiresAt = this.now() + this.ttlMs; + } +} diff --git a/src/providers/codex/storage/CodexSkillStorage.ts b/src/providers/codex/storage/CodexSkillStorage.ts new file mode 100644 index 0000000..2763ecd --- /dev/null +++ b/src/providers/codex/storage/CodexSkillStorage.ts @@ -0,0 +1,250 @@ +import * as path from 'path'; + +import type { VaultFileAdapter } from '../../../core/storage/VaultFileAdapter'; +import { parseSlashCommandContent, serializeSlashCommandMarkdown } from '../../../utils/slashCommand'; + +export const CODEX_VAULT_SKILLS_PATH = '.codex/skills'; +export const AGENTS_VAULT_SKILLS_PATH = '.agents/skills'; + +export type CodexSkillRootId = 'vault-codex' | 'vault-agents'; + +export const CODEX_SKILL_ROOT_OPTIONS = [ + { id: 'vault-codex' as const, label: CODEX_VAULT_SKILLS_PATH }, + { id: 'vault-agents' as const, label: AGENTS_VAULT_SKILLS_PATH }, +]; + +const ROOT_PATH_BY_ID: Record = { + 'vault-codex': CODEX_VAULT_SKILLS_PATH, + 'vault-agents': AGENTS_VAULT_SKILLS_PATH, +}; + +const ROOT_ID_BY_PATH = new Map( + Object.entries(ROOT_PATH_BY_ID).map(([rootId, rootPath]) => [rootPath, rootId as CodexSkillRootId]), +); + +const ALL_SCAN_ROOTS: CodexSkillRootId[] = ['vault-codex', 'vault-agents']; +const SKILL_PERSISTENCE_PREFIX = 'codex-skill'; + +export type CodexSkillStorageAdapter = Pick< + VaultFileAdapter, + 'read' | 'write' | 'delete' | 'deleteFolder' | 'listFolders' | 'ensureFolder' +>; + +export interface CodexSkillEntry { + name: string; + description?: string; + content: string; + provenance: 'vault' | 'home'; + rootId: CodexSkillRootId; +} + +export interface CodexSkillLocation { + name: string; + rootId: CodexSkillRootId; +} + +export interface CodexSkillSaveInput { + name: string; + description?: string; + content: string; + rootId?: CodexSkillRootId; + previousLocation?: CodexSkillLocation; +} + +export interface CodexSkillPersistenceState { + rootId: CodexSkillRootId; + currentName?: string; +} + +export function createCodexSkillPersistenceKey( + state: CodexSkillPersistenceState, +): string { + const parts = [SKILL_PERSISTENCE_PREFIX, state.rootId]; + if (state.currentName) { + parts.push(encodeURIComponent(state.currentName)); + } + return parts.join(':'); +} + +export function parseCodexSkillPersistenceKey( + persistenceKey?: string, +): CodexSkillPersistenceState | null { + if (!persistenceKey) { + return null; + } + + const legacyRootId = ROOT_ID_BY_PATH.get(persistenceKey); + if (legacyRootId) { + return { rootId: legacyRootId }; + } + + const [prefix, rootId, encodedName] = persistenceKey.split(':'); + if (prefix !== SKILL_PERSISTENCE_PREFIX) { + return null; + } + if (rootId !== 'vault-codex' && rootId !== 'vault-agents') { + return null; + } + + return { + rootId, + ...(encodedName ? { currentName: decodeURIComponent(encodedName) } : {}), + }; +} + +export function resolveCodexSkillLocationFromPath( + skillPath: string, + vaultPath: string, +): CodexSkillLocation | null { + const usesWindowsPathSemantics = ( + /^[A-Za-z]:[\\/]/.test(skillPath) + || /^[A-Za-z]:[\\/]/.test(vaultPath) + || skillPath.startsWith('\\\\') + || vaultPath.startsWith('\\\\') + ); + const pathApi = usesWindowsPathSemantics ? path.win32 : path.posix; + const normalizedSkillPath = pathApi.normalize(skillPath); + const normalizedVaultPath = pathApi.normalize(vaultPath); + + for (const [rootId, rootPath] of Object.entries(ROOT_PATH_BY_ID) as Array<[CodexSkillRootId, string]>) { + const rootDir = pathApi.normalize(pathApi.join(normalizedVaultPath, rootPath)); + const relative = pathApi.relative(rootDir, normalizedSkillPath); + + if ( + !relative + || relative.startsWith(`..${pathApi.sep}`) + || relative === '..' + ) { + continue; + } + + const parts = relative.split(pathApi.sep); + if (parts.length !== 2 || parts[1] !== 'SKILL.md' || !parts[0]) { + continue; + } + + return { + name: parts[0], + rootId, + }; + } + + return null; +} + +export class CodexSkillStorage { + constructor( + private vaultAdapter: CodexSkillStorageAdapter, + private homeAdapter?: CodexSkillStorageAdapter, + ) {} + + async scanAll(): Promise { + const vaultSkills = await this.scanRoots(this.vaultAdapter, ALL_SCAN_ROOTS, 'vault'); + const homeSkills = this.homeAdapter + ? await this.scanRoots(this.homeAdapter, ALL_SCAN_ROOTS, 'home') + : []; + + // Deduplicate: vault takes priority over home + const seen = new Set(vaultSkills.map(s => s.name.toLowerCase())); + const deduped = homeSkills.filter(s => !seen.has(s.name.toLowerCase())); + + return [...vaultSkills, ...deduped]; + } + + async scanVault(): Promise { + return this.scanRoots(this.vaultAdapter, ALL_SCAN_ROOTS, 'vault'); + } + + async save(input: CodexSkillSaveInput): Promise { + const targetRootId = input.rootId ?? 'vault-codex'; + const targetLocation = { rootId: targetRootId, name: input.name }; + const { dirPath, filePath } = this.buildLocationPaths(targetLocation); + const previousLocation = input.previousLocation; + + await this.vaultAdapter.ensureFolder(dirPath); + const markdown = serializeSlashCommandMarkdown( + { name: input.name, description: input.description }, + input.content, + ); + await this.vaultAdapter.write(filePath, markdown); + + if ( + previousLocation + && (previousLocation.rootId !== targetRootId || previousLocation.name !== input.name) + ) { + await this.delete(previousLocation); + } + } + + async delete(location: CodexSkillLocation): Promise { + const { dirPath, filePath } = this.buildLocationPaths(location); + await this.vaultAdapter.delete(filePath); + await this.vaultAdapter.deleteFolder(dirPath); + } + + async load(location: CodexSkillLocation): Promise { + const { filePath } = this.buildLocationPaths(location); + + try { + const content = await this.vaultAdapter.read(filePath); + const parsed = parseSlashCommandContent(content); + + return { + name: location.name, + description: parsed.description, + content: parsed.promptContent, + provenance: 'vault', + rootId: location.rootId, + }; + } catch { + return null; + } + } + + private async scanRoots( + adapter: CodexSkillStorageAdapter, + roots: CodexSkillRootId[], + provenance: 'vault' | 'home', + ): Promise { + const results: CodexSkillEntry[] = []; + + for (const rootId of roots) { + const rootPath = ROOT_PATH_BY_ID[rootId]; + try { + const folders = await adapter.listFolders(rootPath); + for (const folder of folders) { + const skillName = folder.split('/').pop()!; + const skillPath = `${rootPath}/${skillName}/SKILL.md`; + + try { + const content = await adapter.read(skillPath); + const parsed = parseSlashCommandContent(content); + + results.push({ + name: skillName, + description: parsed.description, + content: parsed.promptContent, + provenance, + rootId, + }); + } catch { + // Skip malformed files + } + } + } catch { + // Root doesn't exist or can't be read + } + } + + return results; + } + + private buildLocationPaths(location: CodexSkillLocation): { dirPath: string; filePath: string } { + const rootPath = ROOT_PATH_BY_ID[location.rootId]; + const dirPath = `${rootPath}/${location.name}`; + return { + dirPath, + filePath: `${dirPath}/SKILL.md`, + }; + } +} diff --git a/src/providers/codex/storage/CodexSubagentStorage.ts b/src/providers/codex/storage/CodexSubagentStorage.ts new file mode 100644 index 0000000..ee3a85d --- /dev/null +++ b/src/providers/codex/storage/CodexSubagentStorage.ts @@ -0,0 +1,212 @@ +import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'; + +import type { VaultFileAdapter } from '../../../core/storage/VaultFileAdapter'; +import { + CODEX_SUBAGENT_KNOWN_KEYS, + type CodexSubagentDefinition, +} from '../types/subagent'; + +export const CODEX_AGENTS_PATH = '.codex/agents'; +const SUBAGENT_PERSISTENCE_PREFIX = 'codex-subagent'; + +export interface CodexSubagentLocation { + fileName: string; +} + +export function createCodexSubagentPersistenceKey( + location: CodexSubagentLocation, +): string { + return `${SUBAGENT_PERSISTENCE_PREFIX}:${encodeURIComponent(location.fileName)}`; +} + +export function parseCodexSubagentPersistenceKey( + persistenceKey?: string, +): CodexSubagentLocation | null { + if (!persistenceKey) { + return null; + } + + if (persistenceKey.startsWith(`${CODEX_AGENTS_PATH}/`) && persistenceKey.endsWith('.toml')) { + return { fileName: persistenceKey.slice(CODEX_AGENTS_PATH.length + 1) }; + } + + const [prefix, encodedFileName] = persistenceKey.split(':'); + if (prefix !== SUBAGENT_PERSISTENCE_PREFIX || !encodedFileName) { + return null; + } + + const fileName = decodeURIComponent(encodedFileName); + return fileName.endsWith('.toml') ? { fileName } : null; +} + +export class CodexSubagentStorage { + constructor( + private vaultAdapter: Pick, + ) {} + + async loadAll(): Promise { + return this.scanAdapter(this.vaultAdapter); + } + + async load(agent: CodexSubagentDefinition): Promise { + const filePath = this.resolveCurrentPath(agent); + try { + if (!(await this.vaultAdapter.exists(filePath))) return null; + const content = await this.vaultAdapter.read(filePath); + return parseSubagentToml(content, filePath); + } catch { + return null; + } + } + + async save(agent: CodexSubagentDefinition, previous?: CodexSubagentDefinition | null): Promise { + const filePath = this.resolveTargetPath(agent, previous); + const previousPath = previous ? this.resolveCurrentPath(previous) : null; + await this.vaultAdapter.ensureFolder(CODEX_AGENTS_PATH); + const content = serializeSubagentToml(agent); + await this.vaultAdapter.write(filePath, content); + + if (previousPath && previousPath !== filePath) { + await this.vaultAdapter.delete(previousPath); + } + } + + async delete(agent: CodexSubagentDefinition): Promise { + const filePath = this.resolveCurrentPath(agent); + await this.vaultAdapter.delete(filePath); + } + + private resolveCurrentPath(agent: CodexSubagentDefinition): string { + const persistedLocation = parseCodexSubagentPersistenceKey(agent.persistenceKey); + if (persistedLocation) { + return `${CODEX_AGENTS_PATH}/${persistedLocation.fileName}`; + } + + return `${CODEX_AGENTS_PATH}/${agent.name}.toml`; + } + + private resolveTargetPath( + agent: CodexSubagentDefinition, + previous?: CodexSubagentDefinition | null, + ): string { + if (previous && previous.name === agent.name) { + return this.resolveCurrentPath(previous); + } + + return `${CODEX_AGENTS_PATH}/${agent.name}.toml`; + } + + private async scanAdapter( + adapter: Pick, + ): Promise { + const results: CodexSubagentDefinition[] = []; + + try { + const files = await adapter.listFiles(CODEX_AGENTS_PATH); + for (const filePath of files) { + if (!filePath.endsWith('.toml')) continue; + try { + const content = await adapter.read(filePath); + const agent = parseSubagentToml(content, filePath); + if (agent) results.push(agent); + } catch { + // Skip malformed files + } + } + } catch { + // Directory doesn't exist yet + } + + return results; + } +} + +export function parseSubagentToml( + content: string, + filePath: string, +): CodexSubagentDefinition | null { + let parsed: Record; + try { + parsed = parseToml(content) as Record; + } catch { + return null; + } + + const name = typeof parsed.name === 'string' ? parsed.name : undefined; + const description = + typeof parsed.description === 'string' ? parsed.description : undefined; + const developerInstructions = + typeof parsed.developer_instructions === 'string' + ? parsed.developer_instructions + : undefined; + + if (!name || !description || !developerInstructions) return null; + + const result: CodexSubagentDefinition = { + name, + description, + developerInstructions, + persistenceKey: createCodexSubagentPersistenceKey({ + fileName: filePath.startsWith(`${CODEX_AGENTS_PATH}/`) + ? filePath.slice(CODEX_AGENTS_PATH.length + 1) + : filePath.split('/').pop() ?? filePath, + }), + }; + + if (typeof parsed.model === 'string') { + result.model = parsed.model; + } + if (typeof parsed.model_reasoning_effort === 'string') { + result.modelReasoningEffort = parsed.model_reasoning_effort; + } + if (typeof parsed.sandbox_mode === 'string') { + result.sandboxMode = parsed.sandbox_mode; + } + if (Array.isArray(parsed.nickname_candidates)) { + const candidates = parsed.nickname_candidates.filter( + (v): v is string => typeof v === 'string', + ); + if (candidates.length > 0) result.nicknameCandidates = candidates; + } + + const extraFields: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (!CODEX_SUBAGENT_KNOWN_KEYS.has(key)) { + extraFields[key] = value; + } + } + if (Object.keys(extraFields).length > 0) { + result.extraFields = extraFields; + } + + return result; +} + +export function serializeSubagentToml(agent: CodexSubagentDefinition): string { + const doc: Record = { + name: agent.name, + description: agent.description, + developer_instructions: agent.developerInstructions, + }; + + if (agent.nicknameCandidates && agent.nicknameCandidates.length > 0) { + doc.nickname_candidates = agent.nicknameCandidates; + } + if (agent.model) { + doc.model = agent.model; + } + if (agent.modelReasoningEffort) { + doc.model_reasoning_effort = agent.modelReasoningEffort; + } + if (agent.sandboxMode) { + doc.sandbox_mode = agent.sandboxMode; + } + + if (agent.extraFields) { + for (const [key, value] of Object.entries(agent.extraFields)) { + doc[key] = value; + } + } + + return stringifyToml(doc); +} diff --git a/src/providers/codex/types/index.ts b/src/providers/codex/types/index.ts new file mode 100644 index 0000000..31307fe --- /dev/null +++ b/src/providers/codex/types/index.ts @@ -0,0 +1,16 @@ +import type { ForkSource } from '../../../core/types/chat'; + +export interface CodexProviderState { + threadId?: string; + sessionFilePath?: string; + transcriptRootPath?: string; + forkSourceSessionFilePath?: string; + forkSourceTranscriptRootPath?: string; + forkSource?: ForkSource; +} + +export function getCodexState( + providerState?: Record, +): CodexProviderState { + return (providerState ?? {}); +} diff --git a/src/providers/codex/types/models.ts b/src/providers/codex/types/models.ts new file mode 100644 index 0000000..f21a7fa --- /dev/null +++ b/src/providers/codex/types/models.ts @@ -0,0 +1,21 @@ +export type CodexModel = string; + +export const CODEX_SPARK_MODEL: CodexModel = 'gpt-5.3-codex-spark'; + +function formatCodexModelSuffix(suffix: string): string { + return suffix + .split('-') + .filter(Boolean) + .map(segment => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase()) + .join(' '); +} + +export function formatCodexModelLabel(model: string): string { + const match = model.match(/^gpt-([^-]+)(?:-(.+))?$/i); + if (!match) { + return model; + } + + const [, version, suffix] = match; + return `GPT-${version}${suffix ? ` ${formatCodexModelSuffix(suffix)}` : ''}`; +} diff --git a/src/providers/codex/types/subagent.ts b/src/providers/codex/types/subagent.ts new file mode 100644 index 0000000..57500dc --- /dev/null +++ b/src/providers/codex/types/subagent.ts @@ -0,0 +1,23 @@ +export interface CodexSubagentDefinition { + name: string; + description: string; + developerInstructions: string; + nicknameCandidates?: string[]; + model?: string; + modelReasoningEffort?: string; + sandboxMode?: string; + /** Opaque storage token preserved across edits/deletes. */ + persistenceKey?: string; + /** Preserves unrecognized TOML keys for round-trip fidelity. */ + extraFields?: Record; +} + +export const CODEX_SUBAGENT_KNOWN_KEYS = new Set([ + 'name', + 'description', + 'developer_instructions', + 'nickname_candidates', + 'model', + 'model_reasoning_effort', + 'sandbox_mode', +]); diff --git a/src/providers/codex/ui/CodexChatUIConfig.ts b/src/providers/codex/ui/CodexChatUIConfig.ts new file mode 100644 index 0000000..7152b02 --- /dev/null +++ b/src/providers/codex/ui/CodexChatUIConfig.ts @@ -0,0 +1,190 @@ +import { DEFAULT_REASONING_VALUE } from '../../../core/providers/reasoning'; +import type { + ProviderChatUIConfig, + ProviderPermissionModeToggleConfig, + ProviderReasoningOption, + ProviderServiceTierToggleConfig, + ProviderUIOption, +} from '../../../core/providers/types'; +import { OPENAI_PROVIDER_ICON } from '../../../shared/icons'; +import { getCodexModelOptions } from '../modelOptions'; +import { + findCodexModel, + getCodexDefaultReasoningEffort, + getCodexFastServiceTier, + getDefaultCodexModel, +} from '../models'; +import { + isCodexModelSelectionId, + looksLikeCodexModel, + toCodexRuntimeModelId, +} from '../modelSelection'; +import { + applyCodexModelDefaults, + getCodexProviderSettings, + getVisibleCodexModelIds, +} from '../settings'; + +const EFFORT_LEVELS: ProviderReasoningOption[] = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'xhigh', label: 'XHigh' }, + { value: 'max', label: 'Max' }, +]; + +function formatEffortLabel(value: string): string { + if (value.toLowerCase() === 'xhigh') { + return 'XHigh'; + } + return value.charAt(0).toUpperCase() + value.slice(1); +} + +const CODEX_PERMISSION_MODE_TOGGLE: ProviderPermissionModeToggleConfig = { + inactiveValue: 'normal', + inactiveLabel: 'Safe', + activeValue: 'yolo', + activeLabel: 'YOLO', + planValue: 'plan', + planLabel: 'Plan', +}; + +const DEFAULT_SERVICE_TIER_VALUE = 'default'; +const DEFAULT_SERVICE_TIER_LABEL = 'Standard'; + +const DEFAULT_CONTEXT_WINDOW = 200_000; + +function getVisibleDiscoveredModels(settings: Record) { + const codexSettings = getCodexProviderSettings(settings); + const visibleModelIds = new Set(getVisibleCodexModelIds( + codexSettings.visibleModels, + codexSettings.discoveredModels, + )); + return codexSettings.discoveredModels.filter(model => visibleModelIds.has(model.model)); +} + +export const codexChatUIConfig: ProviderChatUIConfig = { + getModelOptions(settings: Record): ProviderUIOption[] { + return getCodexModelOptions(settings); + }, + + getDefaultModel(settings: Record): string | null { + return getDefaultCodexModel(getVisibleDiscoveredModels(settings))?.model ?? null; + }, + + ownsModel(model: string, settings: Record): boolean { + if (isCodexModelSelectionId(model)) { + return true; + } + + const runtimeModel = toCodexRuntimeModelId(model); + if (getCodexModelOptions(settings).some((option: ProviderUIOption) => + option.value === model || toCodexRuntimeModelId(option.value) === runtimeModel + )) { + return true; + } + + return looksLikeCodexModel(runtimeModel); + }, + + isAdaptiveReasoningModel(_model: string, _settings: Record): boolean { + return true; + }, + + getReasoningOptions(modelId: string, settings: Record): ProviderReasoningOption[] { + const model = findCodexModel( + getCodexProviderSettings(settings).discoveredModels, + modelId, + ); + if (!model) { + return [...EFFORT_LEVELS]; + } + + return model.supportedReasoningEfforts.map(option => ({ + value: option.value, + label: formatEffortLabel(option.value), + ...(option.description ? { description: option.description } : {}), + })); + }, + + getDefaultReasoningValue(modelId: string, settings: Record): string { + const model = findCodexModel( + getCodexProviderSettings(settings).discoveredModels, + modelId, + ); + return model ? getCodexDefaultReasoningEffort(model) : DEFAULT_REASONING_VALUE; + }, + + getContextWindowSize(): number { + return DEFAULT_CONTEXT_WINDOW; + }, + + isDefaultModel(model: string): boolean { + return looksLikeCodexModel(toCodexRuntimeModelId(model)) && !isCodexModelSelectionId(model); + }, + + applyModelDefaults(model: string, settings: unknown): void { + if (!settings || typeof settings !== 'object') { + return; + } + + applyCodexModelDefaults(toCodexRuntimeModelId(model), settings as Record); + }, + + normalizeModelVariant(model: string, settings: Record): string { + const runtimeModel = toCodexRuntimeModelId(model); + const option = getCodexModelOptions(settings).find((candidate) => + candidate.value === model || toCodexRuntimeModelId(candidate.value) === runtimeModel + ); + if (option) { + return option.value; + } + + const codexSettings = getCodexProviderSettings(settings); + const discoveredModels = codexSettings.discoveredModels; + if (discoveredModels.length === 0) { + return model; + } + + return getDefaultCodexModel(getVisibleDiscoveredModels(settings))?.model ?? model; + }, + + getCustomModelIds(envVars: Record): Set { + const ids = new Set(); + if (envVars.OPENAI_MODEL && !looksLikeCodexModel(envVars.OPENAI_MODEL)) { + ids.add(envVars.OPENAI_MODEL); + } + return ids; + }, + + getPermissionModeToggle(): ProviderPermissionModeToggleConfig { + return CODEX_PERMISSION_MODE_TOGGLE; + }, + + getServiceTierToggle(settings): ProviderServiceTierToggleConfig | null { + const model = findCodexModel( + getCodexProviderSettings(settings).discoveredModels, + typeof settings.model === 'string' ? settings.model : undefined, + ); + if (!model) { + return null; + } + + const tier = getCodexFastServiceTier(model); + if (!tier) { + return null; + } + + return { + inactiveValue: model.defaultServiceTier ?? DEFAULT_SERVICE_TIER_VALUE, + inactiveLabel: DEFAULT_SERVICE_TIER_LABEL, + activeValue: tier.id, + activeLabel: tier.name, + description: tier.description || undefined, + }; + }, + + getProviderIcon() { + return OPENAI_PROVIDER_ICON; + }, +}; diff --git a/src/providers/codex/ui/CodexModelPicker.ts b/src/providers/codex/ui/CodexModelPicker.ts new file mode 100644 index 0000000..77fe35f --- /dev/null +++ b/src/providers/codex/ui/CodexModelPicker.ts @@ -0,0 +1,124 @@ +import { Notice } from 'obsidian'; + +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import type { ProviderSettingsTabRendererContext } from '../../../core/providers/types'; +import { + type ProviderModelPickerModel, + type ProviderModelPickerState, + renderProviderModelPicker, +} from '../../../shared/settings/ProviderModelPicker'; +import type { CodexWorkspaceServices } from '../app/CodexWorkspaceServices'; +import { getCodexModelsInPickerOrder } from '../models'; +import { + createCodexVisibleModelFilter, + getCodexProviderSettings, + getVisibleCodexModelIds, + updateCodexProviderSettings, +} from '../settings'; + +function sameVisibleModels(left: string[] | null, right: string[] | null): boolean { + if (left === null || right === null) { + return left === right; + } + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +export function renderCodexModelPicker( + container: HTMLElement, + context: ProviderSettingsTabRendererContext, + workspace: CodexWorkspaceServices, +): void { + const settingsBag = context.plugin.settings as unknown as Record; + + const getState = (): ProviderModelPickerState => { + const current = getCodexProviderSettings(settingsBag); + const pickerOrderedModels = getCodexModelsInPickerOrder(current.discoveredModels); + const visibleModelIds = getVisibleCodexModelIds( + current.visibleModels, + current.discoveredModels, + ); + const visibleModelIdSet = new Set(visibleModelIds); + const selectedIds = pickerOrderedModels + .map(model => model.model) + .filter(modelId => visibleModelIdSet.has(modelId)); + for (const modelId of visibleModelIds) { + if (!selectedIds.includes(modelId)) { + selectedIds.push(modelId); + } + } + + const models: ProviderModelPickerModel[] = pickerOrderedModels.map(model => ({ + ...(model.isDefault ? { catalogBadge: 'Default' } : {}), + description: model.description, + id: model.model, + isAvailable: true, + name: model.displayName, + })); + const discoveredIds = new Set(models.map(model => model.id)); + for (const modelId of visibleModelIds) { + if (!discoveredIds.has(modelId)) { + models.push({ + description: 'Selected model', + id: modelId, + isAvailable: false, + name: modelId, + unavailableMessage: 'Not currently reported by Codex', + }); + } + } + + return { + aliases: current.modelAliases, + discoveredCount: current.discoveredModels.length, + models, + selectedIds, + }; + }; + + const persistVisibleModels = async (modelIds: string[]): Promise => { + const current = getCodexProviderSettings(settingsBag); + const nextVisibleModels = createCodexVisibleModelFilter(modelIds, current.discoveredModels); + if (sameVisibleModels(current.visibleModels, nextVisibleModels)) { + return; + } + + await context.plugin.mutateSettings((settings) => { + updateCodexProviderSettings(settings, { visibleModels: nextVisibleModels }); + ProviderSettingsCoordinator.normalizeAllModelVariants(settings); + }); + context.refreshModelSelectors(); + }; + + renderProviderModelPicker({ + container, + emptyCatalogText: 'No Codex models discovered yet. Click Discover to query app-server.', + failedCatalogText: 'Could not load models from Codex app-server. Check the CLI path and login state, then try again.', + getState, + initiallyOpen: getCodexProviderSettings(settingsBag).discoveredModels.length === 0, + async loadCatalog() { + if (!workspace.refreshModelCatalog) { + return 'failed'; + } + + const result = await workspace.refreshModelCatalog(); + if (result.diagnostics) { + new Notice(`Codex model discovery failed: ${result.diagnostics}`); + return 'failed'; + } + context.refreshModelSelectors(); + return getCodexProviderSettings(settingsBag).discoveredModels.length > 0 ? 'loaded' : 'empty'; + }, + loadingCatalogText: 'Loading the Codex model catalog...', + modifier: 'codex', + async onAliasesChange(modelAliases) { + await context.plugin.mutateSettings((settings) => { + updateCodexProviderSettings(settings, { modelAliases }); + }); + context.refreshModelSelectors(); + }, + onSelectedIdsChange: persistVisibleModels, + providerName: 'Codex', + searchPlaceholder: 'Filter by model name, description, or ID...', + settingDescription: 'Choose which app-server models appear in the Codex selector. Existing session models stay pinned even when hidden here.', + }); +} diff --git a/src/providers/codex/ui/CodexSettingsTab.ts b/src/providers/codex/ui/CodexSettingsTab.ts new file mode 100644 index 0000000..3bf40a7 --- /dev/null +++ b/src/providers/codex/ui/CodexSettingsTab.ts @@ -0,0 +1,343 @@ +import * as fs from 'fs'; +import { Notice, Setting } from 'obsidian'; + +import type { ProviderSettingsTabRenderer } from '../../../core/providers/types'; +import { t } from '../../../i18n/i18n'; +import { renderEnvironmentSettingsSection } from '../../../shared/settings/EnvironmentSettingsSection'; +import { getHostnameKey } from '../../../utils/env'; +import { expandHomePath } from '../../../utils/path'; +import { getCodexWorkspaceServices } from '../app/CodexWorkspaceServices'; +import { getDefaultCodexModel } from '../models'; +import { isWindowsStyleCliReference } from '../runtime/CodexBinaryLocator'; +import { getCodexProviderSettings, updateCodexProviderSettings } from '../settings'; +import { renderCodexModelPicker } from './CodexModelPicker'; +import { CodexSkillSettings } from './CodexSkillSettings'; +import { CodexSubagentSettings } from './CodexSubagentSettings'; + +export const codexSettingsTabRenderer: ProviderSettingsTabRenderer = { + render(container, context) { + const codexWorkspace = getCodexWorkspaceServices(); + const settingsBag = context.plugin.settings as unknown as Record; + const codexSettings = getCodexProviderSettings(settingsBag); + const hostnameKey = getHostnameKey(); + const isWindowsHost = process.platform === 'win32'; + let installationMethod = codexSettings.installationMethod; + const environmentModelPlaceholder = getDefaultCodexModel(codexSettings.discoveredModels)?.model + ?? 'model-id'; + + const refreshCodexModelCatalog = async (): Promise => { + const result = await codexWorkspace.refreshModelCatalog?.(); + if (result?.diagnostics) { + new Notice(`Codex model discovery failed: ${result.diagnostics}`); + } + }; + + // --- Setup --- + + new Setting(container).setName(t('settings.setup')).setHeading(); + + new Setting(container) + .setName(t('settings.codex.enableProvider.name')) + .setDesc(t('settings.codex.enableProvider.desc')) + .addToggle((toggle) => + toggle + .setValue(codexSettings.enabled) + .onChange(async (value) => { + await context.plugin.mutateSettings((settings) => { + updateCodexProviderSettings(settings, { enabled: value }); + }); + if (value) { + await refreshCodexModelCatalog(); + } + context.refreshModelSelectors(); + }) + ); + + if (isWindowsHost) { + new Setting(container) + .setName(t('settings.codex.installationMethod.name')) + .setDesc(t('settings.codex.installationMethod.desc')) + .addDropdown((dropdown) => { + dropdown + .addOption('native-windows', t('settings.codex.installationMethod.nativeWindows')) + .addOption('wsl', t('settings.codex.installationMethod.wsl')) + .setValue(installationMethod) + .onChange(async (value) => { + installationMethod = value === 'wsl' ? 'wsl' : 'native-windows'; + await context.plugin.mutateSettings((settings) => { + updateCodexProviderSettings(settings, { installationMethod }); + }); + refreshInstallationMethodUI(); + await refreshCodexModelCatalog(); + }); + }); + } + + const getCliPathCopy = (): { desc: string; placeholder: string } => { + if (!isWindowsHost) { + return { + desc: t('settings.codex.cliPath.descUnix'), + placeholder: '/usr/local/bin/codex', + }; + } + + if (installationMethod === 'wsl') { + return { + desc: t('settings.codex.cliPath.descWsl'), + placeholder: 'codex', + }; + } + + return { + desc: t('settings.codex.cliPath.descWindows'), + placeholder: 'C:\\Users\\you\\AppData\\Roaming\\npm\\codex.exe', + }; + }; + + const shouldValidateCliPathAsFile = (): boolean => !isWindowsHost || installationMethod !== 'wsl'; + + const cliPathSetting = new Setting(container) + .setName(t('settings.codex.cliPath.name')) + .setDesc(getCliPathCopy().desc); + + const validationEl = container.createDiv({ + cls: 'claudian-cli-path-validation claudian-setting-validation claudian-setting-validation-error claudian-hidden', + }); + + const validatePath = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + + if (!shouldValidateCliPathAsFile()) { + if (isWindowsStyleCliReference(trimmed)) { + return t('settings.codex.cliPath.validation.wslWindowsPath'); + } + return null; + } + + const expandedPath = expandHomePath(trimmed); + + if (!fs.existsSync(expandedPath)) { + return t('settings.cliPath.validation.notExist'); + } + const stat = fs.statSync(expandedPath); + if (!stat.isFile()) { + return t('settings.cliPath.validation.isDirectory'); + } + return null; + }; + + const updateCliPathValidation = (value: string, inputEl?: HTMLInputElement): boolean => { + const error = validatePath(value); + if (error) { + validationEl.setText(error); + validationEl.toggleClass('claudian-hidden', false); + if (inputEl) { + inputEl.toggleClass('claudian-input-error', true); + } + return false; + } + + validationEl.toggleClass('claudian-hidden', true); + if (inputEl) { + inputEl.toggleClass('claudian-input-error', false); + } + return true; + }; + + const cliPathsByHost = { ...codexSettings.cliPathsByHost }; + let cliPathInputEl: HTMLInputElement | null = null; + let wslDistroSettingEl: HTMLElement | null = null; + let wslDistroInputEl: HTMLInputElement | null = null; + + const refreshInstallationMethodUI = (): void => { + const cliCopy = getCliPathCopy(); + cliPathSetting.setDesc(cliCopy.desc); + if (cliPathInputEl) { + cliPathInputEl.placeholder = cliCopy.placeholder; + updateCliPathValidation(cliPathInputEl.value, cliPathInputEl); + } + if (wslDistroSettingEl) { + wslDistroSettingEl.toggleClass('claudian-hidden', installationMethod !== 'wsl'); + } + if (wslDistroInputEl) { + wslDistroInputEl.disabled = installationMethod !== 'wsl'; + } + }; + + const persistCliPath = async (value: string): Promise => { + const isValid = updateCliPathValidation(value, cliPathInputEl ?? undefined); + if (!isValid) { + return false; + } + + const trimmed = value.trim(); + if (trimmed) { + cliPathsByHost[hostnameKey] = trimmed; + } else { + delete cliPathsByHost[hostnameKey]; + } + + await context.plugin.mutateSettings((settings) => { + updateCodexProviderSettings(settings, { cliPathsByHost: { ...cliPathsByHost } }); + }); + await context.plugin.recycleProviderRuntimes?.('codex'); + return true; + }; + + const currentValue = codexSettings.cliPathsByHost[hostnameKey] || ''; + + cliPathSetting.addText((text) => { + text + .setPlaceholder(getCliPathCopy().placeholder) + .setValue(currentValue) + .onChange(async (value) => { + await persistCliPath(value); + }); + text.inputEl.addClass('claudian-settings-cli-path-input'); + cliPathInputEl = text.inputEl; + + updateCliPathValidation(currentValue, text.inputEl); + }); + + if (isWindowsHost) { + const wslDistroSetting = new Setting(container) + .setName(t('settings.codex.wslDistroOverride.name')) + .setDesc(t('settings.codex.wslDistroOverride.desc')); + + wslDistroSettingEl = wslDistroSetting.settingEl; + wslDistroSetting.addText((text) => { + text + .setPlaceholder('Ubuntu') + .setValue(codexSettings.wslDistroOverride) + .onChange(async (value) => { + await context.plugin.mutateSettings((settings) => { + updateCodexProviderSettings(settings, { wslDistroOverride: value }); + }); + }); + + text.inputEl.addClass('claudian-settings-cli-path-input'); + text.inputEl.disabled = installationMethod !== 'wsl'; + wslDistroInputEl = text.inputEl; + }); + } + + refreshInstallationMethodUI(); + + // --- Safety --- + + new Setting(container).setName(t('settings.safety')).setHeading(); + + new Setting(container) + .setName(t('settings.codexSafeMode.name')) + .setDesc(t('settings.codexSafeMode.desc')) + .addDropdown((dropdown) => { + dropdown + .addOption('workspace-write', t('settings.codex.safeMode.workspaceWrite')) + .addOption('read-only', t('settings.codex.safeMode.readOnly')) + .setValue(codexSettings.safeMode) + .onChange(async (value) => { + await context.plugin.mutateSettings((settings) => { + updateCodexProviderSettings( + settings, + { safeMode: value as 'workspace-write' | 'read-only' }, + ); + }); + }); + }); + + // --- Models --- + + new Setting(container).setName(t('settings.models')).setHeading(); + + renderCodexModelPicker(container, context, codexWorkspace); + + const SUMMARY_OPTIONS: { value: string; label: string }[] = [ + { value: 'auto', label: t('settings.codex.reasoningSummary.auto') }, + { value: 'concise', label: t('settings.codex.reasoningSummary.concise') }, + { value: 'detailed', label: t('settings.codex.reasoningSummary.detailed') }, + { value: 'none', label: t('settings.codex.reasoningSummary.off') }, + ]; + + new Setting(container) + .setName(t('settings.codex.reasoningSummary.name')) + .setDesc(t('settings.codex.reasoningSummary.desc')) + .addDropdown((dropdown) => { + for (const opt of SUMMARY_OPTIONS) { + dropdown.addOption(opt.value, opt.label); + } + dropdown.setValue(codexSettings.reasoningSummary); + dropdown.onChange(async (value) => { + await context.plugin.mutateSettings((settings) => { + updateCodexProviderSettings( + settings, + { reasoningSummary: value as 'auto' | 'concise' | 'detailed' | 'none' }, + ); + }); + }); + }); + + // --- Skills --- + + const codexCatalog = codexWorkspace.commandCatalog; + if (codexCatalog) { + new Setting(container).setName(t('settings.codex.skills.name')).setHeading(); + + const skillsDesc = container.createDiv({ cls: 'claudian-sp-settings-desc' }); + skillsDesc.createEl('p', { + cls: 'setting-item-description', + text: t('settings.codex.skills.desc'), + }); + + const skillsContainer = container.createDiv({ cls: 'claudian-slash-commands-container' }); + new CodexSkillSettings(skillsContainer, codexCatalog, context.plugin.app); + } + + context.renderHiddenProviderCommandSetting(container, 'codex', { + name: t('settings.codex.skills.hiddenName'), + desc: t('settings.codex.skills.hiddenDesc'), + placeholder: t('settings.codex.skills.hiddenPlaceholder'), + }); + + // --- Subagents --- + + new Setting(container).setName(t('settings.codex.subagents.name')).setHeading(); + + const subagentDesc = container.createDiv({ cls: 'claudian-sp-settings-desc' }); + subagentDesc.createEl('p', { + cls: 'setting-item-description', + text: t('settings.codex.subagents.desc'), + }); + + const subagentContainer = container.createDiv({ cls: 'claudian-slash-commands-container' }); + new CodexSubagentSettings(subagentContainer, codexWorkspace.subagentStorage, context.plugin.app, () => { + void codexWorkspace.refreshAgentMentions?.(); + }); + + // --- MCP Servers --- + + new Setting(container).setName(t('settings.mcpServers.name')).setHeading(); + const mcpNotice = container.createDiv({ cls: 'claudian-mcp-settings-desc' }); + const mcpDesc = mcpNotice.createEl('p', { cls: 'setting-item-description' }); + mcpDesc.appendText(t('settings.codex.mcp.descBeforeCommand')); + mcpDesc.createEl('code').appendText('codex mcp'); + mcpDesc.appendText(t('settings.codex.mcp.descAfterCommand')); + mcpDesc.createEl('a', { + text: t('settings.codex.mcp.learnMore'), + href: 'https://developers.openai.com/codex/mcp', + }); + + // --- Environment --- + + renderEnvironmentSettingsSection({ + container, + plugin: context.plugin, + scope: 'provider:codex', + heading: t('settings.environment'), + name: t('settings.codex.environment.name'), + desc: t('settings.codex.environment.desc'), + placeholder: `OPENAI_API_KEY=your-key\nOPENAI_BASE_URL=https://api.openai.com/v1\nOPENAI_MODEL=${environmentModelPlaceholder}\nCODEX_SANDBOX=workspace-write`, + renderCustomContextLimits: (target) => context.renderCustomContextLimits(target, 'codex'), + }); + }, +}; diff --git a/src/providers/codex/ui/CodexSkillSettings.ts b/src/providers/codex/ui/CodexSkillSettings.ts new file mode 100644 index 0000000..851a78e --- /dev/null +++ b/src/providers/codex/ui/CodexSkillSettings.ts @@ -0,0 +1,276 @@ +import { type App, Modal, Notice, setIcon, Setting } from 'obsidian'; + +import type { ProviderCommandCatalog } from '../../../core/providers/commands/ProviderCommandCatalog'; +import type { ProviderCommandEntry } from '../../../core/providers/commands/ProviderCommandEntry'; +import { t } from '../../../i18n/i18n'; +import { validateCommandName } from '../../../utils/slashCommand'; +import { + CODEX_SKILL_ROOT_OPTIONS, + type CodexSkillRootId, + createCodexSkillPersistenceKey, + parseCodexSkillPersistenceKey, +} from '../storage/CodexSkillStorage'; + +export class CodexSkillModal extends Modal { + private existing: ProviderCommandEntry | null; + private onSave: (entry: ProviderCommandEntry) => Promise; + + private _nameInput!: HTMLInputElement; + private _descInput!: HTMLInputElement; + private _contentArea!: HTMLTextAreaElement; + private _selectedRootId: CodexSkillRootId; + private _triggerSave!: () => Promise; + + constructor( + app: App, + existing: ProviderCommandEntry | null, + onSave: (entry: ProviderCommandEntry) => Promise + ) { + super(app); + this.existing = existing; + this.onSave = onSave; + this._selectedRootId = parseCodexSkillPersistenceKey(existing?.persistenceKey)?.rootId ?? 'vault-codex'; + } + + /** Exposed for unit tests only. */ + getTestInputs() { + return { + nameInput: this._nameInput, + descInput: this._descInput, + contentArea: this._contentArea, + setDirectory: (rootId: CodexSkillRootId) => { this._selectedRootId = rootId; }, + triggerSave: this._triggerSave, + }; + } + + onOpen() { + this.setTitle(this.existing ? t('settings.codexSkills.modal.titleEdit') : t('settings.codexSkills.modal.titleAdd')); + this.modalEl.addClass('claudian-sp-modal'); + + const { contentEl } = this; + + new Setting(contentEl) + .setName(t('settings.codexSkills.modal.directory')) + .setDesc(t('settings.codexSkills.modal.directoryDesc')) + .addDropdown(dropdown => { + for (const opt of CODEX_SKILL_ROOT_OPTIONS) { + dropdown.addOption(opt.id, opt.label); + } + dropdown.setValue(this._selectedRootId); + dropdown.onChange(value => { this._selectedRootId = value as CodexSkillRootId; }); + }); + + new Setting(contentEl) + .setName(t('settings.codexSkills.modal.skillName')) + .setDesc(t('settings.codexSkills.modal.skillNameDesc')) + .addText(text => { + this._nameInput = text.inputEl; + text.setValue(this.existing?.name || '') + .setPlaceholder('Analyze-code'); + }); + + new Setting(contentEl) + .setName(t('settings.codexSkills.modal.description')) + .setDesc(t('settings.codexSkills.modal.descriptionDesc')) + .addText(text => { + this._descInput = text.inputEl; + text.setValue(this.existing?.description || ''); + }); + + new Setting(contentEl) + .setName(t('settings.codexSkills.modal.instructions')) + .setDesc(t('settings.codexSkills.modal.instructionsDesc')); + + const contentArea = contentEl.createEl('textarea', { + cls: 'claudian-sp-content-area', + attr: { rows: '10', placeholder: t('settings.codexSkills.modal.instructionsPlaceholder') }, + }); + contentArea.value = this.existing?.content || ''; + this._contentArea = contentArea; + + const doSave = async () => { + const name = this._nameInput.value.trim(); + const nameError = validateCommandName(name); + if (nameError) { + new Notice(nameError); + return; + } + + const content = this._contentArea.value; + if (!content.trim()) { + new Notice(t('settings.codexSkills.instructionsRequired')); + return; + } + + const entry: ProviderCommandEntry = { + id: this.existing?.id || `codex-skill-${name}`, + providerId: 'codex', + kind: 'skill', + name, + description: this._descInput.value.trim() || undefined, + content, + scope: 'vault', + source: 'user', + isEditable: true, + isDeletable: true, + displayPrefix: '$', + insertPrefix: '$', + persistenceKey: createCodexSkillPersistenceKey({ + rootId: this._selectedRootId, + ...(this.existing?.name ? { currentName: this.existing.name } : {}), + }), + }; + + try { + await this.onSave(entry); + } catch { + new Notice(t('settings.codexSkills.saveFailed')); + return; + } + this.close(); + }; + this._triggerSave = doSave; + + const buttonContainer = contentEl.createDiv({ cls: 'claudian-sp-modal-buttons' }); + + const cancelBtn = buttonContainer.createEl('button', { + text: t('common.cancel'), + cls: 'claudian-cancel-btn', + }); + cancelBtn.addEventListener('click', () => this.close()); + + const saveBtn = buttonContainer.createEl('button', { + text: t('common.save'), + cls: 'claudian-save-btn', + }); + saveBtn.addEventListener('click', () => { + void doSave(); + }); + } + + onClose() { + this.contentEl.empty(); + } +} + +export class CodexSkillSettings { + private containerEl: HTMLElement; + private catalog: ProviderCommandCatalog; + private entries: ProviderCommandEntry[] = []; + private app?: App; + + constructor(containerEl: HTMLElement, catalog: ProviderCommandCatalog, app?: App) { + this.containerEl = containerEl; + this.catalog = catalog; + this.app = app; + void this.render(); + } + + async deleteEntry(entry: ProviderCommandEntry): Promise { + await this.catalog.deleteVaultEntry(entry); + await this.render(); + } + + async refresh(): Promise { + await this.catalog.refresh(); + await this.render(); + } + + async render(): Promise { + this.containerEl.empty(); + + try { + this.entries = await this.catalog.listVaultEntries(); + } catch { + this.entries = []; + } + + const headerEl = this.containerEl.createDiv({ cls: 'claudian-sp-header' }); + headerEl.createSpan({ text: t('settings.codexSkills.header'), cls: 'claudian-sp-label' }); + + const actionsEl = headerEl.createDiv({ cls: 'claudian-sp-header-actions' }); + const refreshBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': t('common.refresh') }, + }); + setIcon(refreshBtn, 'refresh-cw'); + refreshBtn.addEventListener('click', () => { void this.refresh(); }); + + const addBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': t('common.add') }, + }); + setIcon(addBtn, 'plus'); + addBtn.addEventListener('click', () => this.openModal(null)); + + if (this.entries.length === 0) { + const emptyEl = this.containerEl.createDiv({ cls: 'claudian-sp-empty-state' }); + emptyEl.setText(t('settings.codexSkills.noSkills')); + return; + } + + const listEl = this.containerEl.createDiv({ cls: 'claudian-sp-list' }); + for (const entry of this.entries) { + this.renderItem(listEl, entry); + } + } + + private renderItem(listEl: HTMLElement, entry: ProviderCommandEntry): void { + const itemEl = listEl.createDiv({ cls: 'claudian-sp-item' }); + const infoEl = itemEl.createDiv({ cls: 'claudian-sp-info' }); + + const headerRow = infoEl.createDiv({ cls: 'claudian-sp-item-header' }); + const nameEl = headerRow.createSpan({ cls: 'claudian-sp-item-name' }); + nameEl.setText(`$${entry.name}`); + headerRow.createSpan({ text: t('settings.codexSkills.skillBadge'), cls: 'claudian-slash-item-badge' }); + + if (entry.description) { + const descEl = infoEl.createDiv({ cls: 'claudian-sp-item-desc' }); + descEl.setText(entry.description); + } + + const actionsEl = itemEl.createDiv({ cls: 'claudian-sp-item-actions' }); + + if (entry.isEditable) { + const editBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': t('common.edit') }, + }); + setIcon(editBtn, 'pencil'); + editBtn.addEventListener('click', () => this.openModal(entry)); + } + + if (entry.isDeletable) { + const deleteBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn claudian-settings-delete-btn', + attr: { 'aria-label': t('common.delete') }, + }); + setIcon(deleteBtn, 'trash-2'); + deleteBtn.addEventListener('click', () => { + void (async (): Promise => { + try { + await this.deleteEntry(entry); + new Notice(t('settings.codexSkills.deleted', { name: entry.name })); + } catch { + new Notice(t('settings.codexSkills.deleteFailed')); + } + })(); + }); + } + } + + private openModal(existing: ProviderCommandEntry | null): void { + if (!this.app) return; + + const modal = new CodexSkillModal( + this.app, + existing, + async (entry) => { + await this.catalog.saveVaultEntry(entry); + await this.render(); + new Notice(t(existing ? 'settings.codexSkills.updated' : 'settings.codexSkills.created', { name: entry.name })); + } + ); + modal.open(); + } +} diff --git a/src/providers/codex/ui/CodexSubagentSettings.ts b/src/providers/codex/ui/CodexSubagentSettings.ts new file mode 100644 index 0000000..cd4c5ba --- /dev/null +++ b/src/providers/codex/ui/CodexSubagentSettings.ts @@ -0,0 +1,408 @@ +import type { App } from 'obsidian'; +import { Modal, Notice, setIcon, Setting } from 'obsidian'; + +import { t } from '../../../i18n/i18n'; +import { confirmDelete } from '../../../shared/modals/ConfirmModal'; +import type { CodexSubagentStorage } from '../storage/CodexSubagentStorage'; +import type { CodexSubagentDefinition } from '../types/subagent'; + +export function getCodexSubagentReasoningEffortOptions() { + return [ + { value: '', label: t('settings.codexSubagents.reasoningEffort.inherit') }, + { value: 'low', label: t('settings.codexSubagents.reasoningEffort.low') }, + { value: 'medium', label: t('settings.codexSubagents.reasoningEffort.medium') }, + { value: 'high', label: t('settings.codexSubagents.reasoningEffort.high') }, + { value: 'xhigh', label: t('settings.codexSubagents.reasoningEffort.xhigh') }, + { value: 'max', label: 'Max' }, + ] as const; +} + +function getSandboxModeOptions() { + return [ + { value: '', label: t('settings.codexSubagents.sandboxMode.inherit') }, + { value: 'read-only', label: t('settings.codexSubagents.sandboxMode.readOnly') }, + { value: 'danger-full-access', label: t('settings.codexSubagents.sandboxMode.dangerFullAccess') }, + { value: 'workspace-write', label: t('settings.codexSubagents.sandboxMode.workspaceWrite') }, + ] as const; +} + +const MAX_NAME_LENGTH = 64; +const CODEX_AGENT_NAME_PATTERN = /^[a-z0-9_-]+$/; +const CODEX_NICKNAME_PATTERN = /^[A-Za-z0-9 _-]+$/; + +export function validateCodexSubagentName(name: string): string | null { + if (!name) return t('settings.codexSubagents.validation.nameRequired'); + if (name.length > MAX_NAME_LENGTH) { + return t('settings.codexSubagents.validation.nameTooLong', { count: MAX_NAME_LENGTH }); + } + if (!CODEX_AGENT_NAME_PATTERN.test(name)) return t('settings.codexSubagents.validation.nameInvalid'); + return null; +} + +export function validateCodexNicknameCandidates(candidates: string[]): string | null { + const normalized = candidates.map(candidate => candidate.trim()).filter(Boolean); + if (normalized.length === 0) return null; + + const seen = new Set(); + for (const candidate of normalized) { + if (!CODEX_NICKNAME_PATTERN.test(candidate)) { + return t('settings.codexSubagents.validation.nicknameInvalid'); + } + + const dedupeKey = candidate.toLowerCase(); + if (seen.has(dedupeKey)) { + return t('settings.codexSubagents.validation.nicknameDuplicate'); + } + seen.add(dedupeKey); + } + + return null; +} + +class CodexSubagentModal extends Modal { + private existing: CodexSubagentDefinition | null; + private allAgents: CodexSubagentDefinition[]; + private onSave: (agent: CodexSubagentDefinition) => Promise; + + private _nameInput!: HTMLInputElement; + private _descInput!: HTMLInputElement; + private _instructionsArea!: HTMLTextAreaElement; + private _nicknamesInput!: HTMLInputElement; + private _modelInput!: HTMLInputElement; + private _reasoningEffort = ''; + private _sandboxMode = ''; + private _triggerSave!: () => Promise; + + constructor( + app: App, + existing: CodexSubagentDefinition | null, + allAgents: CodexSubagentDefinition[], + onSave: (agent: CodexSubagentDefinition) => Promise, + ) { + super(app); + this.existing = existing; + this.allAgents = allAgents; + this.onSave = onSave; + this._reasoningEffort = existing?.modelReasoningEffort ?? ''; + this._sandboxMode = existing?.sandboxMode ?? ''; + } + + getTestInputs() { + return { + nameInput: this._nameInput, + descInput: this._descInput, + instructionsArea: this._instructionsArea, + nicknamesInput: this._nicknamesInput, + modelInput: this._modelInput, + setReasoningEffort: (v: string) => { this._reasoningEffort = v; }, + setSandboxMode: (v: string) => { this._sandboxMode = v; }, + triggerSave: this._triggerSave, + }; + } + + onOpen() { + this.setTitle(this.existing ? t('settings.codexSubagents.modal.titleEdit') : t('settings.codexSubagents.modal.titleAdd')); + this.modalEl.addClass('claudian-sp-modal'); + + const { contentEl } = this; + + new Setting(contentEl) + .setName(t('settings.subagents.modal.name')) + .setDesc(t('settings.codexSubagents.modal.nameDesc')) + .addText(text => { + this._nameInput = text.inputEl; + text.setValue(this.existing?.name ?? '') + .setPlaceholder(t('settings.codexSubagents.modal.namePlaceholder')); + }); + + new Setting(contentEl) + .setName(t('settings.subagents.modal.description')) + .setDesc(t('settings.codexSubagents.modal.descriptionDesc')) + .addText(text => { + this._descInput = text.inputEl; + text.setValue(this.existing?.description ?? '') + .setPlaceholder('Reviews code for correctness and security'); + }); + + // Advanced options + const details = contentEl.createEl('details', { cls: 'claudian-sp-advanced-section' }); + details.createEl('summary', { + text: t('settings.subagents.modal.advancedOptions'), + cls: 'claudian-sp-advanced-summary', + }); + if ( + this.existing?.model || + this.existing?.modelReasoningEffort || + this.existing?.sandboxMode || + this.existing?.nicknameCandidates?.length + ) { + details.open = true; + } + + new Setting(details) + .setName(t('settings.subagents.modal.model')) + .setDesc(t('settings.codexSubagents.modal.modelDesc')) + .addText(text => { + this._modelInput = text.inputEl; + text.setValue(this.existing?.model ?? '') + .setPlaceholder('Model ID'); + }); + + new Setting(details) + .setName(t('settings.codexSubagents.reasoningEffort.name')) + .setDesc(t('settings.codexSubagents.reasoningEffort.desc')) + .addDropdown(dropdown => { + for (const opt of getCodexSubagentReasoningEffortOptions()) { + dropdown.addOption(opt.value, opt.label); + } + dropdown.setValue(this._reasoningEffort); + dropdown.onChange(v => { this._reasoningEffort = v; }); + }); + + new Setting(details) + .setName(t('settings.codexSubagents.sandboxMode.name')) + .setDesc(t('settings.codexSubagents.sandboxMode.desc')) + .addDropdown(dropdown => { + for (const opt of getSandboxModeOptions()) { + dropdown.addOption(opt.value, opt.label); + } + dropdown.setValue(this._sandboxMode); + dropdown.onChange(v => { this._sandboxMode = v; }); + }); + + new Setting(details) + .setName(t('settings.codexSubagents.nicknameCandidates.name')) + .setDesc(t('settings.codexSubagents.nicknameCandidates.desc')) + .addText(text => { + this._nicknamesInput = text.inputEl; + text.setValue(this.existing?.nicknameCandidates?.join(', ') ?? ''); + }); + + // Developer instructions + new Setting(contentEl) + .setName(t('settings.codexSubagents.developerInstructions.name')) + .setDesc(t('settings.codexSubagents.developerInstructions.desc')); + + const instructionsArea = contentEl.createEl('textarea', { + cls: 'claudian-sp-content-area', + attr: { + rows: '10', + placeholder: t('settings.codexSubagents.developerInstructions.placeholder'), + }, + }); + instructionsArea.value = this.existing?.developerInstructions ?? ''; + this._instructionsArea = instructionsArea; + + // Buttons + const doSave = async () => { + const name = this._nameInput.value.trim(); + const nameError = validateCodexSubagentName(name); + if (nameError) { + new Notice(nameError); + return; + } + + const description = this._descInput.value.trim(); + if (!description) { + new Notice(t('settings.subagents.descriptionRequired')); + return; + } + + const developerInstructions = this._instructionsArea.value; + if (!developerInstructions.trim()) { + new Notice(t('settings.codexSubagents.developerInstructions.required')); + return; + } + + const nicknameCandidates = this._nicknamesInput.value + .split(',') + .map(s => s.trim()) + .filter(Boolean); + const nicknameError = validateCodexNicknameCandidates(nicknameCandidates); + if (nicknameError) { + new Notice(nicknameError); + return; + } + + const duplicate = this.allAgents.find( + a => a.name.toLowerCase() === name.toLowerCase() && + a.persistenceKey !== this.existing?.persistenceKey, + ); + if (duplicate) { + new Notice(t('settings.subagents.duplicateName', { name })); + return; + } + + const agent: CodexSubagentDefinition = { + name, + description, + developerInstructions, + nicknameCandidates: nicknameCandidates.length > 0 ? nicknameCandidates : undefined, + model: this._modelInput.value.trim() || undefined, + modelReasoningEffort: this._reasoningEffort || undefined, + sandboxMode: this._sandboxMode || undefined, + persistenceKey: this.existing?.persistenceKey, + extraFields: this.existing?.extraFields, + }; + + try { + await this.onSave(agent); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + new Notice(t('settings.subagents.saveFailed', { message })); + return; + } + this.close(); + }; + this._triggerSave = doSave; + + const buttonContainer = contentEl.createDiv({ cls: 'claudian-sp-modal-buttons' }); + + const cancelBtn = buttonContainer.createEl('button', { + text: t('common.cancel'), + cls: 'claudian-cancel-btn', + }); + cancelBtn.addEventListener('click', () => this.close()); + + const saveBtn = buttonContainer.createEl('button', { + text: t('common.save'), + cls: 'claudian-save-btn', + }); + saveBtn.addEventListener('click', () => { + void doSave(); + }); + } + + onClose() { + this.contentEl.empty(); + } +} + +export class CodexSubagentSettings { + private containerEl: HTMLElement; + private storage: CodexSubagentStorage; + private agents: CodexSubagentDefinition[] = []; + private app?: App; + private onChanged?: () => void; + + constructor(containerEl: HTMLElement, storage: CodexSubagentStorage, app?: App, onChanged?: () => void) { + this.containerEl = containerEl; + this.storage = storage; + this.app = app; + this.onChanged = onChanged; + void this.render(); + } + + async render(): Promise { + this.containerEl.empty(); + + try { + this.agents = await this.storage.loadAll(); + } catch { + this.agents = []; + } + + const headerEl = this.containerEl.createDiv({ cls: 'claudian-sp-header' }); + headerEl.createSpan({ text: t('settings.codexSubagents.header'), cls: 'claudian-sp-label' }); + + const actionsEl = headerEl.createDiv({ cls: 'claudian-sp-header-actions' }); + + const refreshBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': t('common.refresh') }, + }); + setIcon(refreshBtn, 'refresh-cw'); + refreshBtn.addEventListener('click', () => { void this.render(); }); + + const addBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': t('common.add') }, + }); + setIcon(addBtn, 'plus'); + addBtn.addEventListener('click', () => this.openModal(null)); + + if (this.agents.length === 0) { + const emptyEl = this.containerEl.createDiv({ cls: 'claudian-sp-empty-state' }); + emptyEl.setText(t('settings.codexSubagents.noAgents')); + return; + } + + const listEl = this.containerEl.createDiv({ cls: 'claudian-sp-list' }); + for (const agent of this.agents) { + this.renderItem(listEl, agent); + } + } + + private renderItem(listEl: HTMLElement, agent: CodexSubagentDefinition): void { + const itemEl = listEl.createDiv({ cls: 'claudian-sp-item' }); + const infoEl = itemEl.createDiv({ cls: 'claudian-sp-info' }); + + const headerRow = infoEl.createDiv({ cls: 'claudian-sp-item-header' }); + const nameEl = headerRow.createSpan({ cls: 'claudian-sp-item-name' }); + nameEl.setText(agent.name); + + if (agent.model) { + headerRow.createSpan({ text: agent.model, cls: 'claudian-slash-item-badge' }); + } + + if (agent.description) { + const descEl = infoEl.createDiv({ cls: 'claudian-sp-item-desc' }); + descEl.setText(agent.description); + } + + const actionsEl = itemEl.createDiv({ cls: 'claudian-sp-item-actions' }); + + const editBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': t('common.edit') }, + }); + setIcon(editBtn, 'pencil'); + editBtn.addEventListener('click', () => this.openModal(agent)); + + const deleteBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn claudian-settings-delete-btn', + attr: { 'aria-label': t('common.delete') }, + }); + setIcon(deleteBtn, 'trash-2'); + deleteBtn.addEventListener('click', () => { + void (async (): Promise => { + if (!this.app) return; + const confirmed = await confirmDelete( + this.app, + t('settings.subagents.deleteConfirm', { name: agent.name }), + ); + if (!confirmed) return; + try { + await this.storage.delete(agent); + await this.render(); + this.onChanged?.(); + new Notice(t('settings.subagents.deleted', { name: agent.name })); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + new Notice(t('settings.subagents.deleteFailed', { message })); + } + })(); + }); + } + + private openModal(existing: CodexSubagentDefinition | null): void { + if (!this.app) return; + + const modal = new CodexSubagentModal( + this.app, + existing, + this.agents, + async (agent) => { + await this.storage.save(agent, existing); + await this.render(); + this.onChanged?.(); + new Notice( + existing + ? t('settings.subagents.updated', { name: agent.name }) + : t('settings.subagents.created', { name: agent.name }), + ); + }, + ); + modal.open(); + } +} diff --git a/src/providers/defaultProviderConfigs.ts b/src/providers/defaultProviderConfigs.ts new file mode 100644 index 0000000..187cca1 --- /dev/null +++ b/src/providers/defaultProviderConfigs.ts @@ -0,0 +1,14 @@ +import type { ProviderConfigMap } from '../core/types/settings'; +import { DEFAULT_CLAUDE_PROVIDER_SETTINGS } from './claude/settings'; +import { DEFAULT_CODEX_PROVIDER_CONFIG } from './codex/settings'; +import { DEFAULT_OPENCODE_PROVIDER_SETTINGS } from './opencode/settings'; +import { DEFAULT_PI_PROVIDER_SETTINGS } from './pi/settings'; + +export function getBuiltInProviderDefaultConfigs(): ProviderConfigMap { + return { + claude: { ...DEFAULT_CLAUDE_PROVIDER_SETTINGS }, + codex: { ...DEFAULT_CODEX_PROVIDER_CONFIG }, + opencode: { ...DEFAULT_OPENCODE_PROVIDER_SETTINGS }, + pi: { ...DEFAULT_PI_PROVIDER_SETTINGS }, + }; +} diff --git a/src/providers/index.ts b/src/providers/index.ts new file mode 100644 index 0000000..5c2324c --- /dev/null +++ b/src/providers/index.ts @@ -0,0 +1,29 @@ +import { ProviderRegistry } from '../core/providers/ProviderRegistry'; +import { ProviderWorkspaceRegistry } from '../core/providers/ProviderWorkspaceRegistry'; +import { claudeProviderRegistration } from './claude/registration'; +import { codexProviderRegistration } from './codex/registration'; +import { opencodeProviderRegistration } from './opencode/registration'; +import { piProviderRegistration } from './pi/registration'; + +let builtInProvidersRegistered = false; + +export const BUILT_IN_PROVIDER_MODULES = [ + claudeProviderRegistration, + codexProviderRegistration, + opencodeProviderRegistration, + piProviderRegistration, +] as const; + +export function registerBuiltInProviders(): void { + if (builtInProvidersRegistered) { + return; + } + + for (const providerModule of BUILT_IN_PROVIDER_MODULES) { + ProviderRegistry.register(providerModule.id, providerModule); + ProviderWorkspaceRegistry.register(providerModule.id, providerModule.workspace); + } + builtInProvidersRegistered = true; +} + +registerBuiltInProviders(); diff --git a/src/providers/opencode/AGENTS.md b/src/providers/opencode/AGENTS.md new file mode 100644 index 0000000..ce10e83 --- /dev/null +++ b/src/providers/opencode/AGENTS.md @@ -0,0 +1,37 @@ +# OpenCode Provider + +`src/providers/opencode/` adapts OpenCode through Agent Client Protocol over an `opencode acp` subprocess. + +## Ownership + +- Runtime process management, ACP transport, prompt encoding, stream normalization, SQLite history hydration, model/mode discovery, command discovery, agent storage, settings UI, and OpenCode-specific settings reconciliation live here. +- Shared code should consume OpenCode behavior through `ChatRuntime`, provider capabilities, and workspace-service contracts. + +## Protocol Rules + +- Live output comes from ACP session notifications and is normalized through `AcpSessionUpdateNormalizer` plus OpenCode tool normalization. +- History hydration reads OpenCode's native SQLite database. Never mutate OpenCode native history from Claudian. +- `providerState.databasePath` preserves the database used for a conversation. Keep it when building session updates. +- `sessionCwds` maps ACP session IDs to vault working directories for read/write request path resolution. + +## Launch and Settings + +- `prepareOpencodeLaunchArtifacts()` writes managed config and system prompt files under `.claudian/opencode/`. +- Preserve user OpenCode config by loading `OPENCODE_CONFIG` and layering Claudian-managed agent config over it. +- Environment keys that affect config or data location invalidate OpenCode sessions: `OPENCODE_CONFIG`, `OPENCODE_DB`, `OPENCODE_DISABLE_PROJECT_CONFIG`, and `XDG_DATA_HOME`. +- OpenCode mode IDs map to shared permission modes. Keep this mapping in `modes.ts`, not feature code. + +## Commands and Agents + +- Runtime commands are read from the OpenCode session and exposed through `OpencodeCommandCatalog`. +- OpenCode runtime commands are not editable or deletable from Claudian. +- Command discovery warmup for blank tabs should use the isolated metadata database, not a persisted conversation session. +- Do not let command discovery create a real session for history-backed conversations that have messages but no provider session yet. +- OpenCode agent definitions are stored under `.opencode/agent` and `.opencode/agents`; keep parsing and serialization in `OpencodeAgentStorage`. + +## Gotchas + +- `OpencodeAuxQueryRunner` owns its own process and session. It is independent from the chat runtime. +- File read/write permission requests may target paths outside the session working directory. Preserve the existing approval mapping and path checks. +- SQLite reading uses `OpencodeSqliteReader` fallbacks because runtime environments may not expose the same SQLite API. +- OpenCode metadata warmup intentionally uses an in-memory or metadata database to avoid binding tab state to discovery work. diff --git a/src/providers/opencode/CLAUDE.md b/src/providers/opencode/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/src/providers/opencode/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/providers/opencode/agents/OpencodeAgentMentionProvider.ts b/src/providers/opencode/agents/OpencodeAgentMentionProvider.ts new file mode 100644 index 0000000..3806328 --- /dev/null +++ b/src/providers/opencode/agents/OpencodeAgentMentionProvider.ts @@ -0,0 +1,42 @@ +import type { AgentMentionProvider } from '../../../core/providers/types'; +import type { OpencodeAgentStorage } from '../storage/OpencodeAgentStorage'; +import type { OpencodeAgentDefinition } from '../types/agent'; + +export class OpencodeAgentMentionProvider implements AgentMentionProvider { + private agents: OpencodeAgentDefinition[] = []; + + constructor(private storage: OpencodeAgentStorage) {} + + async loadAgents(): Promise { + this.agents = await this.storage.loadAll(); + } + + searchAgents(query: string): Array<{ + id: string; + name: string; + description?: string; + source: 'plugin' | 'vault' | 'global' | 'builtin'; + }> { + const q = query.toLowerCase(); + return this.agents + .filter((agent) => isMentionableSubagent(agent)) + .filter((agent) => ( + agent.name.toLowerCase().includes(q) || + agent.description.toLowerCase().includes(q) + )) + .map((agent) => ({ + id: agent.name, + name: agent.name, + description: agent.description, + source: 'vault' as const, + })); + } +} + +function isMentionableSubagent(agent: OpencodeAgentDefinition): boolean { + if (agent.hidden || agent.disable) { + return false; + } + + return agent.mode === 'subagent'; +} diff --git a/src/providers/opencode/app/OpencodeRuntimeCommandLoader.ts b/src/providers/opencode/app/OpencodeRuntimeCommandLoader.ts new file mode 100644 index 0000000..44467c8 --- /dev/null +++ b/src/providers/opencode/app/OpencodeRuntimeCommandLoader.ts @@ -0,0 +1,67 @@ +import type { + ProviderRuntimeCommandLoader, + ProviderRuntimeCommandLoaderContext, +} from '../../../core/providers/types'; +import { OpencodeChatRuntime } from '../runtime/OpencodeChatRuntime'; +import { getOpencodeProviderSettings } from '../settings'; + +const OPENCODE_METADATA_WARMUP_DB = ':memory:'; + +export class OpencodeRuntimeCommandLoader implements ProviderRuntimeCommandLoader { + isAvailable(settings: Record): boolean { + return getOpencodeProviderSettings(settings).enabled; + } + + async loadCommands(context: ProviderRuntimeCommandLoaderContext) { + const shouldWarmBlankSession = context.allowSessionCreation === true + && !context.conversation?.sessionId; + const shouldWarmPreSessionConversation = !!context.conversation + && !context.conversation.sessionId + && context.conversation.messages.length > 0; + + if ( + !context.runtime + && !context.conversation?.sessionId + && !shouldWarmBlankSession + && !shouldWarmPreSessionConversation + ) { + return []; + } + + // Rebinding an already-live tab runtime to a history-backed conversation with + // no session id must stay cold until the first send. If command discovery + // creates a real session on that bound runtime, the first turn can skip + // history bootstrap. Keep this warmup isolated instead. + const canReuseRuntime = context.runtime?.providerId === 'opencode' + && !shouldWarmPreSessionConversation; + const runtime = canReuseRuntime + ? context.runtime! + : new OpencodeChatRuntime(context.plugin); + + try { + if (context.conversation) { + runtime.syncConversationState(context.conversation, context.externalContextPaths); + } else if (shouldWarmBlankSession) { + // Blank-tab warmup uses an isolated in-memory session to fetch metadata + // without binding a persisted OpenCode session to the tab. + runtime.syncConversationState({ + providerState: { databasePath: OPENCODE_METADATA_WARMUP_DB }, + sessionId: null, + }); + } + + const ready = await runtime.ensureReady({ + allowSessionCreation: shouldWarmBlankSession || shouldWarmPreSessionConversation, + }); + if (!ready) { + return []; + } + + return await runtime.getSupportedCommands(); + } finally { + if (runtime !== context.runtime) { + runtime.cleanup(); + } + } + } +} diff --git a/src/providers/opencode/app/OpencodeWorkspaceServices.ts b/src/providers/opencode/app/OpencodeWorkspaceServices.ts new file mode 100644 index 0000000..dcbaad4 --- /dev/null +++ b/src/providers/opencode/app/OpencodeWorkspaceServices.ts @@ -0,0 +1,55 @@ +import type { ProviderCommandCatalog } from '../../../core/providers/commands/ProviderCommandCatalog'; +import { ProviderWorkspaceRegistry } from '../../../core/providers/ProviderWorkspaceRegistry'; +import type { + ProviderTabWarmupPolicy, + ProviderWorkspaceRegistration, + ProviderWorkspaceServices, +} from '../../../core/providers/types'; +import type { VaultFileAdapter } from '../../../core/storage/VaultFileAdapter'; +import { OpencodeAgentMentionProvider } from '../agents/OpencodeAgentMentionProvider'; +import { OpencodeCommandCatalog } from '../commands/OpencodeCommandCatalog'; +import { OpencodeCliResolver } from '../runtime/OpencodeCliResolver'; +import { OpencodeAgentStorage } from '../storage/OpencodeAgentStorage'; +import { opencodeSettingsTabRenderer } from '../ui/OpencodeSettingsTab'; +import { OpencodeRuntimeCommandLoader } from './OpencodeRuntimeCommandLoader'; + +export interface OpencodeWorkspaceServices extends ProviderWorkspaceServices { + agentStorage: OpencodeAgentStorage; + agentMentionProvider: OpencodeAgentMentionProvider; + commandCatalog: ProviderCommandCatalog; +} + +const opencodeTabWarmupPolicy: ProviderTabWarmupPolicy = { + resolveMode() { + return 'commands'; + }, +}; + +export async function createOpencodeWorkspaceServices( + vaultAdapter: VaultFileAdapter, +): Promise { + const agentStorage = new OpencodeAgentStorage(vaultAdapter); + const agentMentionProvider = new OpencodeAgentMentionProvider(agentStorage); + await agentMentionProvider.loadAgents(); + + return { + agentStorage, + agentMentionProvider, + commandCatalog: new OpencodeCommandCatalog(), + cliResolver: new OpencodeCliResolver(), + runtimeCommandLoader: new OpencodeRuntimeCommandLoader(), + settingsTabRenderer: opencodeSettingsTabRenderer, + tabWarmupPolicy: opencodeTabWarmupPolicy, + refreshAgentMentions: async () => { + await agentMentionProvider.loadAgents(); + }, + }; +} + +export const opencodeWorkspaceRegistration: ProviderWorkspaceRegistration = { + initialize: async ({ vaultAdapter }) => createOpencodeWorkspaceServices(vaultAdapter), +}; + +export function maybeGetOpencodeWorkspaceServices(): OpencodeWorkspaceServices | null { + return ProviderWorkspaceRegistry.getServices('opencode') as OpencodeWorkspaceServices | null; +} diff --git a/src/providers/opencode/auxiliary/OpencodeInlineEditService.ts b/src/providers/opencode/auxiliary/OpencodeInlineEditService.ts new file mode 100644 index 0000000..8a0bf3e --- /dev/null +++ b/src/providers/opencode/auxiliary/OpencodeInlineEditService.ts @@ -0,0 +1,13 @@ +import { QueryBackedInlineEditService } from '../../../core/auxiliary/QueryBackedInlineEditService'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { OpencodeAuxQueryRunner } from '../runtime/OpencodeAuxQueryRunner'; + +export class OpencodeInlineEditService extends QueryBackedInlineEditService { + constructor(plugin: ProviderHost) { + super(new OpencodeAuxQueryRunner(plugin, { + agentProfile: 'readonly', + artifactPurpose: 'inline', + allowReadTextFile: true, + })); + } +} diff --git a/src/providers/opencode/auxiliary/OpencodeInstructionRefineService.ts b/src/providers/opencode/auxiliary/OpencodeInstructionRefineService.ts new file mode 100644 index 0000000..3f00967 --- /dev/null +++ b/src/providers/opencode/auxiliary/OpencodeInstructionRefineService.ts @@ -0,0 +1,12 @@ +import { QueryBackedInstructionRefineService } from '../../../core/auxiliary/QueryBackedInstructionRefineService'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { OpencodeAuxQueryRunner } from '../runtime/OpencodeAuxQueryRunner'; + +export class OpencodeInstructionRefineService extends QueryBackedInstructionRefineService { + constructor(plugin: ProviderHost) { + super(new OpencodeAuxQueryRunner(plugin, { + agentProfile: 'passive', + artifactPurpose: 'instructions', + })); + } +} diff --git a/src/providers/opencode/auxiliary/OpencodeTaskResultInterpreter.ts b/src/providers/opencode/auxiliary/OpencodeTaskResultInterpreter.ts new file mode 100644 index 0000000..a2f908d --- /dev/null +++ b/src/providers/opencode/auxiliary/OpencodeTaskResultInterpreter.ts @@ -0,0 +1,29 @@ +import type { + ProviderTaskResultInterpreter, + ProviderTaskTerminalStatus, +} from '../../../core/providers/types'; + +export class OpencodeTaskResultInterpreter implements ProviderTaskResultInterpreter { + hasAsyncLaunchMarker(_toolUseResult: unknown): boolean { + return false; + } + + extractAgentId(_toolUseResult: unknown): string | null { + return null; + } + + extractStructuredResult(_toolUseResult: unknown): string | null { + return null; + } + + resolveTerminalStatus( + _toolUseResult: unknown, + fallbackStatus: ProviderTaskTerminalStatus, + ): ProviderTaskTerminalStatus { + return fallbackStatus; + } + + extractTagValue(_payload: string, _tagName: string): string | null { + return null; + } +} diff --git a/src/providers/opencode/auxiliary/OpencodeTitleGenerationService.ts b/src/providers/opencode/auxiliary/OpencodeTitleGenerationService.ts new file mode 100644 index 0000000..fc8d26d --- /dev/null +++ b/src/providers/opencode/auxiliary/OpencodeTitleGenerationService.ts @@ -0,0 +1,27 @@ +import { QueryBackedTitleGenerationService } from '../../../core/auxiliary/QueryBackedTitleGenerationService'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { decodeOpencodeModelId } from '../models'; +import { OpencodeAuxQueryRunner } from '../runtime/OpencodeAuxQueryRunner'; +import { opencodeChatUIConfig } from '../ui/OpencodeChatUIConfig'; + +export class OpencodeTitleGenerationService extends QueryBackedTitleGenerationService { + constructor(plugin: ProviderHost) { + super({ + createRunner: () => new OpencodeAuxQueryRunner(plugin, { + agentProfile: 'passive', + artifactPurpose: 'title-gen', + }), + resolveModel: () => { + const settings = plugin.settings as unknown as Record; + const titleModel = typeof settings.titleGenerationModel === 'string' + ? settings.titleGenerationModel + : ''; + if (!opencodeChatUIConfig.ownsModel(titleModel, settings)) { + return undefined; + } + + return decodeOpencodeModelId(titleModel) ?? undefined; + }, + }); + } +} diff --git a/src/providers/opencode/capabilities.ts b/src/providers/opencode/capabilities.ts new file mode 100644 index 0000000..fe7013d --- /dev/null +++ b/src/providers/opencode/capabilities.ts @@ -0,0 +1,16 @@ +import type { ProviderCapabilities } from '../../core/providers/types'; + +export const OPENCODE_PROVIDER_CAPABILITIES: Readonly = Object.freeze({ + providerId: 'opencode', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: false, + supportsFork: false, + supportsProviderCommands: true, + supportsImageAttachments: true, + supportsInstructionMode: true, + supportsMcpTools: false, + supportsTurnSteer: false, + reasoningControl: 'effort', +}); diff --git a/src/providers/opencode/commands/OpencodeCommandCatalog.ts b/src/providers/opencode/commands/OpencodeCommandCatalog.ts new file mode 100644 index 0000000..a30a208 --- /dev/null +++ b/src/providers/opencode/commands/OpencodeCommandCatalog.ts @@ -0,0 +1,92 @@ +import type { + ProviderCommandCatalog, + ProviderCommandDropdownConfig, +} from '../../../core/providers/commands/ProviderCommandCatalog'; +import type { ProviderCommandEntry } from '../../../core/providers/commands/ProviderCommandEntry'; +import type { SlashCommand } from '../../../core/types'; + +function slashCommandToEntry(command: SlashCommand): ProviderCommandEntry { + return { + id: command.id, + providerId: 'opencode', + kind: 'command', + name: command.name, + description: command.description, + content: command.content, + argumentHint: command.argumentHint, + allowedTools: command.allowedTools, + model: command.model, + disableModelInvocation: command.disableModelInvocation, + userInvocable: command.userInvocable, + context: command.context, + agent: command.agent, + hooks: command.hooks, + scope: 'runtime', + source: command.source ?? 'sdk', + isEditable: false, + isDeletable: false, + displayPrefix: '/', + insertPrefix: '/', + }; +} + +function dedupeRuntimeCommands(commands: SlashCommand[]): SlashCommand[] { + const deduped: SlashCommand[] = []; + const seen = new Set(); + + for (const command of commands) { + const normalizedName = command.name.trim().replace(/^\/+/, ''); + if (!normalizedName) { + continue; + } + + const key = normalizedName.toLowerCase(); + if (seen.has(key)) { + continue; + } + + seen.add(key); + deduped.push({ + ...command, + name: normalizedName, + }); + } + + return deduped; +} + +export class OpencodeCommandCatalog implements ProviderCommandCatalog { + private runtimeCommands: SlashCommand[] = []; + + setRuntimeCommands(commands: SlashCommand[]): void { + this.runtimeCommands = dedupeRuntimeCommands(commands); + } + + async listDropdownEntries(_context: { includeBuiltIns: boolean }): Promise { + return this.runtimeCommands.map(slashCommandToEntry); + } + + async listVaultEntries(): Promise { + return []; + } + + async saveVaultEntry(_entry: ProviderCommandEntry): Promise { + throw new Error('OpenCode runtime commands are not editable from Claudian.'); + } + + async deleteVaultEntry(_entry: ProviderCommandEntry): Promise { + throw new Error('OpenCode runtime commands are not deletable from Claudian.'); + } + + getDropdownConfig(): ProviderCommandDropdownConfig { + return { + providerId: 'opencode', + triggerChars: ['/'], + builtInPrefix: '/', + skillPrefix: '/', + commandPrefix: '/', + }; + } + + async refresh(): Promise {} +} diff --git a/src/providers/opencode/discoveryState.ts b/src/providers/opencode/discoveryState.ts new file mode 100644 index 0000000..f1969db --- /dev/null +++ b/src/providers/opencode/discoveryState.ts @@ -0,0 +1,135 @@ +import { sameDiscoveredModels, sameModes, sameThinkingOptionsByModel } from './internal/compareCollections'; +import { + normalizeOpencodeDiscoveredModels, + normalizeOpencodeThinkingOptionsByModel, + type OpencodeDiscoveredModel, + type OpencodeThinkingOptionsByModel, +} from './models'; +import { + normalizeOpencodeAvailableModes, + type OpencodeMode, +} from './modes'; + +const OPENCODE_DISCOVERY_STATE = Symbol('opencodeDiscoveryState'); + +interface OpencodeDiscoveryState { + availableModes: OpencodeMode[]; + discoveredModels: OpencodeDiscoveredModel[]; + thinkingOptionsByModel: OpencodeThinkingOptionsByModel; +} + +type SettingsBag = Record; + +function ensureDiscoveryState(settings: Record): OpencodeDiscoveryState { + const bag = settings as SettingsBag; + const existing = bag[OPENCODE_DISCOVERY_STATE]; + if (existing && typeof existing === 'object' && !Array.isArray(existing)) { + const state = existing as Partial; + state.availableModes ??= []; + state.discoveredModels ??= []; + state.thinkingOptionsByModel ??= {}; + return state as OpencodeDiscoveryState; + } + + const next: OpencodeDiscoveryState = { + availableModes: [], + discoveredModels: [], + thinkingOptionsByModel: {}, + }; + bag[OPENCODE_DISCOVERY_STATE] = next; + return next; +} + +function cloneModes(modes: OpencodeMode[]): OpencodeMode[] { + return modes.map((mode) => ({ ...mode })); +} + +function cloneDiscoveredModels(models: OpencodeDiscoveredModel[]): OpencodeDiscoveredModel[] { + return models.map((model) => ({ ...model })); +} + +function cloneThinkingOptionsByModel( + optionsByModel: OpencodeThinkingOptionsByModel, +): OpencodeThinkingOptionsByModel { + return Object.fromEntries( + Object.entries(optionsByModel).map(([rawId, options]) => [ + rawId, + options.map((option) => ({ ...option })), + ]), + ); +} + +export function getOpencodeDiscoveryState(settings: Record): OpencodeDiscoveryState { + const state = ensureDiscoveryState(settings); + return { + availableModes: cloneModes(state.availableModes), + discoveredModels: cloneDiscoveredModels(state.discoveredModels), + thinkingOptionsByModel: cloneThinkingOptionsByModel(state.thinkingOptionsByModel), + }; +} + +export function updateOpencodeDiscoveryState( + settings: Record, + updates: Partial, +): boolean { + const state = ensureDiscoveryState(settings); + const nextAvailableModes = 'availableModes' in updates + ? normalizeOpencodeAvailableModes(updates.availableModes) + : state.availableModes; + const nextDiscoveredModels = 'discoveredModels' in updates + ? normalizeOpencodeDiscoveredModels(updates.discoveredModels) + : state.discoveredModels; + const nextThinkingOptionsByModel = 'thinkingOptionsByModel' in updates + ? normalizeOpencodeThinkingOptionsByModel(updates.thinkingOptionsByModel, nextDiscoveredModels) + : state.thinkingOptionsByModel; + const changed = !sameModes(state.availableModes, nextAvailableModes) + || !sameDiscoveredModels(state.discoveredModels, nextDiscoveredModels) + || !sameThinkingOptionsByModel(state.thinkingOptionsByModel, nextThinkingOptionsByModel); + + if (!changed) { + return false; + } + + state.availableModes = cloneModes(nextAvailableModes); + state.discoveredModels = cloneDiscoveredModels(nextDiscoveredModels); + state.thinkingOptionsByModel = cloneThinkingOptionsByModel(nextThinkingOptionsByModel); + return true; +} + +export function clearOpencodeDiscoveryState(settings: Record): boolean { + const state = ensureDiscoveryState(settings); + if ( + state.availableModes.length === 0 + && state.discoveredModels.length === 0 + && Object.keys(state.thinkingOptionsByModel).length === 0 + ) { + return false; + } + + state.availableModes = []; + state.discoveredModels = []; + state.thinkingOptionsByModel = {}; + return true; +} + +export function seedOpencodeDiscoveryStateFromLegacyConfig( + settings: Record, + legacyConfig: Record, +): boolean { + const state = ensureDiscoveryState(settings); + const nextAvailableModes = state.availableModes.length > 0 + ? state.availableModes + : normalizeOpencodeAvailableModes(legacyConfig.availableModes); + const nextDiscoveredModels = state.discoveredModels.length > 0 + ? state.discoveredModels + : normalizeOpencodeDiscoveredModels(legacyConfig.discoveredModels); + const nextThinkingOptionsByModel = Object.keys(state.thinkingOptionsByModel).length > 0 + ? state.thinkingOptionsByModel + : normalizeOpencodeThinkingOptionsByModel(legacyConfig.thinkingOptionsByModel, nextDiscoveredModels); + + return updateOpencodeDiscoveryState(settings, { + availableModes: nextAvailableModes, + discoveredModels: nextDiscoveredModels, + thinkingOptionsByModel: nextThinkingOptionsByModel, + }); +} diff --git a/src/providers/opencode/env/OpencodeSettingsReconciler.ts b/src/providers/opencode/env/OpencodeSettingsReconciler.ts new file mode 100644 index 0000000..0e6288e --- /dev/null +++ b/src/providers/opencode/env/OpencodeSettingsReconciler.ts @@ -0,0 +1,178 @@ +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import type { ProviderSettingsReconciler } from '../../../core/providers/types'; +import type { Conversation } from '../../../core/types'; +import { parseEnvironmentVariables } from '../../../utils/env'; +import { clearOpencodeDiscoveryState } from '../discoveryState'; +import { sameStringList, sameStringMap } from '../internal/compareCollections'; +import { ensureProviderProjectionMap } from '../internal/providerProjection'; +import { + decodeOpencodeModelId, + encodeOpencodeModelId, + extractOpencodeModelVariantValue, + isOpencodeModelSelectionId, + OPENCODE_DEFAULT_THINKING_LEVEL, + resolveOpencodeBaseModelRawId, +} from '../models'; +import { + getOpencodeProviderSettings, + hasLegacyOpencodeDiscoveryFields, + normalizeOpencodePreferredThinkingByModel, + normalizeOpencodeVisibleModels, + updateOpencodeProviderSettings, +} from '../settings'; +import { getOpencodeState } from '../types'; + +interface NormalizedSelection { + baseModelId: string | null; + variant: string | null; +} + +const OPENCODE_ENV_HASH_KEYS = [ + 'OPENCODE_CONFIG', + 'OPENCODE_DB', + 'OPENCODE_DISABLE_PROJECT_CONFIG', + 'XDG_DATA_HOME', +] as const; + +function computeOpencodeEnvHash(envText: string): string { + const envVars = parseEnvironmentVariables(envText || ''); + return OPENCODE_ENV_HASH_KEYS + .filter((key) => envVars[key]) + .map((key) => `${key}=${envVars[key]}`) + .sort() + .join('|'); +} + +export const opencodeSettingsReconciler: ProviderSettingsReconciler = { + handleEnvironmentChange(settings: Record): boolean { + return clearOpencodeDiscoveryState(settings); + }, + + reconcileModelWithEnvironment( + settings: Record, + conversations: Conversation[], + ): { changed: boolean; invalidatedConversations: Conversation[] } { + const envText = getRuntimeEnvironmentText(settings, 'opencode'); + const currentHash = computeOpencodeEnvHash(envText); + const savedHash = getOpencodeProviderSettings(settings).environmentHash; + + if (currentHash === savedHash) { + return { changed: false, invalidatedConversations: [] }; + } + + const invalidatedConversations: Conversation[] = []; + for (const conversation of conversations) { + if (conversation.providerId !== 'opencode') { + continue; + } + + const state = getOpencodeState(conversation.providerState); + if (!conversation.sessionId && !state.databasePath) { + continue; + } + + conversation.sessionId = null; + conversation.providerState = undefined; + invalidatedConversations.push(conversation); + } + + updateOpencodeProviderSettings(settings, { environmentHash: currentHash }); + return { changed: true, invalidatedConversations }; + }, + + normalizeModelVariantSettings(settings: Record): boolean { + const hadLegacyDiscoveryFields = hasLegacyOpencodeDiscoveryFields(settings); + if (hadLegacyDiscoveryFields) { + updateOpencodeProviderSettings(settings, {}); + } + + const opencodeSettings = getOpencodeProviderSettings(settings); + let changed = hadLegacyDiscoveryFields; + + const normalizeSelection = (value: unknown): NormalizedSelection => { + if (typeof value !== 'string' || !isOpencodeModelSelectionId(value)) { + return { baseModelId: null, variant: null }; + } + + const rawModelId = decodeOpencodeModelId(value); + if (!rawModelId) { + return { baseModelId: value, variant: null }; + } + + const baseRawId = resolveOpencodeBaseModelRawId(rawModelId, opencodeSettings.discoveredModels); + return { + baseModelId: encodeOpencodeModelId(baseRawId), + variant: extractOpencodeModelVariantValue(rawModelId, opencodeSettings.discoveredModels), + }; + }; + + const modelSelection = normalizeSelection(settings.model); + if (typeof settings.model === 'string' && modelSelection.baseModelId && settings.model !== modelSelection.baseModelId) { + settings.model = modelSelection.baseModelId; + changed = true; + } + if ( + modelSelection.variant + && (typeof settings.effortLevel !== 'string' || settings.effortLevel.trim().length === 0) + ) { + settings.effortLevel = modelSelection.variant; + changed = true; + } + + const titleModelSelection = normalizeSelection(settings.titleGenerationModel); + if ( + typeof settings.titleGenerationModel === 'string' + && titleModelSelection.baseModelId + && settings.titleGenerationModel !== titleModelSelection.baseModelId + ) { + settings.titleGenerationModel = titleModelSelection.baseModelId; + changed = true; + } + + const savedProviderModelRaw = settings.savedProviderModel; + if (savedProviderModelRaw && typeof savedProviderModelRaw === 'object' && !Array.isArray(savedProviderModelRaw)) { + const savedProviderModel = savedProviderModelRaw as Record; + const savedSelection = normalizeSelection(savedProviderModel.opencode); + if ( + typeof savedProviderModel.opencode === 'string' + && savedSelection.baseModelId + && savedProviderModel.opencode !== savedSelection.baseModelId + ) { + savedProviderModel.opencode = savedSelection.baseModelId; + changed = true; + } + if (savedSelection.variant) { + const savedEffort = ensureProviderProjectionMap(settings, 'savedProviderEffort'); + if (typeof savedEffort.opencode !== 'string') { + savedEffort.opencode = savedSelection.variant; + changed = true; + } + } + } + + const normalizedVisibleModels = normalizeOpencodeVisibleModels( + opencodeSettings.visibleModels, + opencodeSettings.discoveredModels, + ); + const normalizedPreferredThinking = normalizeOpencodePreferredThinkingByModel( + opencodeSettings.preferredThinkingByModel, + opencodeSettings.discoveredModels, + ); + const shouldUpdateProviderSettings = !sameStringList(normalizedVisibleModels, opencodeSettings.visibleModels) + || !sameStringMap(normalizedPreferredThinking, opencodeSettings.preferredThinkingByModel); + if (shouldUpdateProviderSettings) { + updateOpencodeProviderSettings(settings, { + preferredThinkingByModel: normalizedPreferredThinking, + visibleModels: normalizedVisibleModels, + }); + changed = true; + } + + if (typeof settings.effortLevel === 'string' && !settings.effortLevel.trim()) { + settings.effortLevel = OPENCODE_DEFAULT_THINKING_LEVEL; + changed = true; + } + + return changed; + }, +}; diff --git a/src/providers/opencode/history/OpencodeConversationHistoryService.ts b/src/providers/opencode/history/OpencodeConversationHistoryService.ts new file mode 100644 index 0000000..35d5b5c --- /dev/null +++ b/src/providers/opencode/history/OpencodeConversationHistoryService.ts @@ -0,0 +1,101 @@ +import type { + ProviderConversationHistoryService, + ProviderHistoryPathContext, +} from '../../../core/providers/types'; +import type { Conversation } from '../../../core/types'; +import { getOpencodeState, type OpencodeProviderState } from '../types'; +import { resolveOpencodeDatabasePathHint } from './OpencodeHistoryPathResolver'; +import { + isOpencodeSessionHydrationDiagnosticMessage, + loadOpencodeSessionMessages, +} from './OpencodeHistoryStore'; + +export class OpencodeConversationHistoryService implements ProviderConversationHistoryService { + private hydratedKeys = new Map(); + + async hydrateConversationHistory( + conversation: Conversation, + _vaultPath: string | null, + pathContext?: ProviderHistoryPathContext, + ): Promise { + const state = getOpencodeState(conversation.providerState); + const databasePath = resolveOpencodeDatabasePathHint(state.databasePath, pathContext); + if (state.databasePath && state.databasePath !== databasePath) { + const providerState = { ...conversation.providerState }; + if (databasePath) { + providerState.databasePath = databasePath; + } else { + delete providerState.databasePath; + } + conversation.providerState = Object.keys(providerState).length > 0 + ? providerState + : undefined; + } + const sessionId = conversation.sessionId; + if (!sessionId) { + this.hydratedKeys.delete(conversation.id); + return; + } + + const hydrationKey = `${sessionId}::${databasePath ?? ''}`; + if ( + conversation.messages.length > 0 + && this.hydratedKeys.get(conversation.id) === hydrationKey + ) { + return; + } + + const messages = await loadOpencodeSessionMessages(sessionId, { databasePath: databasePath ?? undefined }); + if (messages.length === 0) { + this.hydratedKeys.delete(conversation.id); + return; + } + + conversation.messages = messages; + if ( + messages.length === 1 + && isOpencodeSessionHydrationDiagnosticMessage(messages[0]) + ) { + this.hydratedKeys.delete(conversation.id); + return; + } + + this.hydratedKeys.set(conversation.id, hydrationKey); + } + + async deleteConversationSession( + _conversation: Conversation, + _vaultPath: string | null, + ): Promise { + // Never mutate OpenCode native history. + } + + resolveSessionIdForConversation(conversation: Conversation | null): string | null { + return conversation?.sessionId ?? null; + } + + isPendingForkConversation(_conversation: Conversation): boolean { + return false; + } + + buildForkProviderState( + _sourceSessionId: string, + _resumeAt: string, + _sourceProviderState?: Record, + ): Record { + return {}; + } + + buildPersistedProviderState( + conversation: Conversation, + ): Record | undefined { + const state = getOpencodeState(conversation.providerState); + const providerState: OpencodeProviderState = { + ...(state.databasePath ? { databasePath: state.databasePath } : {}), + }; + + return Object.keys(providerState).length > 0 + ? providerState as Record + : undefined; + } +} diff --git a/src/providers/opencode/history/OpencodeHistoryPathResolver.ts b/src/providers/opencode/history/OpencodeHistoryPathResolver.ts new file mode 100644 index 0000000..107503c --- /dev/null +++ b/src/providers/opencode/history/OpencodeHistoryPathResolver.ts @@ -0,0 +1,36 @@ +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { ProviderHistoryPathContext } from '../../../core/providers/types'; +import { isPathWithinRoot, isSamePath } from '../../../core/storage/pathContainment'; +import { + resolveExistingOpencodeDatabasePath, + resolveOpencodeDatabasePath, + resolveOpencodeDataDir, +} from '../runtime/OpencodePaths'; + +export function resolveOpencodeDatabasePathHint( + persistedPath: string | null | undefined, + context?: ProviderHistoryPathContext, +): string | null { + if (!context) { + return resolveExistingOpencodeDatabasePath(persistedPath); + } + + const env = context.environment; + const configuredPath = resolveOpencodeDatabasePath(env); + const home = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir(); + const trustedRoots = [ + resolveOpencodeDataDir(env), + path.join(home, 'Library', 'Application Support', 'opencode'), + ]; + const isTrustedHint = !!persistedPath && ( + (!!configuredPath && isSamePath(persistedPath, configuredPath)) + || trustedRoots.some(root => isPathWithinRoot(persistedPath, root)) + ); + + return resolveExistingOpencodeDatabasePath( + isTrustedHint ? persistedPath : undefined, + env, + ); +} diff --git a/src/providers/opencode/history/OpencodeHistoryStore.ts b/src/providers/opencode/history/OpencodeHistoryStore.ts new file mode 100644 index 0000000..d5d436a --- /dev/null +++ b/src/providers/opencode/history/OpencodeHistoryStore.ts @@ -0,0 +1,508 @@ +import * as fs from 'node:fs'; + +import { extractResolvedAnswersFromResultText } from '../../../core/tools/toolInput'; +import { isWriteEditTool, TOOL_ASK_USER_QUESTION } from '../../../core/tools/toolNames'; +import type { ChatMessage, ContentBlock, ImageAttachment, ToolCallInfo } from '../../../core/types'; +import { extractUserQuery } from '../../../utils/context'; +import { extractDiffData } from '../../../utils/diff'; +import { + buildImageAttachmentFromBase64, + parseImageDataUri, +} from '../../../utils/imageAttachment'; +import { + normalizeOpencodeToolInput, + normalizeOpencodeToolName, + normalizeOpencodeToolUseResult, +} from '../normalization/opencodeToolNormalization'; +import { resolveExistingOpencodeDatabasePath } from '../runtime/OpencodePaths'; +import type { OpencodeProviderState } from '../types'; +import { + loadOpencodeSessionRows, + type StoredRow, +} from './OpencodeSqliteReader'; + +export { OPENCODE_MESSAGE_ROW_SQL } from './OpencodeSqliteReader'; + +interface StoredMessage { + info: StoredRow; + parts: StoredRow[]; +} + +interface OpencodeHydrationDiagnosticContext { + databasePath?: string; + sessionId?: string; +} + +const OPENCODE_HYDRATION_DIAGNOSTIC_ID_PREFIX = 'opencode-hydration-error'; + +export async function loadOpencodeSessionMessages( + sessionId: string, + providerState?: OpencodeProviderState, +): Promise { + const databasePath = resolveExistingOpencodeDatabasePath(providerState?.databasePath); + if (!databasePath || databasePath === ':memory:' || !fs.existsSync(databasePath)) { + return []; + } + + const rows = await loadOpencodeSessionRows(databasePath, sessionId); + if (!rows) { + return [createOpencodeHydrationDiagnosticMessage({ + databasePath, + reason: 'Could not read OpenCode session rows from SQLite.', + sessionId, + })]; + } + + return mapOpencodeMessages( + hydrateStoredMessages(rows.messageRows, rows.partRows), + { databasePath, sessionId }, + ); +} + +export function mapOpencodeMessages( + messages: StoredMessage[], + context: OpencodeHydrationDiagnosticContext = {}, +): ChatMessage[] { + const mappedMessages: ChatMessage[] = []; + + for (const message of messages) { + try { + const mappedMessage = mapStoredMessage(message, context); + if (mappedMessage) { + mappedMessages.push(mappedMessage); + } + } catch (error) { + mappedMessages.push(createOpencodeHydrationDiagnosticMessage({ + ...context, + messageId: getString(message.info.id) ?? undefined, + reason: formatUnknownError(error), + })); + } + } + + return mergeAdjacentAssistantMessages(mappedMessages); +} + +function hydrateStoredMessages( + messageRows: StoredRow[], + partRows: StoredRow[], +): StoredMessage[] { + const partsByMessage = new Map(); + + for (const row of partRows) { + const messageId = getString(row.message_id); + const id = getString(row.id); + const data = parseJsonObject(row.data); + if (!messageId || !id || !data) { + continue; + } + + const parts = partsByMessage.get(messageId) ?? []; + parts.push({ ...data, id }); + partsByMessage.set(messageId, parts); + } + + return messageRows.flatMap((row) => { + const id = getString(row.id); + if (!id) { + return []; + } + + const data = parseJsonObject(row.data); + return [{ + info: data + ? { ...data, id, time_created: row.time_created } + : { + data_time_completed: row.data_time_completed, + data_time_created: row.data_time_created, + data_valid: row.data_valid, + id, + role: row.role, + time_created: row.time_created, + }, + parts: partsByMessage.get(id) ?? [], + }]; + }); +} + +function mapStoredMessage( + message: StoredMessage, + context: OpencodeHydrationDiagnosticContext, +): ChatMessage | null { + const role = getString(message.info.role); + const id = getString(message.info.id); + if (!id) { + return null; + } + if (isInvalidStoredMessageData(message.info)) { + return createOpencodeHydrationDiagnosticMessage({ + ...context, + messageId: id, + reason: 'OpenCode message metadata is not valid JSON.', + }); + } + if (role !== 'user' && role !== 'assistant') { + return null; + } + + const createdAt = getMessageCreatedAt(message.info) + ?? Date.now(); + + if (role === 'user') { + const promptText = extractUserQuery(getJoinedTextParts(message.parts)); + const images = buildUserImages(message.parts, id); + return { + assistantMessageId: undefined, + content: promptText, + id, + ...(images.length > 0 ? { images } : {}), + role: 'user', + timestamp: createdAt, + userMessageId: id, + }; + } + + const contentBlocks = buildAssistantContentBlocks(message.parts); + const toolCalls = buildAssistantToolCalls(message.parts); + const completedAt = getMessageCompletedAt(message.info); + const durationSeconds = completedAt && completedAt >= createdAt + ? Math.max(0, (completedAt - createdAt) / 1_000) + : undefined; + + return { + assistantMessageId: id, + content: contentBlocks + .filter((block): block is Extract => block.type === 'text') + .map((block) => block.content) + .join(''), + contentBlocks: contentBlocks.length > 0 ? contentBlocks : undefined, + durationSeconds, + id, + role: 'assistant', + timestamp: createdAt, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + }; +} + +function mergeAdjacentAssistantMessages(messages: ChatMessage[]): ChatMessage[] { + const merged: ChatMessage[] = []; + + for (const message of messages) { + const previous = merged[merged.length - 1]; + if ( + message.role === 'assistant' + && previous?.role === 'assistant' + && !message.isInterrupt + && !previous.isInterrupt + && !isOpencodeHydrationDiagnosticMessage(message) + && !isOpencodeHydrationDiagnosticMessage(previous) + ) { + previous.content += message.content; + previous.assistantMessageId = message.assistantMessageId ?? previous.assistantMessageId; + previous.durationFlavorWord = message.durationFlavorWord ?? previous.durationFlavorWord; + previous.durationSeconds = mergeAssistantDurationSeconds(previous, message); + previous.toolCalls = mergeOptionalArrays(previous.toolCalls, message.toolCalls); + previous.contentBlocks = mergeOptionalArrays(previous.contentBlocks, message.contentBlocks); + continue; + } + + merged.push(message); + } + + return merged; +} + +function mergeOptionalArrays(left?: T[], right?: T[]): T[] | undefined { + if (!left?.length && !right?.length) { + return undefined; + } + + return [ + ...(left ?? []), + ...(right ?? []), + ]; +} + +function mergeAssistantDurationSeconds( + first: ChatMessage, + next: ChatMessage, +): number | undefined { + const firstEnd = getMessageCompletionTime(first); + const nextEnd = getMessageCompletionTime(next); + if (firstEnd === null && nextEnd === null) { + return undefined; + } + + const end = Math.max(firstEnd ?? first.timestamp, nextEnd ?? next.timestamp); + return Math.max(0, (end - first.timestamp) / 1_000); +} + +function getMessageCompletionTime(message: ChatMessage): number | null { + if (typeof message.durationSeconds !== 'number') { + return null; + } + + return message.timestamp + (message.durationSeconds * 1_000); +} + +function getMessageCreatedAt(info: StoredRow): number | null { + return getNestedNumber(info, ['time', 'created']) + ?? getNumber(info.data_time_created) + ?? getNumber(info.time_created); +} + +function getMessageCompletedAt(info: StoredRow): number | null { + return getNestedNumber(info, ['time', 'completed']) + ?? getNumber(info.data_time_completed); +} + +function isInvalidStoredMessageData(info: StoredRow): boolean { + return getNumber(info.data_valid) === 0; +} + +function createOpencodeHydrationDiagnosticMessage(params: { + databasePath?: string; + messageId?: string; + reason: string; + sessionId?: string; +}): ChatMessage { + const detailLines = [ + 'Failed to hydrate OpenCode session.', + 'provider: OpenCode', + ...(params.sessionId ? [`sessionId: ${params.sessionId}`] : []), + ...(params.databasePath ? [`databasePath: ${params.databasePath}`] : []), + ...(params.messageId ? [`messageId: ${params.messageId}`] : []), + `reason: ${params.reason}`, + ]; + const content = detailLines.join('\n'); + + return { + assistantMessageId: undefined, + content, + contentBlocks: [{ content, type: 'text' }], + id: buildOpencodeHydrationDiagnosticId(params), + role: 'assistant', + timestamp: Date.now(), + }; +} + +function buildOpencodeHydrationDiagnosticId(params: { + messageId?: string; + sessionId?: string; +}): string { + const scope = params.messageId ? 'message' : 'session'; + const rawId = params.messageId ?? params.sessionId ?? String(Date.now()); + const safeId = rawId.replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 120) || String(Date.now()); + return `${OPENCODE_HYDRATION_DIAGNOSTIC_ID_PREFIX}-${scope}-${safeId}`; +} + +export function isOpencodeSessionHydrationDiagnosticMessage(message: ChatMessage): boolean { + return message.id.startsWith(`${OPENCODE_HYDRATION_DIAGNOSTIC_ID_PREFIX}-session-`); +} + +function isOpencodeHydrationDiagnosticMessage(message: ChatMessage): boolean { + return message.id.startsWith(OPENCODE_HYDRATION_DIAGNOSTIC_ID_PREFIX); +} + +function formatUnknownError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function buildAssistantContentBlocks(parts: StoredRow[]): ContentBlock[] { + const blocks: ContentBlock[] = []; + + for (const part of parts) { + switch (getString(part.type)) { + case 'reasoning': { + const text = getString(part.text)?.trim(); + if (!text) { + break; + } + blocks.push({ + content: text, + durationSeconds: getDurationSeconds(part), + type: 'thinking', + }); + break; + } + case 'text': { + const text = getString(part.text); + if (!text || getBoolean(part.ignored)) { + break; + } + blocks.push({ + content: text, + type: 'text', + }); + break; + } + case 'tool': { + const toolId = getString(part.callID); + if (!toolId) { + break; + } + blocks.push({ + toolId, + type: 'tool_use', + }); + break; + } + } + } + + return blocks; +} + +function buildAssistantToolCalls(parts: StoredRow[]): ToolCallInfo[] { + return parts.flatMap((part) => { + if (getString(part.type) !== 'tool') { + return []; + } + + const id = getString(part.callID); + const rawName = getString(part.tool); + const state = getObject(part.state); + const status = mapToolStatus(getString(state?.status)); + if (!id || !rawName || !status) { + return []; + } + + const input = normalizeOpencodeToolInput(rawName, getObject(state?.input) ?? {}); + const name = normalizeOpencodeToolName(rawName); + const result = getString(state?.output) ?? getString(state?.error) ?? undefined; + const toolUseResult = normalizeOpencodeToolUseResult(rawName, input, { + ...(result ? { output: result } : {}), + ...(getObject(state?.metadata) ? { metadata: getObject(state?.metadata) } : {}), + }); + + const toolCall: ToolCallInfo = { + id, + input, + name, + result, + status, + }; + + if (name === TOOL_ASK_USER_QUESTION) { + toolCall.resolvedAnswers = toolUseResult?.answers as ToolCallInfo['resolvedAnswers'] + ?? extractResolvedAnswersFromResultText(result); + } + + if (status === 'completed' && isWriteEditTool(name)) { + const diffData = extractDiffData(toolUseResult, toolCall); + if (diffData) { + toolCall.diffData = diffData; + } + } + + return [toolCall]; + }); +} + +function getJoinedTextParts(parts: StoredRow[]): string { + return parts + .filter((part) => getString(part.type) === 'text' && !getBoolean(part.ignored)) + .map((part) => getString(part.text) ?? '') + .join(''); +} + +function buildUserImages(parts: StoredRow[], messageId: string): ImageAttachment[] { + const images: ImageAttachment[] = []; + + for (const part of parts) { + if (getString(part.type) !== 'file') { + continue; + } + + const parsed = parseImageDataUri(getString(part.url)); + const mime = getString(part.mime); + const mediaType = parsed?.mediaType ?? mime; + const data = parsed?.data; + if (!data || !mediaType) { + continue; + } + + const image = buildImageAttachmentFromBase64({ + data, + id: `opencode-img-${messageId}-${images.length}`, + mediaType, + name: getString(part.filename) ?? getString(part.name) ?? `image-${images.length + 1}.${String(mediaType).split('/')[1] ?? 'img'}`, + }); + if (image) { + images.push(image); + } + } + + return images; +} + +function getDurationSeconds(part: StoredRow): number | undefined { + const start = getNestedNumber(part, ['time', 'start']); + const end = getNestedNumber(part, ['time', 'end']); + if (start === null || end === null || end < start) { + return undefined; + } + + return Math.max(0, (end - start) / 1_000); +} + +function mapToolStatus(status: string | null): ToolCallInfo['status'] | null { + switch (status) { + case 'pending': + case 'running': + return 'running'; + case 'completed': + return 'completed'; + case 'error': + return 'error'; + default: + return null; + } +} + +function parseJsonObject(value: unknown): StoredRow | null { + if (typeof value !== 'string') { + return null; + } + + try { + const parsed = JSON.parse(value) as unknown; + return isPlainObject(parsed) ? parsed : null; + } catch { + return null; + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function getBoolean(value: unknown): boolean { + return value === true; +} + +function getObject(value: unknown): StoredRow | null { + return isPlainObject(value) ? value : null; +} + +function getString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function getNumber(value: unknown): number | null { + return typeof value === 'number' ? value : null; +} + +function getNestedNumber( + value: StoredRow, + keys: string[], +): number | null { + let current: unknown = value; + for (const key of keys) { + if (!isPlainObject(current)) { + return null; + } + current = current[key]; + } + return getNumber(current); +} diff --git a/src/providers/opencode/history/OpencodeSqliteReader.ts b/src/providers/opencode/history/OpencodeSqliteReader.ts new file mode 100644 index 0000000..687c1f2 --- /dev/null +++ b/src/providers/opencode/history/OpencodeSqliteReader.ts @@ -0,0 +1,308 @@ +import { spawn as defaultSpawn } from 'node:child_process'; + +import { findNodeExecutable } from '../../../utils/env'; + +export type StoredRow = Record; + +export interface StoredSessionRows { + messageRows: StoredRow[]; + partRows: StoredRow[]; +} + +interface SqliteModule { + DatabaseSync: new (location: string, options?: Record) => { + close(): void; + prepare(sql: string): { + all(...params: unknown[]): StoredRow[]; + }; + }; +} + +export interface OpencodeSqliteReaderDependencies { + findNodeExecutable?: () => string | null; + requireSqliteModule?: () => SqliteModule | null; + spawn?: typeof defaultSpawn; +} + +export const OPENCODE_SQLITE_QUERY_MAX_BUFFER = 100 * 1024 * 1024; +export const OPENCODE_MESSAGE_ROW_SQL = buildOpencodeMessageRowsSql('?'); + +const OPENCODE_PART_ROW_SQL = buildOpencodePartRowsSql('?'); +const OPENCODE_SQLITE_CHILD_SCRIPT = ` +const { DatabaseSync } = require('node:sqlite'); +const [databasePath, sessionId, messageSql, partSql] = process.argv.slice(1); +let db; +try { + db = new DatabaseSync(databasePath, { readonly: true }); + const messageRows = db.prepare(messageSql).all(sessionId); + const partRows = db.prepare(partSql).all(sessionId); + process.stdout.write(JSON.stringify({ messageRows, partRows })); +} finally { + if (db) db.close(); +} +`.trim(); + +export async function loadOpencodeSessionRows( + databasePath: string, + sessionId: string, + dependencies: OpencodeSqliteReaderDependencies = {}, +): Promise { + const resolvedDependencies = resolveDependencies(dependencies); + + const viaCurrentProcess = loadSessionRowsWithCurrentProcessSqlite( + databasePath, + sessionId, + resolvedDependencies.requireSqliteModule, + ); + if (viaCurrentProcess) { + return viaCurrentProcess; + } + + const viaNodeProcess = await loadSessionRowsWithNodeProcess( + databasePath, + sessionId, + resolvedDependencies.findNodeExecutable, + resolvedDependencies.spawn, + ); + if (viaNodeProcess) { + return viaNodeProcess; + } + + return loadSessionRowsWithSqliteCli( + databasePath, + sessionId, + resolvedDependencies.spawn, + ); +} + +function resolveDependencies( + dependencies: OpencodeSqliteReaderDependencies, +): Required { + return { + findNodeExecutable, + requireSqliteModule, + spawn: defaultSpawn, + ...dependencies, + }; +} + +function requireSqliteModule(): SqliteModule | null { + try { + if (typeof module === 'undefined' || typeof module.require !== 'function') { + return null; + } + + const sqlite = module.require('node:sqlite') as unknown; + return isSqliteModule(sqlite) ? sqlite : null; + } catch { + return null; + } +} + +function isSqliteModule(value: unknown): value is SqliteModule { + return ( + isPlainObject(value) + && typeof value.DatabaseSync === 'function' + ); +} + +function loadSessionRowsWithCurrentProcessSqlite( + databasePath: string, + sessionId: string, + requireSqlite: () => SqliteModule | null, +): StoredSessionRows | null { + const sqlite = requireSqlite(); + if (!sqlite) { + return null; + } + + let db: InstanceType | null = null; + try { + db = new sqlite.DatabaseSync(databasePath, { readonly: true }); + const messageRows = db.prepare(OPENCODE_MESSAGE_ROW_SQL).all(sessionId); + const partRows = db.prepare(OPENCODE_PART_ROW_SQL).all(sessionId); + return { messageRows, partRows }; + } catch { + return null; + } finally { + db?.close(); + } +} + +async function loadSessionRowsWithNodeProcess( + databasePath: string, + sessionId: string, + findNode: () => string | null, + spawn: typeof defaultSpawn, +): Promise { + const nodePath = findNode(); + if (!nodePath) { + return null; + } + + const stdout = await runBufferedChild( + nodePath, + [ + '-e', + OPENCODE_SQLITE_CHILD_SCRIPT, + databasePath, + sessionId, + OPENCODE_MESSAGE_ROW_SQL, + OPENCODE_PART_ROW_SQL, + ], + spawn, + ); + return stdout === null ? null : parseStoredSessionRows(stdout); +} + +async function loadSessionRowsWithSqliteCli( + databasePath: string, + sessionId: string, + spawn: typeof defaultSpawn, +): Promise { + const escapedSessionId = escapeSqlLiteral(sessionId); + const messageRows = await runSqlite3JsonQuery( + databasePath, + buildOpencodeMessageRowsSql(`'${escapedSessionId}'`), + spawn, + ); + const partRows = await runSqlite3JsonQuery( + databasePath, + buildOpencodePartRowsSql(`'${escapedSessionId}'`), + spawn, + ); + + if (!messageRows || !partRows) { + return null; + } + + return { messageRows, partRows }; +} + +async function runSqlite3JsonQuery( + databasePath: string, + sql: string, + spawn: typeof defaultSpawn, +): Promise { + const stdout = await runBufferedChild( + 'sqlite3', + ['-json', databasePath, sql], + spawn, + ); + return stdout === null ? null : parseStoredRows(stdout); +} + +function runBufferedChild( + command: string, + args: string[], + spawn: typeof defaultSpawn, +): Promise { + return new Promise((resolve) => { + let settled = false; + let size = 0; + let timer: number | null = null; + const chunks: Buffer[] = []; + let child: ReturnType; + try { + child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, + }); + } catch { + resolve(null); + return; + } + const finish = (value: string | null): void => { + if (settled) return; + settled = true; + if (timer !== null) window.clearTimeout(timer); + resolve(value); + }; + + child.stdout?.on('data', (chunk: Buffer | string) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > OPENCODE_SQLITE_QUERY_MAX_BUFFER) { + child.kill('SIGKILL'); + finish(null); + return; + } + chunks.push(buffer); + }); + child.once('error', () => finish(null)); + child.once('close', (code) => { + finish(code === 0 ? Buffer.concat(chunks).toString('utf8') : null); + }); + timer = window.setTimeout(() => { + child.kill('SIGKILL'); + finish(null); + }, 10_000); + }); +} + +function parseStoredSessionRows(value: string): StoredSessionRows | null { + try { + const parsed = JSON.parse(value || '{}') as unknown; + if (!isPlainObject(parsed)) { + return null; + } + + const messageRows = parseStoredRowsValue(parsed.messageRows); + const partRows = parseStoredRowsValue(parsed.partRows); + return messageRows && partRows ? { messageRows, partRows } : null; + } catch { + return null; + } +} + +function parseStoredRows(value: string): StoredRow[] | null { + try { + return parseStoredRowsValue(JSON.parse(value || '[]') as unknown); + } catch { + return null; + } +} + +function parseStoredRowsValue(value: unknown): StoredRow[] | null { + return Array.isArray(value) + ? value.filter((row): row is StoredRow => isPlainObject(row)) + : null; +} + +function escapeSqlLiteral(value: string): string { + return value.replaceAll('\'', '\'\''); +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function buildOpencodeMessageRowsSql(sessionIdExpression: string): string { + return ` +with message_json as ( + select + id, + time_created, + data, + json_valid(data) as data_valid + from message + where session_id = ${sessionIdExpression} +) +select + id, + time_created, + data_valid, + case when data_valid then json_extract(data, '$.role') end as role, + case when data_valid then json_extract(data, '$.time.created') end as data_time_created, + case when data_valid then json_extract(data, '$.time.completed') end as data_time_completed +from message_json +order by time_created asc, id asc;`.trim(); +} + +function buildOpencodePartRowsSql(sessionIdExpression: string): string { + return ` +select id, message_id, data +from part +where session_id = ${sessionIdExpression} +order by message_id asc, id asc;`.trim(); +} diff --git a/src/providers/opencode/internal/compareCollections.ts b/src/providers/opencode/internal/compareCollections.ts new file mode 100644 index 0000000..0a80d5d --- /dev/null +++ b/src/providers/opencode/internal/compareCollections.ts @@ -0,0 +1,72 @@ +import type { OpencodeDiscoveredModel, OpencodeThinkingOptionsByModel } from '../models'; +import type { OpencodeMode } from '../modes'; + +export function sameStringList(left: string[], right: string[]): boolean { + if (left.length !== right.length) { + return false; + } + + return left.every((entry, index) => entry === right[index]); +} + +export function sameStringMap( + left: Record, + right: Record, +): boolean { + const leftEntries = Object.entries(left); + if (leftEntries.length !== Object.keys(right).length) { + return false; + } + + return leftEntries.every(([key, value]) => right[key] === value); +} + +export function sameModes(left: OpencodeMode[], right: OpencodeMode[]): boolean { + if (left.length !== right.length) { + return false; + } + + return left.every((mode, index) => ( + mode.id === right[index]?.id + && mode.name === right[index]?.name + && (mode.description ?? '') === (right[index]?.description ?? '') + )); +} + +export function sameDiscoveredModels( + left: OpencodeDiscoveredModel[], + right: OpencodeDiscoveredModel[], +): boolean { + if (left.length !== right.length) { + return false; + } + + return left.every((model, index) => ( + model.rawId === right[index]?.rawId + && model.label === right[index]?.label + && (model.description ?? '') === (right[index]?.description ?? '') + )); +} + +export function sameThinkingOptionsByModel( + left: OpencodeThinkingOptionsByModel, + right: OpencodeThinkingOptionsByModel, +): boolean { + const leftEntries = Object.entries(left); + if (leftEntries.length !== Object.keys(right).length) { + return false; + } + + return leftEntries.every(([rawId, leftOptions]) => { + const rightOptions = right[rawId] ?? []; + if (leftOptions.length !== rightOptions.length) { + return false; + } + + return leftOptions.every((option, index) => ( + option.value === rightOptions[index]?.value + && option.label === rightOptions[index]?.label + && (option.description ?? '') === (rightOptions[index]?.description ?? '') + )); + }); +} diff --git a/src/providers/opencode/internal/providerProjection.ts b/src/providers/opencode/internal/providerProjection.ts new file mode 100644 index 0000000..ebb7714 --- /dev/null +++ b/src/providers/opencode/internal/providerProjection.ts @@ -0,0 +1,15 @@ +export type ProjectionKey = 'savedProviderEffort' | 'savedProviderModel'; + +export function ensureProviderProjectionMap( + settings: Record, + key: ProjectionKey, +): Record { + const current = settings[key]; + if (current && typeof current === 'object' && !Array.isArray(current)) { + return current as Record; + } + + const next: Record = {}; + settings[key] = next; + return next; +} diff --git a/src/providers/opencode/models.ts b/src/providers/opencode/models.ts new file mode 100644 index 0000000..e303a48 --- /dev/null +++ b/src/providers/opencode/models.ts @@ -0,0 +1,396 @@ +import { + DEFAULT_REASONING_VALUE, + resolvePreferredReasoningDefault, +} from '../../core/providers/reasoning'; + +export interface OpencodeDiscoveredModel { + description?: string; + label: string; + rawId: string; +} + +export interface OpencodeModelVariant { + description?: string; + label: string; + value: string; +} + +export type OpencodeThinkingOptionsByModel = Record; + +export interface OpencodeBaseModel { + description?: string; + label: string; + rawId: string; + variants: OpencodeModelVariant[]; +} + +export interface OpencodeDiscoveredModelGroup { + models: OpencodeDiscoveredModel[]; + providerKey: string; + providerLabel: string; +} + +export const OPENCODE_SYNTHETIC_MODEL_ID = 'opencode'; +export const OPENCODE_DEFAULT_THINKING_LEVEL = 'default'; + +const OPENCODE_MODEL_PREFIX = 'opencode:'; +const OPENCODE_VARIANT_ASCENDING_ORDER = [ + 'none', + 'minimal', + 'low', + 'medium', + 'high', + 'max', + 'xhigh', +] as const; +const OPENCODE_VARIANT_ASCENDING_RANK = new Map( + OPENCODE_VARIANT_ASCENDING_ORDER.map((value, index) => [value, index] as const), +); + +export function resolveOpencodeDefaultThinkingLevel( + options: OpencodeModelVariant[], + preferredValue?: string, + fallbackValue: string = DEFAULT_REASONING_VALUE, +): string { + const values = options.map(option => option.value); + if (preferredValue && (values.length === 0 || values.includes(preferredValue))) { + return preferredValue; + } + + return resolvePreferredReasoningDefault(values, fallbackValue); +} + +export function isOpencodeModelSelectionId(model: string): boolean { + return model === OPENCODE_SYNTHETIC_MODEL_ID || model.startsWith(OPENCODE_MODEL_PREFIX); +} + +export function encodeOpencodeModelId(rawModelId: string): string { + const normalized = rawModelId.trim(); + return normalized ? `${OPENCODE_MODEL_PREFIX}${normalized}` : OPENCODE_SYNTHETIC_MODEL_ID; +} + +export function decodeOpencodeModelId(model: string): string | null { + if (!model.startsWith(OPENCODE_MODEL_PREFIX)) { + return null; + } + + const rawModelId = model.slice(OPENCODE_MODEL_PREFIX.length).trim(); + return rawModelId || null; +} + +export function normalizeOpencodeDiscoveredModels(value: unknown): OpencodeDiscoveredModel[] { + if (!Array.isArray(value)) { + return []; + } + + const normalized: OpencodeDiscoveredModel[] = []; + const seen = new Set(); + for (const entry of value as unknown[]) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + continue; + } + const record = entry as Record; + + const rawId = typeof record.rawId === 'string' ? record.rawId.trim() : ''; + const label = typeof record.label === 'string' ? record.label.trim() : rawId; + const description = typeof record.description === 'string' + ? record.description.trim() + : ''; + + if (!rawId || seen.has(rawId)) { + continue; + } + + seen.add(rawId); + normalized.push({ + ...(description ? { description } : {}), + label: label || rawId, + rawId, + }); + } + + return normalized; +} + +export function normalizeOpencodeModelVariants(value: unknown): OpencodeModelVariant[] { + if (!Array.isArray(value)) { + return []; + } + + const variants: OpencodeModelVariant[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + continue; + } + + const record = entry as Record; + const rawValue = typeof record.value === 'string' ? record.value.trim() : ''; + if (!rawValue) { + continue; + } + + let rawLabel = ''; + if (typeof record.label === 'string') { + rawLabel = record.label.trim(); + } else if (typeof record.name === 'string') { + rawLabel = record.name.trim(); + } + const description = typeof record.description === 'string' + ? record.description.trim() + : ''; + + variants.push({ + ...(description ? { description } : {}), + label: rawLabel || formatOpencodeThinkingLevelLabel(rawValue), + value: rawValue, + }); + } + + return dedupeOpencodeVariants(variants); +} + +export function normalizeOpencodeThinkingOptionsByModel( + value: unknown, + discoveredModels: OpencodeDiscoveredModel[] = [], +): OpencodeThinkingOptionsByModel { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const normalized: OpencodeThinkingOptionsByModel = {}; + for (const [rawId, variants] of Object.entries(value as Record)) { + const normalizedRawId = resolveOpencodeBaseModelRawId(rawId.trim(), discoveredModels); + const normalizedVariants = normalizeOpencodeModelVariants(variants); + if (!normalizedRawId || normalizedVariants.length === 0) { + continue; + } + + normalized[normalizedRawId] = normalizedVariants; + } + + return normalized; +} + +export function resolveOpencodeBaseModelRawId( + rawId: string, + discoveredModels: OpencodeDiscoveredModel[] | Set, +): string { + const normalizedRawId = rawId.trim(); + if (!normalizedRawId) { + return ''; + } + + const discoveredRawIds = discoveredModels instanceof Set + ? discoveredModels + : new Set(discoveredModels.map((model) => model.rawId)); + const slashIndex = normalizedRawId.lastIndexOf('/'); + if (slashIndex <= 0) { + return normalizedRawId; + } + + const candidate = normalizedRawId.slice(0, slashIndex); + if (discoveredRawIds.has(candidate)) { + return candidate; + } + + const variant = normalizedRawId.slice(slashIndex + 1).trim().toLowerCase(); + return OPENCODE_VARIANT_ASCENDING_RANK.has(variant) + ? candidate + : normalizedRawId; +} + +export function extractOpencodeModelVariantValue( + rawId: string, + discoveredModels: OpencodeDiscoveredModel[] | Set, +): string | null { + const normalizedRawId = rawId.trim(); + if (!normalizedRawId) { + return null; + } + + const baseRawId = resolveOpencodeBaseModelRawId(normalizedRawId, discoveredModels); + if (baseRawId === normalizedRawId || baseRawId.length >= normalizedRawId.length) { + return null; + } + + const variant = normalizedRawId.slice(baseRawId.length + 1).trim(); + return variant || null; +} + +export function combineOpencodeRawModelSelection( + baseRawId: string | null | undefined, + thinkingLevel: string | null | undefined, + discoveredModels: OpencodeDiscoveredModel[], +): string | null { + const normalizedBaseRawId = baseRawId?.trim(); + if (!normalizedBaseRawId) { + return null; + } + + const variant = thinkingLevel?.trim(); + if (!variant || variant === OPENCODE_DEFAULT_THINKING_LEVEL) { + return normalizedBaseRawId; + } + + const supportedVariants = new Set( + getOpencodeModelVariants(normalizedBaseRawId, discoveredModels).map((entry) => entry.value), + ); + return supportedVariants.has(variant) + ? `${normalizedBaseRawId}/${variant}` + : normalizedBaseRawId; +} + +export function splitOpencodeModelLabel(label: string): { + modelLabel: string; + providerLabel: string; +} { + const trimmed = label.trim(); + const slashIndex = trimmed.indexOf('/'); + if (slashIndex <= 0 || slashIndex >= trimmed.length - 1) { + return { + modelLabel: trimmed, + providerLabel: 'Other', + }; + } + + return { + modelLabel: trimmed.slice(slashIndex + 1).trim(), + providerLabel: trimmed.slice(0, slashIndex).trim(), + }; +} + +export function buildOpencodeBaseModels( + models: OpencodeDiscoveredModel[], +): OpencodeBaseModel[] { + const discoveredRawIds = new Set(models.map((model) => model.rawId)); + const discoveredByRawId = new Map(models.map((model) => [model.rawId, model] as const)); + const grouped = new Map(); + + for (const model of models) { + const baseRawId = resolveOpencodeBaseModelRawId(model.rawId, discoveredRawIds); + const existing = grouped.get(baseRawId); + if (existing) { + existing.push(model); + } else { + grouped.set(baseRawId, [model]); + } + } + + return Array.from(grouped.entries()) + .map(([baseRawId, entries]) => { + const baseModel = discoveredByRawId.get(baseRawId) ?? entries[0]; + const variants = entries.flatMap((entry) => { + if (entry.rawId === baseRawId) { + return []; + } + + const variant = extractOpencodeModelVariantValue(entry.rawId, discoveredRawIds); + if (!variant) { + return []; + } + + return [{ + ...(entry.description ? { description: entry.description } : {}), + label: formatOpencodeThinkingLevelLabel(variant), + value: variant, + }]; + }); + + return { + ...(baseModel?.description ? { description: baseModel.description } : {}), + label: baseModel?.label ?? baseRawId, + rawId: baseRawId, + variants: dedupeOpencodeVariants(variants), + }; + }) + .sort((left, right) => left.label.localeCompare(right.label)); +} + +export function getOpencodeModelVariants( + rawId: string, + models: OpencodeDiscoveredModel[], +): OpencodeModelVariant[] { + const baseRawId = resolveOpencodeBaseModelRawId(rawId, models); + return buildOpencodeBaseModels(models) + .find((model) => model.rawId === baseRawId)?.variants ?? []; +} + +function formatOpencodeThinkingLevelLabel(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return ''; + } + + if (trimmed.toLowerCase() === 'xhigh') { + return 'XHigh'; + } + + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); +} + +export function groupOpencodeDiscoveredModels( + models: OpencodeDiscoveredModel[], +): OpencodeDiscoveredModelGroup[] { + const groups = new Map(); + for (const model of buildOpencodeBaseModels(models)) { + const { providerLabel } = splitOpencodeModelLabel(model.label || model.rawId); + const providerKey = providerLabel.toLowerCase(); + const existing = groups.get(providerKey); + if (existing) { + existing.models.push({ + ...(model.description ? { description: model.description } : {}), + label: model.label, + rawId: model.rawId, + }); + continue; + } + + groups.set(providerKey, { + models: [{ + ...(model.description ? { description: model.description } : {}), + label: model.label, + rawId: model.rawId, + }], + providerKey, + providerLabel, + }); + } + + return Array.from(groups.values()) + .map((group) => ({ + ...group, + models: [...group.models].sort((left, right) => left.label.localeCompare(right.label)), + })) + .sort((left, right) => left.providerLabel.localeCompare(right.providerLabel)); +} + +function dedupeOpencodeVariants(variants: OpencodeModelVariant[]): OpencodeModelVariant[] { + const unique = new Map(); + for (const variant of variants) { + if (!unique.has(variant.value)) { + unique.set(variant.value, variant); + } + } + + return Array.from(unique.values()) + .sort((left, right) => compareOpencodeVariantValues(left.value, right.value)); +} + +function compareOpencodeVariantValues(left: string, right: string): number { + const leftRank = OPENCODE_VARIANT_ASCENDING_RANK.get(left.toLowerCase()); + const rightRank = OPENCODE_VARIANT_ASCENDING_RANK.get(right.toLowerCase()); + + if (leftRank !== undefined && rightRank !== undefined) { + return leftRank - rightRank; + } + + if (leftRank !== undefined) { + return -1; + } + + if (rightRank !== undefined) { + return 1; + } + + return left.localeCompare(right); +} diff --git a/src/providers/opencode/modes.ts b/src/providers/opencode/modes.ts new file mode 100644 index 0000000..5777d8c --- /dev/null +++ b/src/providers/opencode/modes.ts @@ -0,0 +1,150 @@ +export interface OpencodeMode { + description?: string; + id: string; + name: string; +} + +export const OPENCODE_BUILD_MODE_ID = 'build'; +export const OPENCODE_YOLO_MODE_ID = 'claudian-yolo'; +export const OPENCODE_SAFE_MODE_ID = 'claudian-safe'; +export const OPENCODE_PLAN_MODE_ID = 'plan'; + +export const OPENCODE_FALLBACK_MODES: ReadonlyArray = Object.freeze([ + { + description: 'The default agent. Executes tools based on configured permissions.', + id: OPENCODE_YOLO_MODE_ID, + name: 'yolo', + }, + { + description: 'Safe mode. Asks before shell commands and file edits.', + id: OPENCODE_SAFE_MODE_ID, + name: 'safe', + }, + { + description: 'Plan mode. Disallows all edit tools.', + id: OPENCODE_PLAN_MODE_ID, + name: OPENCODE_PLAN_MODE_ID, + }, +]); + +const OPENCODE_MANAGED_MODE_IDS = new Set([ + OPENCODE_BUILD_MODE_ID, + ...OPENCODE_FALLBACK_MODES.map((mode) => mode.id), +]); + +export function normalizeOpencodeAvailableModes(value: unknown): OpencodeMode[] { + if (!Array.isArray(value)) { + return []; + } + + const normalized: OpencodeMode[] = []; + const seen = new Set(); + for (const entry of value as unknown[]) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + continue; + } + const record = entry as Record; + + const id = typeof record.id === 'string' ? record.id.trim() : ''; + const name = typeof record.name === 'string' ? record.name.trim() : id; + const description = typeof record.description === 'string' + ? record.description.trim() + : ''; + + if (!id || seen.has(id)) { + continue; + } + + seen.add(id); + normalized.push({ + ...(description ? { description } : {}), + id, + name: name || id, + }); + } + + return normalized; +} + +export function getEffectiveOpencodeModes(modes: OpencodeMode[]): OpencodeMode[] { + return modes.length > 0 ? modes : [...OPENCODE_FALLBACK_MODES]; +} + +export function isManagedOpencodeModeId(value: string): boolean { + return OPENCODE_MANAGED_MODE_IDS.has(value); +} + +export function getManagedOpencodeModes(modes: OpencodeMode[]): OpencodeMode[] { + const effectiveModes = getEffectiveOpencodeModes(modes); + return OPENCODE_FALLBACK_MODES.map((fallbackMode) => ( + effectiveModes.find((mode) => mode.id === fallbackMode.id) ?? fallbackMode + )); +} + +export function normalizeOpencodeSelectedMode( + value: unknown, +): string { + if (typeof value !== 'string') { + return ''; + } + + const trimmed = value.trim(); + if (!trimmed) { + return ''; + } + + return trimmed; +} + +export function normalizeManagedOpencodeSelectedMode( + value: unknown, + modes: OpencodeMode[] = [], +): string { + const normalized = normalizeOpencodeSelectedMode(value); + if (!normalized) { + return ''; + } + + const canonicalModeId = normalized === OPENCODE_BUILD_MODE_ID + ? OPENCODE_YOLO_MODE_ID + : normalized; + const managedModes = getManagedOpencodeModes(modes); + return managedModes.some((mode) => mode.id === canonicalModeId) + ? canonicalModeId + : (managedModes[0]?.id ?? ''); +} + +export function resolveOpencodeModeForPermissionMode( + permissionMode: unknown, + modes: OpencodeMode[] = [], +): string { + const managedModes = getManagedOpencodeModes(modes); + const managedModeIds = new Set(managedModes.map((mode) => mode.id)); + + if (permissionMode === 'plan' && managedModeIds.has(OPENCODE_PLAN_MODE_ID)) { + return OPENCODE_PLAN_MODE_ID; + } + if (permissionMode === 'normal' && managedModeIds.has(OPENCODE_SAFE_MODE_ID)) { + return OPENCODE_SAFE_MODE_ID; + } + if (managedModeIds.has(OPENCODE_YOLO_MODE_ID)) { + return OPENCODE_YOLO_MODE_ID; + } + + return managedModes[0]?.id ?? ''; +} + +export function resolvePermissionModeForManagedOpencodeMode( + modeId: unknown, +): 'normal' | 'plan' | 'yolo' | null { + if (modeId === OPENCODE_BUILD_MODE_ID || modeId === OPENCODE_YOLO_MODE_ID) { + return 'yolo'; + } + if (modeId === OPENCODE_SAFE_MODE_ID) { + return 'normal'; + } + if (modeId === OPENCODE_PLAN_MODE_ID) { + return 'plan'; + } + return null; +} diff --git a/src/providers/opencode/normalization/opencodeToolNormalization.ts b/src/providers/opencode/normalization/opencodeToolNormalization.ts new file mode 100644 index 0000000..291453f --- /dev/null +++ b/src/providers/opencode/normalization/opencodeToolNormalization.ts @@ -0,0 +1,406 @@ +import { + TOOL_ASK_USER_QUESTION, + TOOL_BASH, + TOOL_EDIT, + TOOL_GLOB, + TOOL_GREP, + TOOL_READ, + TOOL_SKILL, + TOOL_TASK, + TOOL_TODO_WRITE, + TOOL_WEB_FETCH, + TOOL_WEB_SEARCH, + TOOL_WRITE, +} from '../../../core/tools/toolNames'; +import type { AskUserAnswers, AskUserQuestionItem } from '../../../core/types'; +import type { SDKToolUseResult } from '../../../core/types/diff'; +import { AcpToolStreamAdapter } from '../../acp'; + +const TOOL_NAME_MAP: Record = { + bash: TOOL_BASH, + edit: TOOL_EDIT, + glob: TOOL_GLOB, + grep: TOOL_GREP, + question: TOOL_ASK_USER_QUESTION, + read: TOOL_READ, + skill: TOOL_SKILL, + task: TOOL_TASK, + todowrite: TOOL_TODO_WRITE, + webfetch: TOOL_WEB_FETCH, + websearch: TOOL_WEB_SEARCH, + write: TOOL_WRITE, +}; + +type OpencodeKnownToolName = keyof typeof TOOL_NAME_MAP; + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isKnownToolName(value: unknown): value is OpencodeKnownToolName { + if (typeof value !== 'string') { + return false; + } + + return value.trim().toLowerCase() in TOOL_NAME_MAP; +} + +function toKnownToolName(value: string | undefined): OpencodeKnownToolName | null { + if (!value) { + return null; + } + + const normalized = value.trim().toLowerCase(); + return isKnownToolName(normalized) + ? normalized + : null; +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string') { + return value; + } + } + + return undefined; +} + +function firstTrimmedString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value !== 'string') { + continue; + } + + const trimmed = value.trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + + return undefined; +} + +function firstNonEmptyString(...values: unknown[]): string { + return firstTrimmedString(...values) ?? ''; +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + const uniqueValues = new Set(); + for (const entry of value) { + if (typeof entry !== 'string') { + continue; + } + + const trimmed = entry.trim(); + if (!trimmed) { + continue; + } + + uniqueValues.add(trimmed); + } + + return [...uniqueValues]; +} + +function normalizeQuestionOptions(value: unknown): Array<{ description: string; label: string }> { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((option) => { + if (typeof option === 'string') { + const label = option.trim(); + return label ? [{ description: '', label }] : []; + } + + if (!isPlainObject(option)) { + return []; + } + + const label = typeof option.label === 'string' ? option.label.trim() : ''; + if (!label) { + return []; + } + + return [{ + description: typeof option.description === 'string' ? option.description : '', + label, + }]; + }); +} + +function normalizeQuestionItems(value: unknown): AskUserQuestionItem[] { + if (!Array.isArray(value)) { + return []; + } + + return value.map((item, index) => { + const record = isPlainObject(item) ? item : {}; + const question = firstTrimmedString(record.question) ?? `Question ${index + 1}`; + const header = firstTrimmedString(record.header) ?? `Q${index + 1}`; + + return { + ...(typeof record.id === 'string' && record.id.trim() + ? { id: record.id } + : {}), + header, + multiSelect: record.multiSelect === true || record.multi_select === true || record.multiple === true, + options: normalizeQuestionOptions(record.options), + question, + }; + }); +} + +function normalizeTodoStatus(value: unknown): 'completed' | 'in_progress' | 'pending' { + switch (value) { + case 'completed': + case 'cancelled': + return 'completed'; + case 'in_progress': + return 'in_progress'; + default: + return 'pending'; + } +} + +function normalizeTodos(value: unknown): Array> { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((item) => { + if (!isPlainObject(item)) { + return []; + } + + const content = firstTrimmedString(item.content, item.title, item.description); + if (!content) { + return []; + } + + return [{ + activeForm: firstTrimmedString(item.activeForm, item.active_form) ?? content, + content, + ...(typeof item.id === 'string' ? { id: item.id } : {}), + status: normalizeTodoStatus(item.status), + }]; + }); +} + +function normalizeQuestionAnswers( + rawAnswers: unknown, + questions: AskUserQuestionItem[], +): AskUserAnswers | undefined { + if (!Array.isArray(rawAnswers) || questions.length === 0) { + return undefined; + } + + const answers: AskUserAnswers = {}; + + for (let index = 0; index < Math.min(rawAnswers.length, questions.length); index += 1) { + const question = questions[index]; + const rawEntry = (rawAnswers as unknown[])[index]; + if (!question) { + continue; + } + + const values = Array.isArray(rawEntry) + ? rawEntry + .filter((value: unknown): value is string => typeof value === 'string' && value.trim().length > 0) + : typeof rawEntry === 'string' && rawEntry.trim().length > 0 + ? [rawEntry] + : []; + + if (values.length === 0) { + continue; + } + + const normalizedValue = values.length === 1 ? values[0] : values; + answers[question.question] = normalizedValue; + if (question.id) { + answers[question.id] = normalizedValue; + } + } + + return Object.keys(answers).length > 0 ? answers : undefined; +} + +function extractToolMetadata(rawOutput: unknown): Record | null { + if (!isPlainObject(rawOutput)) { + return null; + } + + return isPlainObject(rawOutput.metadata) ? rawOutput.metadata : null; +} + +export function resolveOpencodeRawToolName( + currentRawName: string | undefined, + update: { + kind?: string | null; + title?: string | null; + }, +): string { + const titleName = firstTrimmedString(update.title); + const knownTitleName = titleName && isKnownToolName(titleName) + ? titleName.trim().toLowerCase() + : undefined; + + if (knownTitleName) { + return knownTitleName; + } + + if (currentRawName) { + return currentRawName; + } + + switch (update.kind) { + case 'execute': + return 'bash'; + case 'fetch': + return 'webfetch'; + case 'read': + return 'read'; + default: + return titleName ?? 'tool'; + } +} + +function normalizeWebSearchInput(input: Record): Record { + const action = isPlainObject(input.action) + ? input.action + : {}; + + const queries = normalizeStringArray(action.queries ?? input.queries); + const query = firstNonEmptyString(action.query, input.query, queries[0]); + const url = firstNonEmptyString(action.url, input.url); + const pattern = firstNonEmptyString(action.pattern, input.pattern); + const explicitType = firstNonEmptyString(action.type, input.actionType, input.action_type); + + const actionType = explicitType + || (url && pattern ? 'find_in_page' : url ? 'open_page' : (query || queries.length > 0) ? 'search' : ''); + + const normalized: Record = {}; + if (actionType) { + normalized.actionType = actionType; + } + if (query) { + normalized.query = query; + } + if (queries.length > 0) { + normalized.queries = queries; + } + if (url) { + normalized.url = url; + } + if (pattern) { + normalized.pattern = pattern; + } + + return normalized; +} + +export function normalizeOpencodeToolName(rawName: string | undefined): string { + const knownName = toKnownToolName(rawName); + if (!knownName) { + return rawName?.trim() || 'tool'; + } + + return TOOL_NAME_MAP[knownName]; +} + +export function normalizeOpencodeToolInput( + rawName: string | undefined, + input: Record, +): Record { + const knownName = toKnownToolName(rawName); + switch (knownName) { + case 'question': + return { questions: normalizeQuestionItems(input.questions) }; + case 'read': + return { + ...(firstString(input.file_path, input.filePath) ? { file_path: firstString(input.file_path, input.filePath) } : {}), + ...(typeof input.limit === 'number' ? { limit: input.limit } : {}), + ...(typeof input.offset === 'number' ? { offset: input.offset } : {}), + }; + case 'write': + return { + ...(typeof input.content === 'string' ? { content: input.content } : {}), + ...(firstString(input.file_path, input.filePath) ? { file_path: firstString(input.file_path, input.filePath) } : {}), + }; + case 'edit': + return { + ...(firstString(input.file_path, input.filePath) ? { file_path: firstString(input.file_path, input.filePath) } : {}), + ...(firstString(input.old_string, input.oldString) ? { old_string: firstString(input.old_string, input.oldString) } : {}), + ...(firstString(input.new_string, input.newString) ? { new_string: firstString(input.new_string, input.newString) } : {}), + ...(typeof input.replace_all === 'boolean' + ? { replace_all: input.replace_all } + : typeof input.replaceAll === 'boolean' + ? { replace_all: input.replaceAll } + : {}), + }; + case 'task': + return { + ...(firstTrimmedString(input.command) ? { command: firstTrimmedString(input.command) } : {}), + ...(firstTrimmedString(input.description) ? { description: firstTrimmedString(input.description) } : {}), + ...(firstTrimmedString(input.prompt) ? { prompt: firstTrimmedString(input.prompt) } : {}), + ...(input.run_in_background === true || input.run_in_background === false + ? { run_in_background: input.run_in_background } + : {}), + ...(firstTrimmedString(input.subagent_type) ? { subagent_type: firstTrimmedString(input.subagent_type) } : {}), + ...(firstTrimmedString(input.task_id) ? { task_id: firstTrimmedString(input.task_id) } : {}), + }; + case 'todowrite': + return { todos: normalizeTodos(input.todos) }; + case 'skill': + return firstTrimmedString(input.skill, input.name) + ? { skill: firstTrimmedString(input.skill, input.name) } + : {}; + case 'websearch': + return normalizeWebSearchInput(input); + default: + return input; + } +} + +export function normalizeOpencodeToolUseResult( + rawName: string | undefined, + input: Record, + rawOutput: unknown, +): SDKToolUseResult | undefined { + const knownName = toKnownToolName(rawName); + const metadata = extractToolMetadata(rawOutput); + const normalized: SDKToolUseResult = {}; + + if ( + (knownName === 'write' || knownName === 'edit') + && firstString(input.file_path, input.filePath, metadata?.filepath, metadata?.filePath) + ) { + normalized.filePath = firstString(input.file_path, input.filePath, metadata?.filepath, metadata?.filePath); + } + + if (knownName === 'question') { + const questions = Array.isArray(input.questions) + ? input.questions as AskUserQuestionItem[] + : []; + const answers = normalizeQuestionAnswers(metadata?.answers, questions); + if (answers) { + normalized.answers = answers; + } + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +export function createOpencodeToolStreamAdapter(): AcpToolStreamAdapter { + return new AcpToolStreamAdapter({ + normalizeToolInput: normalizeOpencodeToolInput, + normalizeToolName: normalizeOpencodeToolName, + normalizeToolUseResult: normalizeOpencodeToolUseResult, + resolveRawToolName: resolveOpencodeRawToolName, + }); +} diff --git a/src/providers/opencode/registration.ts b/src/providers/opencode/registration.ts new file mode 100644 index 0000000..96deea9 --- /dev/null +++ b/src/providers/opencode/registration.ts @@ -0,0 +1,37 @@ +import type { ProviderModule } from '../../core/providers/types'; +import { opencodeWorkspaceRegistration } from './app/OpencodeWorkspaceServices'; +import { OpencodeInlineEditService } from './auxiliary/OpencodeInlineEditService'; +import { OpencodeInstructionRefineService } from './auxiliary/OpencodeInstructionRefineService'; +import { OpencodeTaskResultInterpreter } from './auxiliary/OpencodeTaskResultInterpreter'; +import { OpencodeTitleGenerationService } from './auxiliary/OpencodeTitleGenerationService'; +import { OPENCODE_PROVIDER_CAPABILITIES } from './capabilities'; +import { opencodeSettingsReconciler } from './env/OpencodeSettingsReconciler'; +import { OpencodeConversationHistoryService } from './history/OpencodeConversationHistoryService'; +import { OpencodeChatRuntime } from './runtime/OpencodeChatRuntime'; +import { getOpencodeProviderSettings, updateOpencodeProviderSettings } from './settings'; +import { opencodeChatUIConfig } from './ui/OpencodeChatUIConfig'; + +export const opencodeProviderRegistration: ProviderModule = { + id: 'opencode', + blankTabOrder: 10, + capabilities: OPENCODE_PROVIDER_CAPABILITIES, + chatUIConfig: opencodeChatUIConfig, + createInlineEditService: (plugin) => new OpencodeInlineEditService(plugin), + createInstructionRefineService: (plugin) => new OpencodeInstructionRefineService(plugin), + createRuntime: ({ plugin }) => new OpencodeChatRuntime(plugin), + createTitleGenerationService: (plugin) => new OpencodeTitleGenerationService(plugin), + displayName: 'OpenCode', + environmentKeyPatterns: [/^OPENCODE_/i], + historyService: new OpencodeConversationHistoryService(), + isEnabled: (settings) => getOpencodeProviderSettings(settings).enabled, + settingsReconciler: opencodeSettingsReconciler, + settingsStorage: { + hostScopedFields: ['cliPathsByHost'], + normalizeStored(target, stored) { + updateOpencodeProviderSettings(target, getOpencodeProviderSettings(stored)); + return false; + }, + }, + taskResultInterpreter: new OpencodeTaskResultInterpreter(), + workspace: opencodeWorkspaceRegistration, +}; diff --git a/src/providers/opencode/runtime/OpencodeAuxQueryRunner.ts b/src/providers/opencode/runtime/OpencodeAuxQueryRunner.ts new file mode 100644 index 0000000..8566b4b --- /dev/null +++ b/src/providers/opencode/runtime/OpencodeAuxQueryRunner.ts @@ -0,0 +1,436 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import type { AuxQueryConfig, AuxQueryRunner } from '../../../core/auxiliary/AuxQueryRunner'; +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import { getVaultPath } from '../../../utils/path'; +import { + AcpClientConnection, + AcpJsonRpcTransport, + type AcpReadTextFileRequest, + type AcpRequestPermissionRequest, + type AcpRequestPermissionResponse, + AcpSessionUpdateNormalizer, + AcpSubprocess, + extractAcpSessionModelState, +} from '../../acp'; +import { decodeOpencodeModelId } from '../models'; +import { opencodeChatUIConfig } from '../ui/OpencodeChatUIConfig'; +import { + type OpencodeManagedAgentConfig, + prepareOpencodeLaunchArtifacts, +} from './OpencodeLaunchArtifacts'; +import { buildOpencodeRuntimeEnv } from './OpencodeRuntimeEnvironment'; + +type OpencodeAuxAgentProfile = 'passive' | 'readonly'; +type OpencodeAuxArtifactPurpose = 'inline' | 'instructions' | 'title-gen'; + +interface OpencodeAuxQueryRunnerOptions { + agentProfile: OpencodeAuxAgentProfile; + artifactPurpose: OpencodeAuxArtifactPurpose; + allowReadTextFile?: boolean; +} + +const OPENCODE_AUX_AGENT_IDS: Record = { + passive: 'claudian-aux-passive', + readonly: 'claudian-aux-readonly', +}; + +const OPENCODE_AUX_READ_PERMISSION = Object.freeze({ + '*': 'allow', + '*.env': 'deny', + '*.env.*': 'deny', + '*.env.example': 'allow', +}); + +export class OpencodeAuxQueryRunner implements AuxQueryRunner { + private availableModelIds = new Set(); + private connection: AcpClientConnection | null = null; + private currentModelId: string | null = null; + private currentLaunchKey: string | null = null; + private process: AcpSubprocess | null = null; + private readonly sessionCwds = new Map(); + private sessionId: string | null = null; + private readonly sessionUpdateNormalizer = new AcpSessionUpdateNormalizer(); + private transport: AcpJsonRpcTransport | null = null; + + constructor( + private readonly plugin: ProviderHost, + private readonly options: OpencodeAuxQueryRunnerOptions, + ) {} + + async query(config: AuxQueryConfig, prompt: string): Promise { + const cwd = getVaultPath(this.plugin.app) ?? process.cwd(); + await this.ensureReady(cwd, config.systemPrompt); + + if (!this.connection) { + throw new Error('OpenCode runtime is not ready.'); + } + + if (!this.sessionId) { + const sessionId = await this.createSession(cwd); + if (!sessionId) { + throw new Error('Failed to create an OpenCode session.'); + } + } + + const sessionId = this.sessionId!; + const selectedModel = this.resolveSelectedRawModel(config.model); + const nextModel = this.resolveApplicableModel(selectedModel); + if (nextModel) { + const response = await this.connection.setConfigOption({ + configId: 'model', + sessionId, + type: 'select', + value: nextModel, + }); + this.syncSessionModelState({ + configOptions: response.configOptions, + }); + } + + this.sessionUpdateNormalizer.reset(); + let accumulatedText = ''; + const removeListener = this.connection.onSessionNotification((notification) => { + if (notification.sessionId !== sessionId) { + return; + } + + const normalized = this.sessionUpdateNormalizer.normalize(notification.update); + if (normalized.type !== 'message_chunk' || normalized.role !== 'assistant') { + return; + } + + for (const chunk of normalized.streamChunks) { + if (chunk.type !== 'text') { + continue; + } + + accumulatedText += chunk.content; + config.onTextChunk?.(accumulatedText); + } + }); + + const abortHandler = () => { + if (this.connection && this.sessionId) { + this.connection.cancel({ sessionId: this.sessionId }); + } + }; + config.abortController?.signal.addEventListener('abort', abortHandler, { once: true }); + + try { + if (config.abortController?.signal.aborted) { + throw new Error('Cancelled'); + } + + await this.connection.prompt({ + prompt: [{ type: 'text', text: prompt }], + sessionId, + }); + + if (config.abortController?.signal.aborted) { + throw new Error('Cancelled'); + } + + return accumulatedText; + } catch (error) { + const message = error instanceof Error ? error.message : 'OpenCode request failed'; + const stderr = this.process?.getStderrSnapshot(); + throw new Error( + stderr ? `${message}\n\n${stderr}` : message, + error instanceof Error ? { cause: error } : undefined, + ); + } finally { + config.abortController?.signal.removeEventListener('abort', abortHandler); + removeListener(); + } + } + + reset(): void { + this.availableModelIds.clear(); + this.sessionId = null; + this.sessionCwds.clear(); + this.currentModelId = null; + this.currentLaunchKey = null; + this.connection?.dispose(); + this.connection = null; + this.transport?.dispose(); + this.transport = null; + if (this.process) { + void this.process.shutdown().catch(() => {}); + } + this.process = null; + this.sessionUpdateNormalizer.reset(); + } + + private async ensureReady(cwd: string, systemPrompt: string): Promise { + const resolvedCliPath = this.plugin.getResolvedProviderCliPath('opencode') ?? 'opencode'; + + const settings = this.plugin.settings as unknown as Record; + const runtimeEnv = buildOpencodeRuntimeEnv(settings, resolvedCliPath); + const auxAgentId = OPENCODE_AUX_AGENT_IDS[this.options.agentProfile]; + const artifacts = await prepareOpencodeLaunchArtifacts({ + artifactsSubdir: `opencode/auxiliary/${this.options.artifactPurpose}`, + defaultAgentId: auxAgentId, + managedAgents: [buildOpencodeAuxAgentConfig(this.options.agentProfile)], + runtimeEnv, + systemPromptKey: systemPrompt, + systemPromptText: systemPrompt, + userName: typeof settings.userName === 'string' ? settings.userName : undefined, + workspaceRoot: cwd, + }); + const nextLaunchKey = JSON.stringify({ + artifactKey: artifacts.launchKey, + command: resolvedCliPath, + configPath: artifacts.configPath, + envText: getRuntimeEnvironmentText(settings, 'opencode'), + }); + + const shouldRestart = !this.process + || !this.transport + || !this.connection + || !this.process.isAlive() + || this.transport.isClosed + || this.currentLaunchKey !== nextLaunchKey; + + if (!shouldRestart) { + return; + } + + this.reset(); + await this.startProcess({ + command: resolvedCliPath, + configPath: artifacts.configPath, + configContent: artifacts.configContent, + cwd, + runtimeEnv, + }); + this.currentLaunchKey = nextLaunchKey; + } + + private async createSession(cwd: string): Promise { + if (!this.connection) { + return null; + } + + try { + const response = await this.connection.newSession({ + cwd, + mcpServers: [], + }); + this.syncSessionModelState({ + configOptions: response.configOptions ?? null, + models: response.models ?? null, + }); + await this.connection.setConfigOption({ + configId: 'mode', + sessionId: response.sessionId, + type: 'select', + value: OPENCODE_AUX_AGENT_IDS[this.options.agentProfile], + }); + this.sessionId = response.sessionId; + this.sessionCwds.set(response.sessionId, cwd); + return response.sessionId; + } catch { + return null; + } + } + + private async startProcess(params: { + command: string; + configPath: string; + configContent: string; + cwd: string; + runtimeEnv: NodeJS.ProcessEnv; + }): Promise { + const processEnv: NodeJS.ProcessEnv = { + ...process.env, + ...params.runtimeEnv, + OPENCODE_CONFIG: params.configPath, + OPENCODE_CONFIG_CONTENT: params.configContent, + PATH: params.runtimeEnv.PATH, + }; + + this.process = new AcpSubprocess({ + args: ['acp', `--cwd=${params.cwd}`], + command: params.command, + cwd: params.cwd, + env: processEnv, + }); + this.process.start(); + + this.transport = new AcpJsonRpcTransport({ + input: this.process.stdout, + onClose: (listener) => this.process!.onClose(listener), + output: this.process.stdin, + }); + + this.connection = new AcpClientConnection({ + clientInfo: { + name: 'claudian-aux', + version: this.plugin.manifest?.version ?? '0.0.0', + }, + delegate: { + fileSystem: this.options.allowReadTextFile + ? { + readTextFile: (request) => this.readTextFile(request), + } + : undefined, + requestPermission: (request) => this.handlePermissionRequest(request), + }, + transport: this.transport, + }); + + this.transport.start(); + await this.connection.initialize(); + } + + private async readTextFile( + request: AcpReadTextFileRequest, + ): Promise<{ content: string }> { + const resolvedPath = this.resolveSessionPath(request.sessionId, request.path); + const content = await fs.readFile(resolvedPath, 'utf-8'); + + if (request.line === undefined && request.limit === undefined) { + return { content }; + } + + const lines = content.split(/\r?\n/); + const startIndex = Math.max(0, (request.line ?? 1) - 1); + const endIndex = request.limit + ? startIndex + Math.max(0, request.limit) + : lines.length; + + return { + content: lines.slice(startIndex, endIndex).join('\n'), + }; + } + + private async handlePermissionRequest( + request: AcpRequestPermissionRequest, + ): Promise { + return selectPermissionOption(request.options, ['reject_once', 'reject_always']); + } + + private resolveSelectedRawModel(explicitModel?: string): string | undefined { + const projectedSettings = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + this.plugin.settings, + 'opencode', + ); + if (explicitModel) { + const trimmed = explicitModel.trim(); + if (!trimmed) { + return undefined; + } + return opencodeChatUIConfig.ownsModel(trimmed, projectedSettings) + ? decodeOpencodeModelId(trimmed) ?? undefined + : trimmed; + } + + const selectedModel = typeof projectedSettings.model === 'string' + ? projectedSettings.model + : ''; + return opencodeChatUIConfig.ownsModel(selectedModel, projectedSettings) + ? decodeOpencodeModelId(selectedModel) ?? undefined + : undefined; + } + + private resolveApplicableModel(selectedModel: string | undefined): string | null { + if (!selectedModel) { + return null; + } + if (selectedModel === this.currentModelId) { + return null; + } + if (this.availableModelIds.size === 0) { + return selectedModel; + } + return this.availableModelIds.has(selectedModel) + ? selectedModel + : null; + } + + private syncSessionModelState(params: { + configOptions?: Parameters[0]['configOptions']; + models?: Parameters[0]['models']; + }): void { + const state = extractAcpSessionModelState(params); + this.currentModelId = state.currentModelId; + this.availableModelIds = new Set(state.availableModels.map((model) => model.id)); + } + + private resolveSessionPath(sessionId: string, rawPath: string): string { + const cwd = this.sessionCwds.get(sessionId) + ?? getVaultPath(this.plugin.app) + ?? process.cwd(); + const resolvedPath = path.isAbsolute(rawPath) + ? path.resolve(rawPath) + : path.resolve(cwd, rawPath); + const relative = path.relative(cwd, resolvedPath); + if (relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))) { + return resolvedPath; + } + + throw new Error('OpenCode aux read access is limited to the current workspace.'); + } +} + +function buildOpencodeAuxAgentConfig(profile: OpencodeAuxAgentProfile): OpencodeManagedAgentConfig { + const id = OPENCODE_AUX_AGENT_IDS[profile]; + if (profile === 'readonly') { + return { + definition: { + description: 'Internal Claudian read-only agent for OpenCode auxiliary tasks.', + mode: 'primary', + permission: { + '*': 'deny', + codesearch: 'allow', + external_directory: 'deny', + glob: 'allow', + grep: 'allow', + lsp: 'allow', + read: OPENCODE_AUX_READ_PERMISSION, + webfetch: 'allow', + websearch: 'allow', + }, + }, + id, + }; + } + + return { + definition: { + description: 'Internal Claudian no-tool agent for OpenCode auxiliary tasks.', + mode: 'primary', + permission: { + '*': 'deny', + external_directory: 'deny', + }, + }, + id, + }; +} + +function selectPermissionOption( + options: readonly { + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; + optionId: string; + }[], + preferredKinds: readonly ('allow_once' | 'allow_always' | 'reject_once' | 'reject_always')[], +): AcpRequestPermissionResponse { + for (const kind of preferredKinds) { + const option = options.find((entry) => entry.kind === kind); + if (option) { + return { + outcome: { + optionId: option.optionId, + outcome: 'selected', + }, + }; + } + } + + return { outcome: { outcome: 'cancelled' } }; +} diff --git a/src/providers/opencode/runtime/OpencodeChatRuntime.ts b/src/providers/opencode/runtime/OpencodeChatRuntime.ts new file mode 100644 index 0000000..96e5cb5 --- /dev/null +++ b/src/providers/opencode/runtime/OpencodeChatRuntime.ts @@ -0,0 +1,1874 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { + computeSystemPromptKey, + type SystemPromptSettings, +} from '../../../core/prompt/mainAgent'; +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { ProviderRegistry } from '../../../core/providers/ProviderRegistry'; +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import type { + ProviderCapabilities, +} from '../../../core/providers/types'; +import type { ChatRuntime } from '../../../core/runtime/ChatRuntime'; +import type { + ApprovalCallback, + ApprovalDecisionOption, + AskUserQuestionCallback, + AutoTurnCallback, + ChatRewindMode, + ChatRewindResult, + ChatRuntimeConversationState, + ChatRuntimeEnsureReadyOptions, + ChatRuntimeQueryOptions, + ChatTurnMetadata, + ChatTurnRequest, + PreparedChatTurn, + SessionUpdateResult, + SubagentRuntimeState, +} from '../../../core/runtime/types'; +import type { + ApprovalDecision, + ChatMessage, + Conversation, + ExitPlanModeCallback, + SlashCommand, + StreamChunk, + ToolCallInfo, +} from '../../../core/types'; +import { getEnhancedPath } from '../../../utils/env'; +import { getVaultPath } from '../../../utils/path'; +import { + AcpClientConnection, + AcpJsonRpcTransport, + type AcpReadTextFileRequest, + type AcpRequestPermissionRequest, + type AcpRequestPermissionResponse, + type AcpSessionConfigOption, + type AcpSessionModelState, + type AcpSessionModeState, + type AcpSessionNotification, + AcpSessionUpdateNormalizer, + AcpSubprocess, + type AcpUsage, + type AcpUsageUpdate, + type AcpWriteTextFileRequest, + buildAcpUsageInfo, + extractAcpSessionModelState, + extractAcpSessionModeState, + extractAcpSessionThoughtLevelState, +} from '../../acp'; +import { OPENCODE_PROVIDER_CAPABILITIES } from '../capabilities'; +import { updateOpencodeDiscoveryState } from '../discoveryState'; +import { + sameDiscoveredModels, + sameModes, + sameStringList, + sameStringMap, + sameThinkingOptionsByModel, +} from '../internal/compareCollections'; +import { ensureProviderProjectionMap } from '../internal/providerProjection'; +import { + decodeOpencodeModelId, + encodeOpencodeModelId, + isOpencodeModelSelectionId, + normalizeOpencodeDiscoveredModels, + normalizeOpencodeModelVariants, + OPENCODE_DEFAULT_THINKING_LEVEL, + OPENCODE_SYNTHETIC_MODEL_ID, + resolveOpencodeBaseModelRawId, + resolveOpencodeDefaultThinkingLevel, +} from '../models'; +import { + getManagedOpencodeModes, + isManagedOpencodeModeId, + normalizeOpencodeAvailableModes, + resolveOpencodeModeForPermissionMode, + resolvePermissionModeForManagedOpencodeMode, +} from '../modes'; +import { createOpencodeToolStreamAdapter } from '../normalization/opencodeToolNormalization'; +import { getOpencodeProviderSettings, updateOpencodeProviderSettings } from '../settings'; +import { getOpencodeState, type OpencodeProviderState } from '../types'; +import { buildOpencodePromptBlocks, buildOpencodePromptText } from './buildOpencodePrompt'; +import { prepareOpencodeLaunchArtifacts } from './OpencodeLaunchArtifacts'; +import { buildOpencodeRuntimeEnv } from './OpencodeRuntimeEnvironment'; + +interface ActiveTurn { + cancelled: boolean; + queue: StreamChunkQueue; + sessionId: string; +} + +class StreamChunkQueue { + private closed = false; + private readonly items: StreamChunk[] = []; + private readonly waiters: Array<(chunk: StreamChunk | null) => void> = []; + + push(chunk: StreamChunk): void { + if (this.closed) { + return; + } + const waiter = this.waiters.shift(); + if (waiter) { + waiter(chunk); + return; + } + this.items.push(chunk); + } + + close(): void { + if (this.closed) { + return; + } + + this.closed = true; + while (this.waiters.length > 0) { + this.waiters.shift()?.(null); + } + } + + async next(): Promise { + if (this.items.length > 0) { + return this.items.shift() ?? null; + } + + if (this.closed) { + return null; + } + + return new Promise((resolve) => { + this.waiters.push(resolve); + }); + } +} + +export class OpencodeChatRuntime implements ChatRuntime { + readonly providerId = 'opencode' as const; + + private activeTurn: ActiveTurn | null = null; + private approvalCallback: ApprovalCallback | null = null; + private connection: AcpClientConnection | null = null; + private connectionGeneration = 0; + private conversationId: string | null = null; + private conversationGeneration = 0; + private contextUsage: AcpUsageUpdate | null = null; + private currentDatabasePath: string | null = null; + private currentLaunchKey: string | null = null; + private currentSessionEffortConfigId: string | null = null; + private currentSessionEffortValue: string | null = null; + private currentSessionEffortValues = new Set(); + private currentSessionModelId: string | null = null; + private currentConversationModel: string | null = null; + private currentSessionModeId: string | null = null; + private currentTurnMetadata: ChatTurnMetadata = {}; + private loadedSessionId: string | null = null; + private permissionModeSyncCallback: ((mode: string) => void) | null = null; + private process: AcpSubprocess | null = null; + private promptUsage: AcpUsage | null = null; + private readonly readyListeners: Array<(ready: boolean) => void> = []; + private ready = false; + private readinessFlight: { key: string; promise: Promise } | null = null; + private disposed = false; + private lifecycleGeneration = 0; + private restartRequiredAfterCancel = false; + private sessionInvalidated = false; + private readonly supportedCommandWaiters: Array<(commands: SlashCommand[]) => void> = []; + private supportedCommands: SlashCommand[] = []; + private sessionCwds = new Map(); + private sessionId: string | null = null; + private readonly sessionUpdateNormalizer = new AcpSessionUpdateNormalizer(); + private readonly toolStreamAdapter = createOpencodeToolStreamAdapter(); + private transport: AcpJsonRpcTransport | null = null; + private unregisterTransportClose: (() => void) | null = null; + + constructor( + private readonly plugin: ProviderHost, + ) {} + + getCapabilities(): Readonly { + return OPENCODE_PROVIDER_CAPABILITIES; + } + + prepareTurn(request: ChatTurnRequest): PreparedChatTurn { + return { + isCompact: false, + mcpMentions: request.enabledMcpServers ?? new Set(), + persistedContent: '', + prompt: buildOpencodePromptText(request), + request, + }; + } + + onReadyStateChange(listener: (ready: boolean) => void): () => void { + this.readyListeners.push(listener); + return () => { + const index = this.readyListeners.indexOf(listener); + if (index >= 0) { + this.readyListeners.splice(index, 1); + } + }; + } + + setResumeCheckpoint(_checkpointId: string | undefined): void {} + + syncConversationState( + conversation: ChatRuntimeConversationState | null, + ): void { + this.setCurrentConversationModel(conversation?.selectedModel); + const previousSessionId = this.sessionId; + const nextConversationId = conversation?.id ?? null; + const nextSessionId = conversation?.sessionId ?? null; + const state = getOpencodeState(conversation?.providerState); + const nextDatabasePath = state.databasePath + ?? ((!nextSessionId || nextSessionId !== previousSessionId) ? null : this.currentDatabasePath); + const targetChanged = nextConversationId !== this.conversationId + || nextSessionId !== this.sessionId + || nextDatabasePath !== this.currentDatabasePath; + if (this.sessionId !== nextSessionId) { + this.currentSessionEffortConfigId = null; + this.currentSessionEffortValue = null; + this.currentSessionEffortValues = new Set(); + this.currentSessionModelId = null; + this.currentSessionModeId = null; + this.sessionInvalidated = false; + this.setSupportedCommands([]); + } + this.conversationId = nextConversationId; + this.sessionId = nextSessionId; + this.currentDatabasePath = nextDatabasePath; + if (targetChanged) { + this.conversationGeneration += 1; + if (this.readinessFlight) { + void this.shutdownProcess(); + } + } + } + + async reloadMcpServers(): Promise {} + + async warmModelMetadata(model: string): Promise { + const conversationGeneration = this.conversationGeneration; + const selectedRawModelId = decodeOpencodeModelId(model); + if (!selectedRawModelId) { + return false; + } + + if (!(await this.ensureReady({ allowSessionCreation: true }))) { + return false; + } + if ( + !this.connection + || !this.sessionId + || !this.isConversationCurrent(conversationGeneration) + ) { + return false; + } + + const discoveredModels = getOpencodeProviderSettings(this.plugin.settings).discoveredModels; + const selectedBaseRawModelId = resolveOpencodeBaseModelRawId(selectedRawModelId, discoveredModels); + if (!selectedBaseRawModelId) { + return false; + } + + const availableModelIds = new Set(discoveredModels.map((entry) => entry.rawId)); + if (availableModelIds.size > 0 && !availableModelIds.has(selectedBaseRawModelId)) { + return false; + } + + const response = await this.connection.setConfigOption({ + configId: 'model', + sessionId: this.sessionId, + type: 'select', + value: selectedBaseRawModelId, + }); + if (!this.isConversationCurrent(conversationGeneration)) { + return false; + } + this.currentSessionModelId = selectedBaseRawModelId; + await this.syncSessionModelState({ + configOptions: response.configOptions, + }, conversationGeneration); + return this.isConversationCurrent(conversationGeneration); + } + + async ensureReady(options?: ChatRuntimeEnsureReadyOptions): Promise { + if (this.disposed) { + return false; + } + const conversationGeneration = this.conversationGeneration; + const key = JSON.stringify({ conversationGeneration, options: options ?? {} }); + if (this.readinessFlight) { + if (this.readinessFlight.key === key) { + return this.readinessFlight.promise; + } + await this.readinessFlight.promise.catch(() => undefined); + return this.ensureReady(options); + } + + const lifecycleGeneration = this.lifecycleGeneration; + const promise = this.ensureReadyInternal( + options, + lifecycleGeneration, + conversationGeneration, + ); + this.readinessFlight = { key, promise }; + return promise.finally(() => { + if (this.readinessFlight?.promise === promise) { + this.readinessFlight = null; + } + }); + } + + private async ensureReadyInternal( + options: ChatRuntimeEnsureReadyOptions | undefined, + lifecycleGeneration: number, + conversationGeneration: number, + ): Promise { + const settings = getOpencodeProviderSettings(this.plugin.settings); + if (!settings.enabled) { + this.setReady(false); + return false; + } + + const cwd = getVaultPath(this.plugin.app) ?? process.cwd(); + const targetSessionId = this.sessionId; + const resolvedCliPath = this.plugin.getResolvedProviderCliPath('opencode') ?? 'opencode'; + const runtimeEnv = this.buildRuntimeEnv( + resolvedCliPath, + this.currentDatabasePath, + ); + const promptSettings = this.getSystemPromptSettings(cwd); + const artifacts = await prepareOpencodeLaunchArtifacts({ + runtimeEnv, + settings: promptSettings, + workspaceRoot: cwd, + }); + if (!this.isReadinessCurrent(lifecycleGeneration, conversationGeneration)) { + return false; + } + this.currentDatabasePath = artifacts.databasePath; + + const nextLaunchKey = JSON.stringify({ + command: resolvedCliPath, + configPath: artifacts.configPath, + envText: getRuntimeEnvironmentText(this.plugin.settings, 'opencode'), + promptKey: computeSystemPromptKey(promptSettings), + artifactKey: artifacts.launchKey, + }); + + const shouldRestart = !this.process + || !this.transport + || !this.connection + || !this.process.isAlive() + || this.transport.isClosed + || options?.force === true + || this.restartRequiredAfterCancel + || this.currentLaunchKey !== nextLaunchKey; + + if (shouldRestart) { + await this.shutdownProcess(); + if (!this.isReadinessCurrent(lifecycleGeneration, conversationGeneration)) { + return false; + } + await this.startProcess({ + command: resolvedCliPath, + configPath: artifacts.configPath, + cwd, + runtimeEnv, + }); + if (!this.isReadinessCurrent(lifecycleGeneration, conversationGeneration)) { + await this.shutdownProcess(); + return false; + } + this.restartRequiredAfterCancel = false; + this.currentLaunchKey = nextLaunchKey; + this.loadedSessionId = null; + this.setReady(true); + } + + if (targetSessionId) { + if (this.loadedSessionId !== targetSessionId) { + const loaded = await this.loadSession(targetSessionId, cwd, conversationGeneration); + if (!this.isReadinessCurrent(lifecycleGeneration, conversationGeneration)) { + await this.shutdownProcess(); + return false; + } + if (!loaded) { + this.sessionInvalidated = true; + this.clearActiveSession(); + } + } + return true; + } + + if (!this.sessionId && !this.sessionInvalidated) { + if (options?.allowSessionCreation === false) { + return true; + } + const sessionId = await this.createSession(cwd, conversationGeneration); + if (!this.isReadinessCurrent(lifecycleGeneration, conversationGeneration)) { + await this.shutdownProcess(); + return false; + } + return Boolean(sessionId); + } + + return true; + } + + async *query( + turn: PreparedChatTurn, + conversationHistory?: ChatMessage[], + queryOptions?: ChatRuntimeQueryOptions, + ): AsyncGenerator { + if (this.activeTurn) { + yield { type: 'error', content: 'OpenCode does not support overlapping turns.' }; + yield { type: 'done' }; + return; + } + if (queryOptions?.model) { + this.setCurrentConversationModel(queryOptions.model); + } + const conversationGeneration = this.conversationGeneration; + const previousMessages = conversationHistory ?? []; + const expectedSessionId = this.sessionId; + let shouldBootstrapHistory = previousMessages.length > 0 + && (!expectedSessionId || this.sessionInvalidated); + + if (!(await this.ensureReady())) { + yield { type: 'error', content: 'Failed to start OpenCode. Check the CLI path and login state.' }; + yield { type: 'done' }; + return; + } + + if (!this.isConversationCurrent(conversationGeneration)) { + yield { type: 'error', content: 'OpenCode conversation changed before the turn started.' }; + yield { type: 'done' }; + return; + } + + if (!this.connection) { + yield { type: 'error', content: 'OpenCode runtime is not ready.' }; + yield { type: 'done' }; + return; + } + + const cwd = getVaultPath(this.plugin.app) ?? process.cwd(); + if (expectedSessionId && !this.sessionId) { + shouldBootstrapHistory = previousMessages.length > 0; + } + + if (!this.sessionId) { + const sessionId = await this.createSession(cwd, conversationGeneration); + if (!sessionId) { + yield { type: 'error', content: 'Failed to create an OpenCode session.' }; + yield { type: 'done' }; + return; + } + } + + const sessionId = this.sessionId!; + this.activeTurn = { + cancelled: false, + queue: new StreamChunkQueue(), + sessionId, + }; + this.currentTurnMetadata = {}; + this.contextUsage = null; + this.promptUsage = null; + this.sessionUpdateNormalizer.reset(); + this.toolStreamAdapter.reset(); + + const activeTurn = this.activeTurn; + try { + await this.applySelectedMode(sessionId, conversationGeneration); + await this.applySelectedModel(sessionId, queryOptions, conversationGeneration); + await this.applySelectedEffort(sessionId, conversationGeneration); + if (!this.isConversationCurrent(conversationGeneration)) { + throw new Error('OpenCode conversation changed before the turn started.'); + } + } catch (error) { + yield { + type: 'error', + content: this.formatRuntimeError(error), + }; + yield { type: 'done' }; + activeTurn.queue.close(); + this.activeTurn = null; + return; + } + + const promptPromise = this.connection.prompt({ + prompt: buildOpencodePromptBlocks( + turn.request, + shouldBootstrapHistory ? previousMessages : [], + ), + sessionId, + }).then((response) => { + if (response.userMessageId) { + this.currentTurnMetadata.userMessageId = response.userMessageId; + } + this.promptUsage = response.usage ?? null; + + const usage = buildAcpUsageInfo({ + contextWindow: this.contextUsage, + model: this.getActiveDisplayModel(queryOptions), + promptUsage: this.promptUsage, + }); + if (usage) { + activeTurn.queue.push({ sessionId, type: 'usage', usage }); + } + + activeTurn.queue.push({ type: 'done' }); + activeTurn.queue.close(); + }).catch((error) => { + activeTurn.queue.push({ + type: 'error', + content: this.formatRuntimeError(error), + }); + activeTurn.queue.push({ type: 'done' }); + activeTurn.queue.close(); + }).finally(() => { + if (this.activeTurn === activeTurn) { + this.activeTurn = null; + } + }); + + try { + while (true) { + const chunk = await activeTurn.queue.next(); + if (!chunk) { + break; + } + yield chunk; + } + if (!activeTurn.cancelled) { + await promptPromise; + } + } finally { + if (this.activeTurn === activeTurn) { + this.activeTurn = null; + } + } + } + + cancel(): void { + const activeTurn = this.activeTurn; + if (!activeTurn || activeTurn.cancelled) { + return; + } + if (this.connection && this.sessionId) { + this.connection.cancel({ sessionId: this.sessionId }); + } + this.restartRequiredAfterCancel = true; + this.settleActiveTurn(); + } + + resetSession(): void { + this.clearActiveSession(); + this.sessionInvalidated = false; + } + + getSessionId(): string | null { + return this.sessionId; + } + + consumeSessionInvalidation(): boolean { + const invalidated = this.sessionInvalidated; + this.sessionInvalidated = false; + return invalidated; + } + + isReady(): boolean { + return this.ready; + } + + async getSupportedCommands(): Promise { + if (this.supportedCommands.length > 0 && this.loadedSessionId === this.sessionId) { + return [...this.supportedCommands]; + } + + if (this.sessionId && this.loadedSessionId !== this.sessionId) { + const ready = await this.ensureReady({ allowSessionCreation: false }); + if (!ready) { + return []; + } + } + + if (!this.sessionId) { + return []; + } + + if (this.supportedCommands.length > 0) { + return [...this.supportedCommands]; + } + + if (!this.sessionId || this.loadedSessionId !== this.sessionId) { + return []; + } + + return this.waitForSupportedCommands(); + } + + cleanup(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.lifecycleGeneration += 1; + this.activeTurn?.queue.close(); + void this.shutdownProcess(); + } + + async rewind( + _userMessageId: string, + _assistantMessageId: string | undefined, + _mode?: ChatRewindMode, + ): Promise { + return { canRewind: false }; + } + + setApprovalCallback(callback: ApprovalCallback | null): void { + this.approvalCallback = callback; + } + + setApprovalDismisser(_dismisser: (() => void) | null): void {} + + setAskUserQuestionCallback(_callback: AskUserQuestionCallback | null): void {} + + setExitPlanModeCallback(_callback: ExitPlanModeCallback | null): void {} + + setPermissionModeSyncCallback(callback: ((sdkMode: string) => void) | null): void { + this.permissionModeSyncCallback = callback; + } + + setSubagentHookProvider(_getState: () => SubagentRuntimeState): void {} + + setAutoTurnCallback(_callback: AutoTurnCallback | null): void {} + + consumeTurnMetadata(): ChatTurnMetadata { + const metadata = this.currentTurnMetadata; + this.currentTurnMetadata = {}; + return metadata; + } + + buildSessionUpdates(params: { + conversation: Conversation | null; + sessionInvalidated: boolean; + }): SessionUpdateResult { + const existingState = params.conversation + ? getOpencodeState(params.conversation.providerState) + : null; + const providerState: OpencodeProviderState = { + ...(this.currentDatabasePath || existingState?.databasePath + ? { databasePath: this.currentDatabasePath ?? existingState?.databasePath } + : {}), + }; + const updates: Partial = { + providerState: Object.keys(providerState).length > 0 + ? providerState as Record + : undefined, + sessionId: this.sessionId, + }; + + if (params.sessionInvalidated) { + if (!this.sessionId) { + updates.providerState = undefined; + updates.sessionId = null; + } + } + + return { updates }; + } + + resolveSessionIdForFork(conversation: Conversation | null): string | null { + return this.sessionId ?? conversation?.sessionId ?? null; + } + + async loadSubagentToolCalls(_agentId: string): Promise { + return []; + } + + async loadSubagentFinalResult(_agentId: string): Promise { + return null; + } + + private async startProcess(params: { + command: string; + configPath: string; + cwd: string; + runtimeEnv: NodeJS.ProcessEnv; + }): Promise { + const processEnv: NodeJS.ProcessEnv = { + ...process.env, + ...params.runtimeEnv, + OPENCODE_CONFIG: params.configPath, + PATH: getEnhancedPath( + params.runtimeEnv.PATH, + path.isAbsolute(params.command) ? params.command : undefined, + ), + }; + + this.process = new AcpSubprocess({ + args: ['acp', `--cwd=${params.cwd}`], + command: params.command, + cwd: params.cwd, + env: processEnv, + }); + this.process.start(); + + this.transport = new AcpJsonRpcTransport({ + input: this.process.stdout, + onClose: (listener) => this.process!.onClose(listener), + output: this.process.stdin, + }); + const transport = this.transport; + this.unregisterTransportClose = transport.onClose((error) => { + if (this.transport === transport) { + this.setReady(false); + this.settleActiveTurn(error ?? new Error('OpenCode runtime closed')); + } + }); + + const connectionGeneration = ++this.connectionGeneration; + this.connection = new AcpClientConnection({ + clientInfo: { + name: 'claudian', + version: this.plugin.manifest?.version ?? '0.0.0', + }, + delegate: { + fileSystem: { + readTextFile: (request) => this.readTextFile(request), + writeTextFile: (request) => this.writeTextFile(request), + }, + onSessionNotification: (notification) => this.handleSessionNotification( + notification, + connectionGeneration, + ), + requestPermission: (request) => this.handlePermissionRequest(request), + }, + transport: this.transport, + }); + + this.transport.start(); + await this.connection.initialize(); + } + + private async shutdownProcess(): Promise { + this.connectionGeneration += 1; + this.setReady(false); + this.settleActiveTurn(); + this.currentSessionModelId = null; + this.currentSessionModeId = null; + this.setSupportedCommands([]); + + this.unregisterTransportClose?.(); + this.unregisterTransportClose = null; + + this.connection?.dispose(); + this.connection = null; + + this.transport?.dispose(); + this.transport = null; + + if (this.process) { + await this.process.shutdown().catch(() => {}); + this.process = null; + } + } + + private setReady(ready: boolean): void { + if (this.ready === ready) { + return; + } + + this.ready = ready; + for (const listener of this.readyListeners) { + listener(ready); + } + } + + private isLifecycleCurrent(generation: number): boolean { + return !this.disposed && generation === this.lifecycleGeneration; + } + + private isConversationCurrent(generation: number): boolean { + return generation === this.conversationGeneration; + } + + private isReadinessCurrent( + lifecycleGeneration: number, + conversationGeneration: number, + ): boolean { + return this.isLifecycleCurrent(lifecycleGeneration) + && this.isConversationCurrent(conversationGeneration); + } + + private getSystemPromptSettings(vaultPath: string): SystemPromptSettings { + return { + customPrompt: this.plugin.settings.systemPrompt, + mediaFolder: this.plugin.settings.mediaFolder, + userName: this.plugin.settings.userName, + vaultPath, + }; + } + + private buildRuntimeEnv( + cliPath: string, + databasePathOverride?: string | null, + ): NodeJS.ProcessEnv { + return buildOpencodeRuntimeEnv( + this.plugin.settings, + cliPath, + databasePathOverride, + ); + } + + private getProviderSettings(): Record { + const settings = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + this.plugin.settings, + this.providerId, + ); + if (this.currentConversationModel) { + settings.model = this.currentConversationModel; + } + return settings; + } + + private resolveSelectedRawModelId(queryOptions?: ChatRuntimeQueryOptions): string | null { + const providerSettings = this.getProviderSettings(); + const selectedModel = typeof queryOptions?.model === 'string' + ? queryOptions.model + : typeof providerSettings.model === 'string' + ? providerSettings.model + : ''; + + if (!isOpencodeModelSelectionId(selectedModel)) { + return null; + } + + const selectedBaseRawModelId = decodeOpencodeModelId(selectedModel); + if (!selectedBaseRawModelId) { + return null; + } + + const discoveredModels = getOpencodeProviderSettings(providerSettings).discoveredModels; + const normalizedBaseRawModelId = resolveOpencodeBaseModelRawId(selectedBaseRawModelId, discoveredModels); + if (!normalizedBaseRawModelId) { + return null; + } + + const availableModelIds = new Set(discoveredModels.map((model) => model.rawId)); + if (availableModelIds.size > 0 && !availableModelIds.has(normalizedBaseRawModelId)) { + return null; + } + + return normalizedBaseRawModelId; + } + + getAuxiliaryModel(): string | null { + return this.currentConversationModel ?? this.getActiveDisplayModel() ?? null; + } + + private setCurrentConversationModel(model: unknown): void { + const selectedModel = typeof model === 'string' ? model.trim() : ''; + this.currentConversationModel = selectedModel || null; + } + + private getActiveDisplayModel(queryOptions?: ChatRuntimeQueryOptions): string | undefined { + const providerSettings = this.getProviderSettings(); + const selectedModel = typeof queryOptions?.model === 'string' + ? queryOptions.model + : typeof providerSettings.model === 'string' + ? providerSettings.model + : ''; + + if ( + selectedModel + && selectedModel !== OPENCODE_SYNTHETIC_MODEL_ID + && isOpencodeModelSelectionId(selectedModel) + ) { + const selectedRawModelId = this.resolveSelectedRawModelId(queryOptions); + return selectedRawModelId + ? encodeOpencodeModelId(selectedRawModelId) + : selectedModel; + } + + return this.currentSessionModelId + ? encodeOpencodeModelId(this.currentSessionModelId) + : (selectedModel && isOpencodeModelSelectionId(selectedModel) ? selectedModel : undefined); + } + + private resolveSelectedModeId(): string | null { + const providerSettings = this.getProviderSettings(); + const opencodeSettings = getOpencodeProviderSettings(providerSettings); + const availableModes = getManagedOpencodeModes(opencodeSettings.availableModes); + const mappedModeId = resolveOpencodeModeForPermissionMode( + providerSettings.permissionMode, + opencodeSettings.availableModes, + ); + if (mappedModeId) { + return mappedModeId; + } + + if (opencodeSettings.selectedMode) { + if ( + availableModes.some((mode) => mode.id === opencodeSettings.selectedMode) + ) { + return opencodeSettings.selectedMode; + } + } + + return availableModes[0]?.id || null; + } + + private async applySelectedMode( + sessionId: string, + conversationGeneration = this.conversationGeneration, + ): Promise { + if (!this.connection) { + return; + } + + const selectedModeId = this.resolveSelectedModeId(); + if (!selectedModeId || selectedModeId === this.currentSessionModeId) { + return; + } + + const response = await this.connection.setConfigOption({ + configId: 'mode', + sessionId, + type: 'select', + value: selectedModeId, + }); + if (!this.isConversationCurrent(conversationGeneration)) { + return; + } + this.currentSessionModeId = selectedModeId; + await this.syncSessionModeState({ + configOptions: response.configOptions, + }, conversationGeneration); + } + + private async applySelectedModel( + sessionId: string, + queryOptions?: ChatRuntimeQueryOptions, + conversationGeneration = this.conversationGeneration, + ): Promise { + if (!this.connection) { + return; + } + + const selectedRawModelId = this.resolveSelectedRawModelId(queryOptions); + if (!selectedRawModelId || selectedRawModelId === this.currentSessionModelId) { + return; + } + + const response = await this.connection.setConfigOption({ + configId: 'model', + sessionId, + type: 'select', + value: selectedRawModelId, + }); + if (!this.isConversationCurrent(conversationGeneration)) { + return; + } + this.currentSessionModelId = selectedRawModelId; + await this.syncSessionModelState({ + configOptions: response.configOptions, + }, conversationGeneration); + } + + private resolveSelectedEffortValue(): string | null { + const providerSettings = this.getProviderSettings(); + const selectedEffort = typeof providerSettings.effortLevel === 'string' + ? providerSettings.effortLevel.trim() + : ''; + if (!selectedEffort || selectedEffort === OPENCODE_DEFAULT_THINKING_LEVEL) { + return null; + } + + return this.currentSessionEffortValues.has(selectedEffort) + ? selectedEffort + : null; + } + + private async applySelectedEffort( + sessionId: string, + conversationGeneration = this.conversationGeneration, + ): Promise { + if (!this.connection || !this.currentSessionEffortConfigId) { + return; + } + + const selectedEffort = this.resolveSelectedEffortValue(); + if (!selectedEffort || selectedEffort === this.currentSessionEffortValue) { + return; + } + + const response = await this.connection.setConfigOption({ + configId: this.currentSessionEffortConfigId, + sessionId, + type: 'select', + value: selectedEffort, + }); + if (!this.isConversationCurrent(conversationGeneration)) { + return; + } + this.currentSessionEffortValue = selectedEffort; + await this.syncSessionModelState({ + configOptions: response.configOptions, + }, conversationGeneration); + } + + private async syncSessionModelState(params: { + configOptions?: AcpSessionConfigOption[] | null; + models?: AcpSessionModelState | null; + }, conversationGeneration?: number): Promise { + if ( + conversationGeneration !== undefined + && !this.isConversationCurrent(conversationGeneration) + ) { + return; + } + const acpState = extractAcpSessionModelState(params); + const currentRawModelId = acpState.currentModelId ?? this.currentSessionModelId; + const discoveredModels = normalizeOpencodeDiscoveredModels( + acpState.availableModels.map((model) => ({ + ...(model.description ? { description: model.description } : {}), + label: model.name, + rawId: model.id, + })), + ); + if (currentRawModelId) { + this.currentSessionModelId = currentRawModelId; + } + + const settingsBag = this.plugin.settings as unknown as Record; + const currentSettings = getOpencodeProviderSettings(settingsBag); + const currentBaseRawModelId = currentRawModelId + ? resolveOpencodeBaseModelRawId(currentRawModelId, discoveredModels) + : null; + const currentPreferredThinking = currentBaseRawModelId + ? currentSettings.preferredThinkingByModel[currentBaseRawModelId] + : ''; + const thoughtLevelState = extractAcpSessionThoughtLevelState(params); + const currentThinkingOptions = normalizeOpencodeModelVariants( + thoughtLevelState.availableLevels.map((level) => ({ + ...(level.description ? { description: level.description } : {}), + label: level.name, + value: level.id, + })), + ); + const currentThinkingLevel = thoughtLevelState.currentLevel; + const defaultThinkingLevel = currentThinkingOptions.length > 0 + ? resolveOpencodeDefaultThinkingLevel( + currentThinkingOptions, + currentPreferredThinking, + currentThinkingLevel ?? undefined, + ) + : currentThinkingLevel; + this.currentSessionEffortConfigId = currentThinkingOptions.length > 0 + ? thoughtLevelState.configId + : null; + this.currentSessionEffortValue = currentThinkingOptions.length > 0 + ? currentThinkingLevel + : null; + this.currentSessionEffortValues = new Set(currentThinkingOptions.map((option) => option.value)); + + const nextThinkingOptionsByModel = { ...currentSettings.thinkingOptionsByModel }; + if (currentBaseRawModelId) { + if (currentThinkingOptions.length > 0) { + nextThinkingOptionsByModel[currentBaseRawModelId] = currentThinkingOptions; + } else { + delete nextThinkingOptionsByModel[currentBaseRawModelId]; + } + } + + const nextVisibleModels = currentSettings.visibleModels.length === 0 && currentBaseRawModelId + ? [currentBaseRawModelId] + : currentSettings.visibleModels; + const shouldSeedCurrentThinking = currentBaseRawModelId + && defaultThinkingLevel + && ( + !currentPreferredThinking + || ( + currentThinkingOptions.length > 0 + && !this.currentSessionEffortValues.has(currentPreferredThinking) + ) + ); + const nextPreferredThinkingByModel = shouldSeedCurrentThinking && currentBaseRawModelId && defaultThinkingLevel + ? { + ...currentSettings.preferredThinkingByModel, + [currentBaseRawModelId]: defaultThinkingLevel, + } + : currentSettings.preferredThinkingByModel; + const shouldSeedVisibleModels = !sameStringList(currentSettings.visibleModels, nextVisibleModels); + const shouldSeedPreferredThinking = !sameStringMap( + currentSettings.preferredThinkingByModel, + nextPreferredThinkingByModel, + ); + const shouldUpdateDiscoveredModels = discoveredModels.length > 0 + && !sameDiscoveredModels(currentSettings.discoveredModels, discoveredModels); + const shouldUpdateThinkingOptions = !sameThinkingOptionsByModel( + currentSettings.thinkingOptionsByModel, + nextThinkingOptionsByModel, + ); + const discoveryChanged = shouldUpdateDiscoveredModels + && updateOpencodeDiscoveryState(settingsBag, { discoveredModels }); + let changed = shouldSeedVisibleModels || shouldSeedPreferredThinking; + + if (currentBaseRawModelId) { + const probeSettings = { + ...settingsBag, + savedProviderEffort: { + ...(settingsBag.savedProviderEffort as Record | undefined), + }, + savedProviderModel: { + ...(settingsBag.savedProviderModel as Record | undefined), + }, + }; + const seeded = this.seedActiveModelSelection( + probeSettings, + encodeOpencodeModelId(currentBaseRawModelId), + defaultThinkingLevel, + ); + changed = changed || seeded; + } + + if (!changed && !discoveryChanged && !shouldUpdateThinkingOptions) { + return; + } + + if (changed || shouldUpdateThinkingOptions) { + await this.plugin.mutateSettings((settings) => { + if ( + conversationGeneration !== undefined + && !this.isConversationCurrent(conversationGeneration) + ) { + return; + } + if (currentBaseRawModelId) { + this.seedActiveModelSelection( + settings, + encodeOpencodeModelId(currentBaseRawModelId), + defaultThinkingLevel, + ); + } + if (shouldUpdateThinkingOptions || shouldSeedPreferredThinking || shouldSeedVisibleModels) { + updateOpencodeProviderSettings(settings, { + ...(shouldSeedPreferredThinking ? { preferredThinkingByModel: nextPreferredThinkingByModel } : {}), + ...(shouldUpdateThinkingOptions ? { thinkingOptionsByModel: nextThinkingOptionsByModel } : {}), + ...(shouldSeedVisibleModels ? { visibleModels: nextVisibleModels } : {}), + }); + } + }); + } + if ( + conversationGeneration !== undefined + && !this.isConversationCurrent(conversationGeneration) + ) { + return; + } + this.refreshModelSelectors(); + } + + private seedActiveModelSelection( + settingsBag: Record, + modelSelection: string, + thinkingLevel: string | null, + ): boolean { + let changed = false; + const savedProviderModel = ensureProviderProjectionMap(settingsBag, 'savedProviderModel'); + const savedModel = typeof savedProviderModel.opencode === 'string' + ? savedProviderModel.opencode + : ''; + if (!savedModel || savedModel === OPENCODE_SYNTHETIC_MODEL_ID) { + savedProviderModel.opencode = modelSelection; + changed = true; + } + + if (thinkingLevel) { + const savedProviderEffort = ensureProviderProjectionMap(settingsBag, 'savedProviderEffort'); + const savedEffort = typeof savedProviderEffort.opencode === 'string' + ? savedProviderEffort.opencode.trim() + : ''; + if ( + !savedEffort + || savedEffort === OPENCODE_DEFAULT_THINKING_LEVEL + || !this.currentSessionEffortValues.has(savedEffort) + ) { + savedProviderEffort.opencode = thinkingLevel; + changed = true; + } + } + + if (ProviderRegistry.resolveSettingsProviderId(settingsBag) !== this.providerId) { + return changed; + } + + const activeModel = typeof settingsBag.model === 'string' ? settingsBag.model : ''; + if (!activeModel || activeModel === OPENCODE_SYNTHETIC_MODEL_ID) { + settingsBag.model = modelSelection; + changed = true; + } + if (thinkingLevel) { + const activeEffort = typeof settingsBag.effortLevel === 'string' ? settingsBag.effortLevel : ''; + if ( + !activeEffort + || activeEffort === OPENCODE_DEFAULT_THINKING_LEVEL + || !this.currentSessionEffortValues.has(activeEffort) + ) { + settingsBag.effortLevel = thinkingLevel; + changed = true; + } + } + return changed; + } + + private async syncSessionModeState(params: { + configOptions?: AcpSessionConfigOption[] | null; + currentModeId?: string | null; + modes?: AcpSessionModeState | null; + }, conversationGeneration?: number): Promise { + if ( + conversationGeneration !== undefined + && !this.isConversationCurrent(conversationGeneration) + ) { + return; + } + const acpState = extractAcpSessionModeState(params); + const availableModes = normalizeOpencodeAvailableModes(acpState.availableModes); + const currentModeId = params.currentModeId ?? acpState.currentModeId; + if (currentModeId) { + this.currentSessionModeId = currentModeId; + this.emitPermissionModeSync(currentModeId); + } + + const settingsBag = this.plugin.settings as unknown as Record; + const currentSettings = getOpencodeProviderSettings(settingsBag); + const shouldSeedSelectedMode = typeof currentModeId === 'string' + && !currentSettings.selectedMode + && isManagedOpencodeModeId(currentModeId); + const discoveryChanged = availableModes.length > 0 + && !sameModes(currentSettings.availableModes, availableModes) + && updateOpencodeDiscoveryState(settingsBag, { availableModes }); + + if (!discoveryChanged && !shouldSeedSelectedMode) { + return; + } + + if (shouldSeedSelectedMode && currentModeId) { + await this.plugin.mutateSettings((settings) => { + if ( + conversationGeneration !== undefined + && !this.isConversationCurrent(conversationGeneration) + ) { + return; + } + updateOpencodeProviderSettings(settings, { selectedMode: currentModeId }); + }); + } + if ( + conversationGeneration !== undefined + && !this.isConversationCurrent(conversationGeneration) + ) { + return; + } + this.refreshModelSelectors(); + } + + private refreshModelSelectors(): void { + this.plugin.refreshModelSelectors?.(); + } + + private emitPermissionModeSync(modeId: string): void { + const permissionMode = resolvePermissionModeForManagedOpencodeMode(modeId); + if (!permissionMode || !this.permissionModeSyncCallback) { + return; + } + + try { + this.permissionModeSyncCallback(permissionMode); + } catch { + // Non-critical UI sync callback. + } + } + + private settleActiveTurn(error?: Error): void { + const activeTurn = this.activeTurn; + if (!activeTurn || activeTurn.cancelled) { + return; + } + + activeTurn.cancelled = true; + if (error) { + activeTurn.queue.push({ type: 'error', content: this.formatRuntimeError(error) }); + } + activeTurn.queue.push({ type: 'done' }); + activeTurn.queue.close(); + if (this.activeTurn === activeTurn) { + this.activeTurn = null; + } + } + + private async createSession( + cwd: string, + conversationGeneration = this.conversationGeneration, + ): Promise { + if (!this.connection) { + return null; + } + + try { + this.setSupportedCommands([]); + const response = await this.connection.newSession({ + cwd, + mcpServers: [], + }); + if (!this.isConversationCurrent(conversationGeneration)) { + return null; + } + this.loadedSessionId = response.sessionId; + this.sessionId = response.sessionId; + this.sessionCwds.set(response.sessionId, cwd); + await this.syncSessionModelState({ + configOptions: response.configOptions ?? null, + models: response.models ?? null, + }, conversationGeneration); + if (!this.isConversationCurrent(conversationGeneration)) { + return null; + } + await this.syncSessionModeState({ + configOptions: response.configOptions ?? null, + modes: response.modes ?? null, + }, conversationGeneration); + if (!this.isConversationCurrent(conversationGeneration)) { + return null; + } + return response.sessionId; + } catch { + return null; + } + } + + private async loadSession( + sessionId: string, + cwd: string, + conversationGeneration = this.conversationGeneration, + ): Promise { + if (!this.connection) { + return false; + } + + try { + this.setSupportedCommands([]); + const response = await this.connection.loadSession({ + cwd, + mcpServers: [], + sessionId, + }); + if (!this.isConversationCurrent(conversationGeneration)) { + return false; + } + this.sessionInvalidated = false; + this.loadedSessionId = response.sessionId; + this.sessionId = response.sessionId; + this.sessionCwds.set(response.sessionId, cwd); + await this.syncSessionModelState({ + configOptions: response.configOptions ?? null, + models: response.models ?? null, + }, conversationGeneration); + if (!this.isConversationCurrent(conversationGeneration)) { + return false; + } + await this.syncSessionModeState({ + configOptions: response.configOptions ?? null, + modes: response.modes ?? null, + }, conversationGeneration); + if (!this.isConversationCurrent(conversationGeneration)) { + return false; + } + return true; + } catch { + return false; + } + } + + private async handleSessionNotification( + notification: AcpSessionNotification, + connectionGeneration = this.connectionGeneration, + ): Promise { + if (connectionGeneration !== this.connectionGeneration) { + return; + } + if (notification.sessionId !== this.sessionId) { + return; + } + + const normalized = this.sessionUpdateNormalizer.normalize(notification.update); + if (normalized.type === 'config_options') { + await this.syncSessionModelState({ + configOptions: normalized.configOptions, + }); + await this.syncSessionModeState({ + configOptions: normalized.configOptions, + }); + return; + } + + if (normalized.type === 'current_mode') { + await this.syncSessionModeState({ + currentModeId: normalized.currentModeId, + }); + return; + } + + if (normalized.type === 'commands') { + this.setSupportedCommands(normalized.commands); + return; + } + + if (!this.activeTurn || this.activeTurn.sessionId !== notification.sessionId) { + return; + } + + switch (normalized.type) { + case 'message_chunk': { + if (normalized.role === 'assistant' && normalized.messageId) { + this.currentTurnMetadata.assistantMessageId = normalized.messageId; + } + if (normalized.role === 'user' && normalized.messageId) { + this.currentTurnMetadata.userMessageId = normalized.messageId; + } + for (const chunk of normalized.streamChunks) { + this.activeTurn.queue.push(chunk); + } + return; + } + case 'tool_call': + case 'tool_call_update': { + const streamChunks = normalized.type === 'tool_call' + ? this.toolStreamAdapter.normalizeToolCall(normalized.toolCall, normalized.streamChunks) + : this.toolStreamAdapter.normalizeToolCallUpdate(normalized.toolCallUpdate, normalized.streamChunks); + + for (const chunk of streamChunks) { + this.activeTurn.queue.push(chunk); + } + return; + } + case 'usage': { + this.contextUsage = normalized.usage; + const usage = buildAcpUsageInfo({ + contextWindow: normalized.usage, + model: this.getActiveDisplayModel(), + promptUsage: this.promptUsage, + }); + if (usage) { + this.activeTurn.queue.push({ + sessionId: notification.sessionId, + type: 'usage', + usage, + }); + } + return; + } + default: + return; + } + } + + private async handlePermissionRequest( + request: AcpRequestPermissionRequest, + ): Promise { + if (!this.approvalCallback) { + return { outcome: { outcome: 'cancelled' } }; + } + + const input = normalizeApprovalInput(request.toolCall.rawInput); + const presentation = buildOpencodePermissionPresentation(request.toolCall.title, input, request.toolCall.locations); + const decision = await this.approvalCallback( + presentation.toolName, + input, + presentation.description, + { + ...(presentation.blockedPath ? { blockedPath: presentation.blockedPath } : {}), + ...(presentation.decisionReason ? { decisionReason: presentation.decisionReason } : {}), + decisionOptions: buildAcpApprovalDecisionOptions(request.options), + }, + ); + + return mapApprovalDecision(decision, request.options); + } + + private setSupportedCommands(commands: SlashCommand[]): void { + this.supportedCommands = commands.map((command) => ({ ...command })); + + const waiters = this.supportedCommandWaiters.splice(0); + for (const waiter of waiters) { + waiter(this.supportedCommands); + } + } + + private waitForSupportedCommands(timeoutMs = 250): Promise { + if (this.supportedCommands.length > 0) { + return Promise.resolve([...this.supportedCommands]); + } + + return new Promise((resolve) => { + const waiter = (commands: SlashCommand[]) => { + window.clearTimeout(timeoutId); + resolve([...commands]); + }; + const timeoutId = window.setTimeout(() => { + const index = this.supportedCommandWaiters.indexOf(waiter); + if (index >= 0) { + this.supportedCommandWaiters.splice(index, 1); + } + resolve([...this.supportedCommands]); + }, timeoutMs); + + this.supportedCommandWaiters.push(waiter); + }); + } + + private async readTextFile( + request: AcpReadTextFileRequest, + ): Promise<{ content: string }> { + const resolvedPath = this.resolveSessionPath(request.sessionId, request.path); + const content = await fs.readFile(resolvedPath, 'utf-8'); + + if (request.line === undefined && request.limit === undefined) { + return { content }; + } + + const lines = content.split(/\r?\n/); + const startIndex = Math.max(0, (request.line ?? 1) - 1); + const endIndex = request.limit + ? startIndex + Math.max(0, request.limit) + : lines.length; + + return { + content: lines.slice(startIndex, endIndex).join('\n'), + }; + } + + private async writeTextFile( + request: AcpWriteTextFileRequest, + ): Promise> { + const resolvedPath = this.resolveSessionPath(request.sessionId, request.path); + await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); + await fs.writeFile(resolvedPath, request.content, 'utf-8'); + return {}; + } + + private resolveSessionPath(sessionId: string, rawPath: string): string { + if (path.isAbsolute(rawPath)) { + return rawPath; + } + + const cwd = this.sessionCwds.get(sessionId) + ?? getVaultPath(this.plugin.app) + ?? process.cwd(); + return path.resolve(cwd, rawPath); + } + + private formatRuntimeError(error: unknown): string { + const baseMessage = error instanceof Error ? error.message : 'OpenCode request failed'; + const stderr = this.process?.getStderrSnapshot(); + return stderr ? `${baseMessage}\n\n${stderr}` : baseMessage; + } + + private clearActiveSession(): void { + this.currentDatabasePath = null; + this.sessionId = null; + this.loadedSessionId = null; + this.currentSessionModelId = null; + this.currentSessionModeId = null; + this.setSupportedCommands([]); + } +} + +function normalizeApprovalInput(rawInput: unknown): Record { + if (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)) { + return rawInput as Record; + } + if (rawInput === undefined) { + return {}; + } + return { value: rawInput }; +} + +function buildOpencodePermissionPresentation( + rawTitle: string | null | undefined, + input: Record, + locations: Array<{ path: string }> | null | undefined, +): { + blockedPath?: string; + decisionReason?: string; + description: string; + toolName: string; +} { + const permissionId = normalizePermissionId(rawTitle); + const blockedPath = extractPermissionPath(input, locations); + + switch (permissionId) { + case 'bash': + return { + decisionReason: 'Command execution permission required', + description: 'OpenCode wants to run a shell command.', + toolName: 'bash', + }; + case 'codesearch': + return { + description: 'OpenCode wants to search indexed code outside the active buffer.', + toolName: 'codesearch', + }; + case 'doom_loop': { + const repeatedTool = typeof input.tool === 'string' ? input.tool.trim() : ''; + return { + decisionReason: 'OpenCode detected repeated identical tool calls', + description: repeatedTool + ? `Allow another repeated \`${repeatedTool}\` call.` + : 'Allow another repeated tool call.', + toolName: 'Doom Loop Guard', + }; + } + case 'edit': + return { + ...(blockedPath ? { blockedPath } : {}), + decisionReason: 'File write permission required', + description: blockedPath + ? 'OpenCode wants to modify this file.' + : 'OpenCode wants to apply file changes.', + toolName: 'edit', + }; + case 'external_directory': + return { + ...(blockedPath ? { blockedPath } : {}), + decisionReason: 'Path is outside the session working directory', + description: blockedPath + ? 'OpenCode wants to access a path outside the working directory.' + : 'OpenCode wants to access files outside the working directory.', + toolName: 'External Directory', + }; + case 'glob': + return { + description: 'OpenCode wants to scan file paths with a glob pattern.', + toolName: 'glob', + }; + case 'grep': + return { + description: 'OpenCode wants to search file contents with a pattern.', + toolName: 'grep', + }; + case 'lsp': + return { + description: 'OpenCode wants to query language server data.', + toolName: 'lsp', + }; + case 'plan_enter': + return { + description: 'OpenCode wants to switch this session into planning mode.', + toolName: 'Enter Plan Mode', + }; + case 'plan_exit': + return { + description: 'OpenCode wants to leave planning mode and resume implementation.', + toolName: 'Exit Plan Mode', + }; + case 'question': + return { + description: 'OpenCode wants to ask you a direct question before continuing.', + toolName: 'Ask Question', + }; + case 'read': + return { + ...(blockedPath ? { blockedPath } : {}), + description: blockedPath + ? 'OpenCode wants to read this path.' + : 'OpenCode wants to read project files.', + toolName: 'read', + }; + case 'skill': + return { + description: 'OpenCode wants to load a skill into the current session.', + toolName: 'skill', + }; + case 'todowrite': + return { + description: 'OpenCode wants to update the shared task list.', + toolName: 'todowrite', + }; + case 'webfetch': + return { + description: 'OpenCode wants to fetch content from a URL.', + toolName: 'webfetch', + }; + case 'websearch': + return { + description: 'OpenCode wants to search the web.', + toolName: 'websearch', + }; + case 'workflow_tool_approval': { + const summary = summarizeWorkflowTools(input); + return { + decisionReason: 'Session-level workflow approval requested', + description: summary + ? `Pre-approve workflow tools for this session: ${summary}.` + : 'Pre-approve workflow tools for this session.', + toolName: 'Workflow Approval', + }; + } + default: + return { + ...(blockedPath ? { blockedPath } : {}), + description: blockedPath + ? `OpenCode wants permission to use ${formatPermissionLabel(permissionId)} on this path.` + : `OpenCode wants permission to use ${formatPermissionLabel(permissionId)}.`, + toolName: formatPermissionLabel(permissionId), + }; + } +} + +function normalizePermissionId(value: string | null | undefined): string { + return value?.trim().toLowerCase() || 'tool'; +} + +function extractPermissionPath( + input: Record, + locations: Array<{ path: string }> | null | undefined, +): string | undefined { + const candidateKeys = ['filepath', 'filePath', 'path', 'parentDir']; + for (const key of candidateKeys) { + const value = input[key]; + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } + + const locationPath = locations?.find((location) => location.path.trim())?.path; + return locationPath?.trim() || undefined; +} + +function summarizeWorkflowTools(input: Record): string { + const tools = Array.isArray(input.tools) ? input.tools : []; + const names = tools.flatMap((tool) => { + if (!tool || typeof tool !== 'object' || Array.isArray(tool)) { + return []; + } + + const entry = tool as Record; + const name = typeof entry.name === 'string' ? entry.name.trim() : ''; + if (!name) { + return []; + } + + let title = ''; + if (typeof entry.args === 'string') { + try { + const parsedArgs = JSON.parse(entry.args) as Record; + title = typeof parsedArgs.title === 'string' + ? parsedArgs.title.trim() + : typeof parsedArgs.name === 'string' + ? parsedArgs.name.trim() + : ''; + } catch { + title = ''; + } + } + + return [title ? `${name}: ${title}` : name]; + }); + + if (names.length === 0) { + return ''; + } + + if (names.length <= 3) { + return names.join(', '); + } + + return `${names.slice(0, 3).join(', ')} +${names.length - 3} more`; +} + +function formatPermissionLabel(permissionId: string): string { + return permissionId + .split(/[_\s]+/) + .filter(Boolean) + .map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(' '); +} + +function mapApprovalDecision( + decision: ApprovalDecision, + options: readonly { + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; + optionId: string; + }[], +): AcpRequestPermissionResponse { + if (decision === 'allow') { + return selectPermissionOption(options, ['allow_once', 'allow_always']); + } + + if (decision === 'allow-always') { + return selectPermissionOption(options, ['allow_always', 'allow_once']); + } + + if (decision === 'deny') { + return selectPermissionOption(options, ['reject_once', 'reject_always']); + } + + if (typeof decision === 'object' && decision.type === 'select-option') { + return { + outcome: { + optionId: decision.value, + outcome: 'selected', + }, + }; + } + + return { outcome: { outcome: 'cancelled' } }; +} + +function buildAcpApprovalDecisionOptions( + options: readonly { + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; + name: string; + optionId: string; + }[], +): ApprovalDecisionOption[] { + return options.map((option) => ({ + ...(option.kind === 'allow_once' + ? { decision: 'allow' as const } + : option.kind === 'allow_always' + ? { decision: 'allow-always' as const } + : {}), + label: option.name, + value: option.optionId, + })); +} + +function selectPermissionOption( + options: readonly { + kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'; + optionId: string; + }[], + preferredKinds: readonly ('allow_once' | 'allow_always' | 'reject_once' | 'reject_always')[], +): AcpRequestPermissionResponse { + for (const kind of preferredKinds) { + const option = options.find((entry) => entry.kind === kind); + if (option) { + return { + outcome: { + optionId: option.optionId, + outcome: 'selected', + }, + }; + } + } + + return { outcome: { outcome: 'cancelled' } }; +} diff --git a/src/providers/opencode/runtime/OpencodeCliResolver.ts b/src/providers/opencode/runtime/OpencodeCliResolver.ts new file mode 100644 index 0000000..01ce176 --- /dev/null +++ b/src/providers/opencode/runtime/OpencodeCliResolver.ts @@ -0,0 +1,57 @@ +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import { findCliBinaryPath, resolveConfiguredCliPath } from '../../../utils/cliBinaryLocator'; +import { getHostnameKey, parseEnvironmentVariables } from '../../../utils/env'; +import { getOpencodeProviderSettings } from '../settings'; + +export class OpencodeCliResolver { + private readonly cachedHostname = getHostnameKey(); + private lastCliPath = ''; + private lastHostnamePath = ''; + private lastEnvText = ''; + private resolvedPath: string | null = null; + + resolveFromSettings(settings: Record): string | null { + const opencodeSettings = getOpencodeProviderSettings(settings); + const cliPath = opencodeSettings.cliPath.trim(); + const hostnamePath = (opencodeSettings.cliPathsByHost[this.cachedHostname] ?? '').trim(); + const envText = getRuntimeEnvironmentText(settings, 'opencode'); + + if ( + this.resolvedPath !== null + && cliPath === this.lastCliPath + && hostnamePath === this.lastHostnamePath + && envText === this.lastEnvText + ) { + return this.resolvedPath; + } + + this.lastCliPath = cliPath; + this.lastHostnamePath = hostnamePath; + this.lastEnvText = envText; + this.resolvedPath = this.resolve( + opencodeSettings.cliPathsByHost, + cliPath, + envText, + ); + return this.resolvedPath; + } + + resolve( + hostnamePaths: Record | undefined, + legacyPath: string, + envText: string, + ): string | null { + const hostnamePath = (hostnamePaths?.[this.cachedHostname] ?? '').trim(); + const customEnv = parseEnvironmentVariables(envText || ''); + return resolveConfiguredCliPath(hostnamePath) + ?? resolveConfiguredCliPath(legacyPath.trim()) + ?? findCliBinaryPath('opencode', customEnv.PATH); + } + + reset(): void { + this.lastCliPath = ''; + this.lastHostnamePath = ''; + this.lastEnvText = ''; + this.resolvedPath = null; + } +} diff --git a/src/providers/opencode/runtime/OpencodeLaunchArtifacts.ts b/src/providers/opencode/runtime/OpencodeLaunchArtifacts.ts new file mode 100644 index 0000000..deb7b3d --- /dev/null +++ b/src/providers/opencode/runtime/OpencodeLaunchArtifacts.ts @@ -0,0 +1,231 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { CLAUDIAN_STORAGE_PATH } from '../../../core/bootstrap/StoragePaths'; +import { + buildSystemPrompt, + computeSystemPromptKey, + type SystemPromptSettings, +} from '../../../core/prompt/mainAgent'; +import { expandHomePath } from '../../../utils/path'; +import { + OPENCODE_BUILD_MODE_ID, + OPENCODE_PLAN_MODE_ID, + OPENCODE_SAFE_MODE_ID, + OPENCODE_YOLO_MODE_ID, +} from '../modes'; +import { resolveOpencodeDatabasePath } from './OpencodePaths'; + +export interface OpencodeLaunchArtifacts { + configPath: string; + configContent: string; + databasePath: string | null; + launchKey: string; + systemPromptPath: string; +} + +export interface OpencodeManagedAgentConfig { + definition?: Record; + id: string; +} + +const DEFAULT_OPENCODE_MANAGED_AGENT_CONFIGS: readonly OpencodeManagedAgentConfig[] = [ + { id: OPENCODE_BUILD_MODE_ID }, + { + definition: { + mode: 'primary', + permission: { + plan_enter: 'allow', + question: 'allow', + }, + }, + id: OPENCODE_YOLO_MODE_ID, + }, + { + definition: { + mode: 'primary', + permission: { + plan_enter: 'allow', + question: 'allow', + bash: 'ask', + edit: 'ask', + }, + }, + id: OPENCODE_SAFE_MODE_ID, + }, + { id: OPENCODE_PLAN_MODE_ID }, +]; + +export interface PrepareOpencodeLaunchArtifactsParams { + artifactsSubdir?: string; + defaultAgentId?: string; + managedAgents?: readonly OpencodeManagedAgentConfig[]; + runtimeEnv: NodeJS.ProcessEnv; + settings?: SystemPromptSettings; + systemPromptKey?: string; + systemPromptText?: string; + userName?: string; + workspaceRoot: string; +} + +export async function prepareOpencodeLaunchArtifacts( + params: PrepareOpencodeLaunchArtifactsParams, +): Promise { + const artifactsDir = path.join( + params.workspaceRoot, + CLAUDIAN_STORAGE_PATH, + params.artifactsSubdir ?? 'opencode', + ); + const systemPromptPath = path.join(artifactsDir, 'system.md'); + const configPath = path.join(artifactsDir, 'config.json'); + const systemPrompt = normalizeSystemPrompt( + params.systemPromptText ?? buildSystemPrompt(requireSettings(params)), + ); + const promptKey = params.systemPromptKey + ?? (params.systemPromptText !== undefined + ? params.systemPromptText + : computeSystemPromptKey(requireSettings(params))); + const baseConfig = await loadOpencodeBaseConfig( + params.runtimeEnv.OPENCODE_CONFIG, + params.workspaceRoot, + ); + const configContent = `${JSON.stringify( + buildOpencodeManagedConfig( + baseConfig, + systemPromptPath, + params.userName ?? params.settings?.userName, + params.managedAgents, + params.defaultAgentId, + ), + null, + 2, + )}\n`; + const databasePath = resolveOpencodeDatabasePath(params.runtimeEnv); + + await fs.mkdir(artifactsDir, { recursive: true }); + await ensureOpencodeDatabaseDirectory(databasePath); + await writeIfChanged(systemPromptPath, systemPrompt); + await writeIfChanged(configPath, configContent); + + return { + configPath, + configContent, + databasePath, + launchKey: [ + promptKey, + configContent, + databasePath ?? '', + params.runtimeEnv.XDG_DATA_HOME ?? '', + ].join('::'), + systemPromptPath, + }; +} + +async function ensureOpencodeDatabaseDirectory(databasePath: string | null): Promise { + if (!databasePath || databasePath === ':memory:') { + return; + } + + await fs.mkdir(path.dirname(databasePath), { recursive: true }); +} + +export function buildOpencodeManagedConfig( + baseConfig: Record, + systemPromptPath: string, + userName?: string, + managedAgents: readonly OpencodeManagedAgentConfig[] = DEFAULT_OPENCODE_MANAGED_AGENT_CONFIGS, + defaultAgentId?: string, +): Record { + const config: Record = { + ...baseConfig, + $schema: typeof baseConfig.$schema === 'string' + ? baseConfig.$schema + : 'https://opencode.ai/config.json', + }; + const existingAgents = isPlainObject(baseConfig.agent) + ? { ...baseConfig.agent } + : {}; + const nextAgents: Record = { ...existingAgents }; + const agentConfigs = managedAgents.length > 0 + ? managedAgents + : DEFAULT_OPENCODE_MANAGED_AGENT_CONFIGS; + + for (const agentConfig of agentConfigs) { + const existingAgentValue = existingAgents[agentConfig.id]; + const existingAgent = isPlainObject(existingAgentValue) + ? { ...existingAgentValue } + : {}; + nextAgents[agentConfig.id] = { + ...existingAgent, + ...(isPlainObject(agentConfig.definition) ? agentConfig.definition : {}), + prompt: `{file:${systemPromptPath}}`, + }; + } + + config.agent = nextAgents; + const trimmedDefaultAgentId = defaultAgentId?.trim(); + if (trimmedDefaultAgentId) { + config.default_agent = trimmedDefaultAgentId; + } + + const trimmedUserName = userName?.trim(); + if (trimmedUserName) { + config.username = trimmedUserName; + } + + return config; +} + +async function writeIfChanged(filePath: string, content: string): Promise { + try { + const existing = await fs.readFile(filePath, 'utf-8'); + if (existing === content) { + return; + } + } catch { + // Missing file; write below. + } + + await fs.writeFile(filePath, content, 'utf-8'); +} + +async function loadOpencodeBaseConfig( + configuredPath: string | undefined, + workspaceRoot: string, +): Promise> { + const trimmedPath = configuredPath?.trim(); + if (!trimmedPath) { + return {}; + } + + const expandedPath = expandHomePath(trimmedPath); + const resolvedPath = path.isAbsolute(expandedPath) + ? expandedPath + : path.resolve(workspaceRoot, expandedPath); + + try { + const rawConfig = await fs.readFile(resolvedPath, 'utf8'); + const parsedConfig = JSON.parse(rawConfig) as unknown; + return isPlainObject(parsedConfig) ? parsedConfig : {}; + } catch { + return {}; + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeSystemPrompt(systemPrompt: string): string { + return systemPrompt.endsWith('\n') ? systemPrompt : `${systemPrompt}\n`; +} + +function requireSettings( + params: PrepareOpencodeLaunchArtifactsParams, +): SystemPromptSettings { + if (params.settings) { + return params.settings; + } + + throw new Error('prepareOpencodeLaunchArtifacts requires settings when no systemPromptText is provided'); +} diff --git a/src/providers/opencode/runtime/OpencodePaths.ts b/src/providers/opencode/runtime/OpencodePaths.ts new file mode 100644 index 0000000..cdf0067 --- /dev/null +++ b/src/providers/opencode/runtime/OpencodePaths.ts @@ -0,0 +1,113 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const OPENCODE_APP_NAME = 'opencode'; +const DEFAULT_DATABASE_NAME = 'opencode.db'; +const DATABASE_NAME_PATTERN = /^opencode(?:-[a-z0-9._-]+)?\.db$/i; + +export function resolveOpencodeDataDir( + env: NodeJS.ProcessEnv = process.env, +): string { + const xdgDataHome = env.XDG_DATA_HOME?.trim(); + if (xdgDataHome) { + return path.join(xdgDataHome, OPENCODE_APP_NAME); + } + + const home = env.HOME || os.homedir(); + if (process.platform === 'win32') { + const appData = env.APPDATA || env.LOCALAPPDATA || path.join(home, 'AppData', 'Roaming'); + return path.join(appData, OPENCODE_APP_NAME); + } + + return path.join(home, '.local', 'share', OPENCODE_APP_NAME); +} + +export function resolveOpencodeDatabasePath( + env: NodeJS.ProcessEnv = process.env, +): string | null { + const override = env.OPENCODE_DB?.trim(); + if (override) { + if (override === ':memory:' || path.isAbsolute(override)) { + return override; + } + return path.join(resolveOpencodeDataDir(env), override); + } + + const candidates = getOpencodeDatabasePathCandidates(env); + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return candidates[0] ?? null; +} + +export function resolveExistingOpencodeDatabasePath( + preferredPath?: string | null, + env: NodeJS.ProcessEnv = process.env, +): string | null { + const preferred = preferredPath?.trim(); + if (preferred) { + if (preferred === ':memory:') { + return preferred; + } + if (fs.existsSync(preferred)) { + return preferred; + } + } + + const resolved = resolveOpencodeDatabasePath(env); + if (resolved && (resolved === ':memory:' || fs.existsSync(resolved))) { + return resolved; + } + + return preferred ?? resolved; +} + +function getOpencodeDatabasePathCandidates( + env: NodeJS.ProcessEnv, +): string[] { + const candidates: string[] = []; + const seen = new Set(); + const home = env.HOME || os.homedir(); + const dataDirs = [ + resolveOpencodeDataDir(env), + path.join(home, 'Library', 'Application Support', OPENCODE_APP_NAME), + ]; + + for (const dataDir of dataDirs) { + pushCandidate(candidates, seen, path.join(dataDir, DEFAULT_DATABASE_NAME)); + try { + const matches = fs.readdirSync(dataDir) + .filter((entry) => DATABASE_NAME_PATTERN.test(entry)) + .sort((left, right) => { + if (left === DEFAULT_DATABASE_NAME) return -1; + if (right === DEFAULT_DATABASE_NAME) return 1; + return left.localeCompare(right); + }); + + for (const entry of matches) { + pushCandidate(candidates, seen, path.join(dataDir, entry)); + } + } catch { + // Ignore missing dirs and unreadable locations. + } + } + + return candidates; +} + +function pushCandidate( + candidates: string[], + seen: Set, + candidate: string, +): void { + if (seen.has(candidate)) { + return; + } + + seen.add(candidate); + candidates.push(candidate); +} diff --git a/src/providers/opencode/runtime/OpencodeRuntimeEnvironment.ts b/src/providers/opencode/runtime/OpencodeRuntimeEnvironment.ts new file mode 100644 index 0000000..5920d85 --- /dev/null +++ b/src/providers/opencode/runtime/OpencodeRuntimeEnvironment.ts @@ -0,0 +1,18 @@ +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import { getEnhancedPath, parseEnvironmentVariables } from '../../../utils/env'; + +export function buildOpencodeRuntimeEnv( + settings: Record, + cliPath: string, + databasePathOverride?: string | null, +): NodeJS.ProcessEnv { + const envText = getRuntimeEnvironmentText(settings, 'opencode'); + const envVars = parseEnvironmentVariables(envText); + return { + ...process.env, + ...envVars, + OPENCODE_DISABLE_CLAUDE_CODE_PROMPT: 'true', + ...(databasePathOverride ? { OPENCODE_DB: databasePathOverride } : {}), + PATH: getEnhancedPath(envVars.PATH, cliPath || undefined), + }; +} diff --git a/src/providers/opencode/runtime/buildOpencodePrompt.ts b/src/providers/opencode/runtime/buildOpencodePrompt.ts new file mode 100644 index 0000000..2fbebe5 --- /dev/null +++ b/src/providers/opencode/runtime/buildOpencodePrompt.ts @@ -0,0 +1,66 @@ +import type { ChatTurnRequest } from '../../../core/runtime/types'; +import type { ChatMessage } from '../../../core/types'; +import { appendBrowserContext } from '../../../utils/browser'; +import { appendCanvasContext } from '../../../utils/canvas'; +import { appendCurrentNote } from '../../../utils/context'; +import { appendEditorContext } from '../../../utils/editor'; +import { buildContextFromHistory, buildPromptWithHistoryContext } from '../../../utils/session'; +import type { AcpContentBlock } from '../../acp'; + +export function buildOpencodePromptText( + request: ChatTurnRequest, + conversationHistory: ChatMessage[] = [], +): string { + let prompt = request.text; + + if (request.currentNotePath) { + prompt = appendCurrentNote(prompt, request.currentNotePath); + } + + if (request.editorSelection && request.editorSelection.mode !== 'none') { + prompt = appendEditorContext(prompt, request.editorSelection); + } + + if (request.browserSelection) { + prompt = appendBrowserContext(prompt, request.browserSelection); + } + + if (request.canvasSelection) { + prompt = appendCanvasContext(prompt, request.canvasSelection); + } + + if (conversationHistory.length > 0) { + const historyContext = buildContextFromHistory(conversationHistory); + prompt = buildPromptWithHistoryContext( + historyContext, + prompt, + prompt, + conversationHistory, + ); + } + + return prompt; +} + +export function buildOpencodePromptBlocks( + request: ChatTurnRequest, + conversationHistory: ChatMessage[] = [], +): AcpContentBlock[] { + const blocks: AcpContentBlock[] = [ + { type: 'text', text: buildOpencodePromptText(request, conversationHistory) }, + ]; + + for (const image of request.images ?? []) { + if (!image.data) { + continue; + } + + blocks.push({ + data: image.data, + mimeType: image.mediaType, + type: 'image', + }); + } + + return blocks; +} diff --git a/src/providers/opencode/settings.ts b/src/providers/opencode/settings.ts new file mode 100644 index 0000000..0820748 --- /dev/null +++ b/src/providers/opencode/settings.ts @@ -0,0 +1,430 @@ +import { getProviderConfig, setProviderConfig } from '../../core/providers/providerConfig'; +import { getProviderEnvironmentVariables } from '../../core/providers/providerEnvironment'; +import type { HostnameCliPaths } from '../../core/types/settings'; +import { + getHostnameKey, + getLegacyHostnameKey, + migrateLegacyHostnameKeyedMap, +} from '../../utils/env'; +import { + getOpencodeDiscoveryState, + seedOpencodeDiscoveryStateFromLegacyConfig, + updateOpencodeDiscoveryState, +} from './discoveryState'; +import { ensureProviderProjectionMap } from './internal/providerProjection'; +import { + decodeOpencodeModelId, + encodeOpencodeModelId, + isOpencodeModelSelectionId, + normalizeOpencodeThinkingOptionsByModel, + type OpencodeDiscoveredModel, + type OpencodeThinkingOptionsByModel, + resolveOpencodeBaseModelRawId, + resolveOpencodeDefaultThinkingLevel, +} from './models'; +import { + normalizeManagedOpencodeSelectedMode, + type OpencodeMode, +} from './modes'; + +export interface PersistedOpencodeProviderSettings { + cliPath: string; + cliPathsByHost: HostnameCliPaths; + enabled: boolean; + environmentHash: string; + environmentVariables: string; + modelAliases: Record; + preferredThinkingByModel: Record; + selectedMode: string; + thinkingOptionsByModel: OpencodeThinkingOptionsByModel; + visibleModels: string[]; +} + +export interface OpencodeProviderSettings extends PersistedOpencodeProviderSettings { + availableModes: OpencodeMode[]; + discoveredModels: OpencodeDiscoveredModel[]; +} + +export const OPENCODE_DEFAULT_ENVIRONMENT_VARIABLES = 'OPENCODE_ENABLE_EXA=1'; + +export const DEFAULT_OPENCODE_PROVIDER_SETTINGS: Readonly = Object.freeze({ + cliPath: '', + cliPathsByHost: {}, + enabled: false, + environmentHash: '', + environmentVariables: OPENCODE_DEFAULT_ENVIRONMENT_VARIABLES, + modelAliases: {}, + preferredThinkingByModel: {}, + selectedMode: '', + thinkingOptionsByModel: {}, + visibleModels: [], +}); + +function normalizeHostnameCliPaths(value: unknown): HostnameCliPaths { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const result: HostnameCliPaths = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string' && entry.trim()) { + result[key] = entry.trim(); + } + } + return result; +} + +export function normalizeOpencodeVisibleModels( + value: unknown, + discoveredModels: OpencodeDiscoveredModel[] = [], +): string[] { + if (!Array.isArray(value)) { + return []; + } + + const normalized: string[] = []; + const seen = new Set(); + for (const entry of value) { + if (typeof entry !== 'string') { + continue; + } + + const trimmed = resolveOpencodeBaseModelRawId(entry.trim(), discoveredModels); + if (!trimmed || seen.has(trimmed)) { + continue; + } + + seen.add(trimmed); + normalized.push(trimmed); + } + + return normalized; +} + +export function normalizeOpencodeModelAliases( + value: unknown, + discoveredModels: OpencodeDiscoveredModel[] = [], +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const normalized: Record = {}; + for (const [rawId, alias] of Object.entries(value as Record)) { + if (typeof alias !== 'string') { + continue; + } + + const normalizedRawId = resolveOpencodeBaseModelRawId(rawId.trim(), discoveredModels); + const normalizedAlias = alias.trim(); + if (!normalizedRawId || !normalizedAlias) { + continue; + } + + normalized[normalizedRawId] = normalizedAlias; + } + + return normalized; +} + +export function normalizeOpencodePreferredThinkingByModel( + value: unknown, + discoveredModels: OpencodeDiscoveredModel[] = [], +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const normalized: Record = {}; + for (const [rawId, thinkingLevel] of Object.entries(value as Record)) { + if (typeof thinkingLevel !== 'string') { + continue; + } + + const normalizedRawId = resolveOpencodeBaseModelRawId(rawId.trim(), discoveredModels); + const normalizedThinkingLevel = thinkingLevel.trim(); + if (!normalizedRawId || !normalizedThinkingLevel) { + continue; + } + + normalized[normalizedRawId] = normalizedThinkingLevel; + } + + return normalized; +} + +export function getOpencodeProviderSettings( + settings: Record, +): OpencodeProviderSettings { + const config = getProviderConfig(settings, 'opencode'); + const normalizedCliPathsByHost = normalizeHostnameCliPaths(config.cliPathsByHost); + const cliPathsByHost = Object.keys(normalizedCliPathsByHost).length > 0 + ? migrateLegacyHostnameKeyedMap( + normalizedCliPathsByHost, + getHostnameKey(), + getLegacyHostnameKey(), + ) + : normalizedCliPathsByHost; + seedOpencodeDiscoveryStateFromLegacyConfig(settings, config); + const discoveryState = getOpencodeDiscoveryState(settings); + const availableModes = discoveryState.availableModes; + const discoveredModels = discoveryState.discoveredModels; + const persistedThinkingOptionsByModel = normalizeOpencodeThinkingOptionsByModel( + config.thinkingOptionsByModel, + discoveredModels, + ); + const thinkingOptionsByModel = normalizeOpencodeThinkingOptionsByModel({ + ...persistedThinkingOptionsByModel, + ...discoveryState.thinkingOptionsByModel, + }, discoveredModels); + + return { + availableModes, + cliPath: (config.cliPath as string | undefined) + ?? DEFAULT_OPENCODE_PROVIDER_SETTINGS.cliPath, + cliPathsByHost, + discoveredModels, + enabled: (config.enabled as boolean | undefined) + ?? DEFAULT_OPENCODE_PROVIDER_SETTINGS.enabled, + environmentHash: (config.environmentHash as string | undefined) + ?? DEFAULT_OPENCODE_PROVIDER_SETTINGS.environmentHash, + environmentVariables: (config.environmentVariables as string | undefined) + ?? getProviderEnvironmentVariables(settings, 'opencode') + ?? DEFAULT_OPENCODE_PROVIDER_SETTINGS.environmentVariables, + modelAliases: normalizeOpencodeModelAliases(config.modelAliases, discoveredModels), + preferredThinkingByModel: normalizeOpencodePreferredThinkingByModel( + config.preferredThinkingByModel, + discoveredModels, + ), + selectedMode: normalizeManagedOpencodeSelectedMode(config.selectedMode, availableModes), + thinkingOptionsByModel, + visibleModels: normalizeOpencodeVisibleModels(config.visibleModels, discoveredModels), + }; +} + +export function updateOpencodeProviderSettings( + settings: Record, + updates: Partial, +): OpencodeProviderSettings { + const current = getOpencodeProviderSettings(settings); + const hostnameKey = getHostnameKey(); + if ('availableModes' in updates || 'discoveredModels' in updates || 'thinkingOptionsByModel' in updates) { + updateOpencodeDiscoveryState(settings, { + ...(updates.availableModes !== undefined ? { availableModes: updates.availableModes } : {}), + ...(updates.discoveredModels !== undefined ? { discoveredModels: updates.discoveredModels } : {}), + ...(updates.thinkingOptionsByModel !== undefined + ? { thinkingOptionsByModel: updates.thinkingOptionsByModel } + : {}), + }); + } + const discoveryState = getOpencodeDiscoveryState(settings); + const nextAvailableModes = discoveryState.availableModes; + const nextDiscoveredModels = discoveryState.discoveredModels; + const nextThinkingOptionsByModel = updates.thinkingOptionsByModel !== undefined + ? discoveryState.thinkingOptionsByModel + : normalizeOpencodeThinkingOptionsByModel( + current.thinkingOptionsByModel, + nextDiscoveredModels, + ); + const nextSelectedMode = normalizeManagedOpencodeSelectedMode( + updates.selectedMode ?? current.selectedMode, + nextAvailableModes, + ); + const nextVisibleModels = normalizeOpencodeVisibleModels( + updates.visibleModels ?? current.visibleModels, + nextDiscoveredModels, + ); + const nextModelAliases = pruneModelAliasesToVisible( + normalizeOpencodeModelAliases( + updates.modelAliases ?? current.modelAliases, + nextDiscoveredModels, + ), + nextVisibleModels, + ); + const nextCliPathsByHost = 'cliPathsByHost' in updates + ? normalizeHostnameCliPaths(updates.cliPathsByHost) + : { ...current.cliPathsByHost }; + let nextCliPath = 'cliPathsByHost' in updates + ? ( + typeof updates.cliPath === 'string' + ? updates.cliPath.trim() + : DEFAULT_OPENCODE_PROVIDER_SETTINGS.cliPath + ) + : current.cliPath.trim(); + + if ('cliPath' in updates && !('cliPathsByHost' in updates)) { + const trimmedCliPath = typeof updates.cliPath === 'string' ? updates.cliPath.trim() : ''; + if (trimmedCliPath) { + nextCliPathsByHost[hostnameKey] = trimmedCliPath; + } else { + delete nextCliPathsByHost[hostnameKey]; + } + nextCliPath = DEFAULT_OPENCODE_PROVIDER_SETTINGS.cliPath; + } + + const next: OpencodeProviderSettings = { + ...current, + ...updates, + availableModes: nextAvailableModes, + cliPath: nextCliPath, + cliPathsByHost: nextCliPathsByHost, + discoveredModels: nextDiscoveredModels, + modelAliases: nextModelAliases, + preferredThinkingByModel: normalizeOpencodePreferredThinkingByModel( + updates.preferredThinkingByModel ?? current.preferredThinkingByModel, + nextDiscoveredModels, + ), + selectedMode: nextSelectedMode, + thinkingOptionsByModel: nextThinkingOptionsByModel, + visibleModels: nextVisibleModels, + }; + + if (updates.visibleModels !== undefined) { + retargetRemovedOpencodeSelections(settings, next); + } + + const persistedThinkingOptionsByModel = pruneThinkingOptionsToPersistedSelections( + settings, + next, + ); + + setProviderConfig(settings, 'opencode', { + cliPath: next.cliPath, + cliPathsByHost: next.cliPathsByHost, + enabled: next.enabled, + environmentHash: next.environmentHash, + environmentVariables: next.environmentVariables, + modelAliases: next.modelAliases, + preferredThinkingByModel: next.preferredThinkingByModel, + selectedMode: next.selectedMode, + thinkingOptionsByModel: persistedThinkingOptionsByModel, + visibleModels: next.visibleModels, + }); + + return next; +} + +export function hasLegacyOpencodeDiscoveryFields(settings: Record): boolean { + const config = getProviderConfig(settings, 'opencode'); + return 'availableModes' in config || 'discoveredModels' in config; +} + +function pruneModelAliasesToVisible( + aliases: Record, + visibleModels: string[], +): Record { + if (visibleModels.length === 0 || Object.keys(aliases).length === 0) { + return {}; + } + + const visibleSet = new Set(visibleModels); + const pruned: Record = {}; + for (const [rawId, alias] of Object.entries(aliases)) { + if (visibleSet.has(rawId)) { + pruned[rawId] = alias; + } + } + return pruned; +} + +function pruneThinkingOptionsToPersistedSelections( + settings: Record, + next: OpencodeProviderSettings, +): OpencodeThinkingOptionsByModel { + const persistableRawIds = new Set(next.visibleModels); + addPersistableSelection(persistableRawIds, settings.model, next.discoveredModels); + addPersistableSelection(persistableRawIds, settings.titleGenerationModel, next.discoveredModels); + + const savedProviderModel = settings.savedProviderModel; + if (savedProviderModel && typeof savedProviderModel === 'object' && !Array.isArray(savedProviderModel)) { + addPersistableSelection( + persistableRawIds, + (savedProviderModel as Record).opencode, + next.discoveredModels, + ); + } + + const pruned: OpencodeThinkingOptionsByModel = {}; + for (const rawId of persistableRawIds) { + const options = next.thinkingOptionsByModel[rawId]; + if (options?.length) { + pruned[rawId] = options.map((option) => ({ ...option })); + } + } + return pruned; +} + +function addPersistableSelection( + target: Set, + value: unknown, + discoveredModels: OpencodeDiscoveredModel[], +): void { + if (typeof value !== 'string' || !isOpencodeModelSelectionId(value)) { + return; + } + + const rawModelId = decodeOpencodeModelId(value); + if (!rawModelId) { + return; + } + + const baseRawId = resolveOpencodeBaseModelRawId(rawModelId, discoveredModels); + if (baseRawId) { + target.add(baseRawId); + } +} + +function retargetRemovedOpencodeSelections( + settings: Record, + next: OpencodeProviderSettings, +): void { + if (next.visibleModels.length === 0) { + if ( + typeof settings.titleGenerationModel === 'string' + && isOpencodeModelSelectionId(settings.titleGenerationModel) + ) { + settings.titleGenerationModel = ''; + } + return; + } + + const visibleSet = new Set(next.visibleModels); + const fallbackRawId = next.visibleModels[0]; + const fallbackModelId = encodeOpencodeModelId(fallbackRawId); + const fallbackEffort = resolveOpencodeDefaultThinkingLevel( + next.thinkingOptionsByModel[fallbackRawId] ?? [], + next.preferredThinkingByModel[fallbackRawId], + ); + + const maybeRetargetModel = (value: unknown): string | null => { + if (typeof value !== 'string' || !isOpencodeModelSelectionId(value)) { + return null; + } + + const rawModelId = decodeOpencodeModelId(value); + if (!rawModelId) { + return fallbackModelId; + } + + const baseRawId = resolveOpencodeBaseModelRawId(rawModelId, next.discoveredModels); + return visibleSet.has(baseRawId) ? null : fallbackModelId; + }; + + const savedProviderModel = ensureProviderProjectionMap(settings, 'savedProviderModel'); + const nextSavedModel = maybeRetargetModel(savedProviderModel.opencode); + if (nextSavedModel) { + savedProviderModel.opencode = nextSavedModel; + ensureProviderProjectionMap(settings, 'savedProviderEffort').opencode = fallbackEffort; + } + + const nextTopLevelModel = maybeRetargetModel(settings.model); + if (nextTopLevelModel) { + settings.model = nextTopLevelModel; + settings.effortLevel = fallbackEffort; + } + + const nextTitleGenerationModel = maybeRetargetModel(settings.titleGenerationModel); + if (nextTitleGenerationModel) { + settings.titleGenerationModel = nextTitleGenerationModel; + } +} diff --git a/src/providers/opencode/storage/OpencodeAgentStorage.ts b/src/providers/opencode/storage/OpencodeAgentStorage.ts new file mode 100644 index 0000000..0264aa8 --- /dev/null +++ b/src/providers/opencode/storage/OpencodeAgentStorage.ts @@ -0,0 +1,346 @@ +import * as path from 'node:path'; + +import type { VaultFileAdapter } from '../../../core/storage/VaultFileAdapter'; +import { extractBoolean, isRecord, parseFrontmatter } from '../../../utils/frontmatter'; +import { yamlString } from '../../../utils/slashCommand'; +import { + OPENCODE_AGENT_KNOWN_KEYS, + type OpencodeAgentDefinition, +} from '../types/agent'; + +export const OPENCODE_AGENT_PATH = '.opencode/agent'; +export const OPENCODE_AGENTS_PATH = '.opencode/agents'; +const OPENCODE_AGENT_SCAN_PATHS = [ + OPENCODE_AGENTS_PATH, + OPENCODE_AGENT_PATH, +] as const; +const OPENCODE_DEFAULT_AGENT_SAVE_PATH = OPENCODE_AGENT_PATH; +const OPENCODE_AGENT_PERSISTENCE_PREFIX = 'opencode-agent'; + +export interface OpencodeAgentLocation { + filePath: string; +} + +export function createOpencodeAgentPersistenceKey( + location: OpencodeAgentLocation, +): string { + return `${OPENCODE_AGENT_PERSISTENCE_PREFIX}:${encodeURIComponent(normalizeVaultPath(location.filePath))}`; +} + +export function parseOpencodeAgentPersistenceKey( + persistenceKey?: string, +): OpencodeAgentLocation | null { + if (!persistenceKey) { + return null; + } + + const normalizedKey = normalizeVaultPath(persistenceKey); + if (isSupportedAgentFilePath(normalizedKey)) { + return { filePath: normalizedKey }; + } + + const [prefix, encodedRelativePath] = persistenceKey.split(':'); + if (prefix !== OPENCODE_AGENT_PERSISTENCE_PREFIX || !encodedRelativePath) { + return null; + } + + const decoded = normalizeVaultPath(decodeURIComponent(encodedRelativePath)); + if (isSupportedAgentFilePath(decoded)) { + return { filePath: decoded }; + } + + return decoded.endsWith('.md') + ? { filePath: `${OPENCODE_AGENTS_PATH}/${decoded}` } + : null; +} + +export class OpencodeAgentStorage { + constructor( + private vaultAdapter: Pick, + ) {} + + async loadAll(): Promise { + return this.scanAdapter(this.vaultAdapter); + } + + async load(agent: OpencodeAgentDefinition): Promise { + const filePath = this.resolveCurrentPath(agent); + try { + if (!(await this.vaultAdapter.exists(filePath))) return null; + const content = await this.vaultAdapter.read(filePath); + return parseOpencodeAgentMarkdown(content, filePath); + } catch { + return null; + } + } + + async save(agent: OpencodeAgentDefinition, previous?: OpencodeAgentDefinition | null): Promise { + const filePath = this.resolveTargetPath(agent, previous); + const previousPath = previous ? this.resolveCurrentPath(previous) : null; + await this.vaultAdapter.ensureFolder(path.posix.dirname(filePath)); + const content = serializeOpencodeAgentMarkdown(agent); + await this.vaultAdapter.write(filePath, content); + + if (previousPath && previousPath !== filePath) { + await this.vaultAdapter.delete(previousPath); + } + } + + async delete(agent: OpencodeAgentDefinition): Promise { + const filePath = this.resolveCurrentPath(agent); + await this.vaultAdapter.delete(filePath); + } + + private resolveCurrentPath(agent: OpencodeAgentDefinition): string { + const persistedLocation = parseOpencodeAgentPersistenceKey(agent.persistenceKey); + if (persistedLocation) { + return persistedLocation.filePath; + } + + return `${OPENCODE_DEFAULT_AGENT_SAVE_PATH}/${agent.name}.md`; + } + + private resolveTargetPath( + agent: OpencodeAgentDefinition, + previous?: OpencodeAgentDefinition | null, + ): string { + if (previous && previous.name === agent.name) { + return this.resolveCurrentPath(previous); + } + + return `${OPENCODE_DEFAULT_AGENT_SAVE_PATH}/${agent.name}.md`; + } + + private async scanAdapter( + adapter: Pick, + ): Promise { + const agentsByName = new Map(); + + for (const rootPath of OPENCODE_AGENT_SCAN_PATHS) { + try { + const files = await adapter.listFilesRecursive(rootPath); + for (const filePath of files) { + if (!filePath.endsWith('.md')) continue; + try { + const content = await adapter.read(filePath); + const agent = parseOpencodeAgentMarkdown(content, filePath); + if (!agent) continue; + + const dedupeKey = agent.name.toLowerCase(); + agentsByName.delete(dedupeKey); + agentsByName.set(dedupeKey, agent); + } catch { + // Skip malformed files + } + } + } catch { + // Directory doesn't exist yet + } + } + + return Array.from(agentsByName.values()); + } +} + +export function parseOpencodeAgentMarkdown( + content: string, + filePath: string, +): OpencodeAgentDefinition | null { + const parsed = parseFrontmatter(content); + if (!parsed) { + return null; + } + + const fileName = normalizeAgentNameFromPath(filePath); + const frontmatter = parsed.frontmatter; + const rawName = typeof frontmatter.name === 'string' ? frontmatter.name.trim() : ''; + const name = rawName || fileName; + const description = typeof frontmatter.description === 'string' ? frontmatter.description.trim() : ''; + + if (!name || !description) { + return null; + } + + const result: OpencodeAgentDefinition = { + name, + description, + prompt: parsed.body.trim(), + persistenceKey: createOpencodeAgentPersistenceKey({ + filePath: normalizeVaultPath(filePath), + }), + }; + + const mode = normalizeMode(frontmatter.mode); + if (mode) result.mode = mode; + + if (typeof frontmatter.model === 'string' && frontmatter.model.trim()) { + result.model = frontmatter.model.trim(); + } + if (typeof frontmatter.variant === 'string' && frontmatter.variant.trim()) { + result.variant = frontmatter.variant.trim(); + } + if (typeof frontmatter.temperature === 'number' && Number.isFinite(frontmatter.temperature)) { + result.temperature = frontmatter.temperature; + } + const topP = normalizeFiniteNumber(frontmatter.top_p); + if (topP !== undefined) { + result.topP = topP; + } + if (typeof frontmatter.color === 'string' && frontmatter.color.trim()) { + result.color = frontmatter.color.trim(); + } + + const steps = normalizePositiveInteger(frontmatter.steps) ?? normalizePositiveInteger(frontmatter.maxSteps); + if (steps !== undefined) { + result.steps = steps; + } + + if (extractBoolean(frontmatter, 'hidden') !== undefined) { + result.hidden = extractBoolean(frontmatter, 'hidden'); + } + if (extractBoolean(frontmatter, 'disable') !== undefined) { + result.disable = extractBoolean(frontmatter, 'disable'); + } + + if (isBooleanRecord(frontmatter.tools)) { + result.tools = { ...frontmatter.tools }; + } + if (isRecord(frontmatter.options)) { + result.options = { ...frontmatter.options }; + } + if (frontmatter.permission !== undefined) { + result.permission = frontmatter.permission; + } + + const extraFrontmatter: Record = {}; + for (const [key, value] of Object.entries(frontmatter)) { + if (!OPENCODE_AGENT_KNOWN_KEYS.has(key)) { + extraFrontmatter[key] = value; + } + } + if (Object.keys(extraFrontmatter).length > 0) { + result.extraFrontmatter = extraFrontmatter; + } + + return result; +} + +export function serializeOpencodeAgentMarkdown(agent: OpencodeAgentDefinition): string { + const lines: string[] = ['---']; + + lines.push(`name: ${yamlString(agent.name)}`); + lines.push(`description: ${yamlString(agent.description)}`); + + if (agent.mode) { + lines.push(`mode: ${agent.mode}`); + } + if (agent.model) { + lines.push(`model: ${serializeYamlValue(agent.model)}`); + } + if (agent.variant) { + lines.push(`variant: ${serializeYamlValue(agent.variant)}`); + } + if (agent.temperature !== undefined) { + lines.push(`temperature: ${serializeYamlValue(agent.temperature)}`); + } + if (agent.topP !== undefined) { + lines.push(`top_p: ${serializeYamlValue(agent.topP)}`); + } + if (agent.color) { + lines.push(`color: ${serializeYamlValue(agent.color)}`); + } + if (agent.steps !== undefined) { + lines.push(`steps: ${serializeYamlValue(agent.steps)}`); + } + if (agent.hidden) { + lines.push('hidden: true'); + } + if (agent.disable) { + lines.push('disable: true'); + } + if (agent.tools && Object.keys(agent.tools).length > 0) { + lines.push(`tools: ${serializeYamlValue(agent.tools)}`); + } + if (agent.options && Object.keys(agent.options).length > 0) { + lines.push(`options: ${serializeYamlValue(agent.options)}`); + } + if (agent.permission !== undefined) { + lines.push(`permission: ${serializeYamlValue(agent.permission)}`); + } + + if (agent.extraFrontmatter) { + for (const [key, value] of Object.entries(agent.extraFrontmatter)) { + lines.push(`${key}: ${serializeYamlValue(value)}`); + } + } + + lines.push('---'); + lines.push(agent.prompt); + + return lines.join('\n'); +} + +function normalizeAgentNameFromPath(filePath: string): string { + const relativePath = toRelativeAgentPath(filePath); + return relativePath.replace(/\.md$/i, ''); +} + +function toRelativeAgentPath(filePath: string): string { + const normalized = normalizeVaultPath(filePath); + + for (const rootPath of OPENCODE_AGENT_SCAN_PATHS) { + const prefix = `${rootPath}/`; + const index = normalized.lastIndexOf(prefix); + if (index >= 0) { + return normalized.slice(index + prefix.length); + } + } + + return normalized.split('/').pop() ?? normalized; +} + +function normalizeMode(value: unknown): OpencodeAgentDefinition['mode'] | undefined { + return value === 'subagent' || value === 'primary' || value === 'all' + ? value + : undefined; +} + +function normalizeFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function normalizePositiveInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value > 0 + ? value + : undefined; +} + +function isBooleanRecord(value: unknown): value is Record { + if (!isRecord(value)) { + return false; + } + + return Object.values(value).every((entry) => typeof entry === 'boolean'); +} + +function serializeYamlValue(value: unknown): string { + if (typeof value === 'string') { + return yamlString(value); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (value === null) { + return 'null'; + } + return JSON.stringify(value); +} + +function normalizeVaultPath(filePath: string): string { + return filePath.replace(/\\/g, '/'); +} + +function isSupportedAgentFilePath(filePath: string): boolean { + return OPENCODE_AGENT_SCAN_PATHS.some((rootPath) => filePath.startsWith(`${rootPath}/`)) + && filePath.endsWith('.md'); +} diff --git a/src/providers/opencode/types/agent.ts b/src/providers/opencode/types/agent.ts new file mode 100644 index 0000000..d923fca --- /dev/null +++ b/src/providers/opencode/types/agent.ts @@ -0,0 +1,37 @@ +export interface OpencodeAgentDefinition { + name: string; + description: string; + prompt: string; + mode?: 'subagent' | 'primary' | 'all'; + hidden?: boolean; + model?: string; + variant?: string; + temperature?: number; + topP?: number; + color?: string; + steps?: number; + disable?: boolean; + tools?: Record; + options?: Record; + permission?: unknown; + persistenceKey?: string; + extraFrontmatter?: Record; +} + +export const OPENCODE_AGENT_KNOWN_KEYS = new Set([ + 'name', + 'description', + 'mode', + 'model', + 'variant', + 'temperature', + 'top_p', + 'steps', + 'maxSteps', + 'hidden', + 'color', + 'disable', + 'tools', + 'options', + 'permission', +]); diff --git a/src/providers/opencode/types/index.ts b/src/providers/opencode/types/index.ts new file mode 100644 index 0000000..3527130 --- /dev/null +++ b/src/providers/opencode/types/index.ts @@ -0,0 +1,9 @@ +export interface OpencodeProviderState { + databasePath?: string; +} + +export function getOpencodeState( + providerState?: Record, +): OpencodeProviderState { + return (providerState ?? {}); +} diff --git a/src/providers/opencode/ui/OpencodeAgentSettings.ts b/src/providers/opencode/ui/OpencodeAgentSettings.ts new file mode 100644 index 0000000..3c6c4af --- /dev/null +++ b/src/providers/opencode/ui/OpencodeAgentSettings.ts @@ -0,0 +1,579 @@ +import type { App } from 'obsidian'; +import { Modal, Notice, setIcon, Setting } from 'obsidian'; + +import { confirmDelete } from '../../../shared/modals/ConfirmModal'; +import type { OpencodeAgentStorage } from '../storage/OpencodeAgentStorage'; +import type { OpencodeAgentDefinition } from '../types/agent'; + +const OPENCODE_AGENT_INVALID_SEGMENT_PATTERN = /[<>:"\\|?*]/; + +export function validateOpencodeAgentName(name: string): string | null { + if (!name) return 'Agent name is required'; + + const segments = name.split('/'); + if (segments.length === 0 || segments.some((segment) => segment.length === 0)) { + return 'Agent name must use slash-separated path segments without leading or trailing slashes'; + } + + for (const segment of segments) { + if (!segment.trim()) { + return 'Agent name path segments cannot be empty or whitespace-only'; + } + + if (segment !== segment.trim()) { + return 'Agent name path segments cannot start or end with whitespace'; + } + + if (segment === '.' || segment === '..') { + return 'Agent name cannot include "." or ".." path segments'; + } + + if (segment.includes('\0') || OPENCODE_AGENT_INVALID_SEGMENT_PATTERN.test(segment)) { + return 'Agent name path segments cannot contain Windows-reserved filename characters'; + } + } + + return null; +} + +export function findOpencodeAgentNameConflict( + agents: OpencodeAgentDefinition[], + name: string, + currentPersistenceKey?: string, +): OpencodeAgentDefinition | null { + const normalizedName = name.toLowerCase(); + return agents.find( + (agent) => agent.name.toLowerCase() === normalizedName + && agent.persistenceKey !== currentPersistenceKey, + ) ?? null; +} + +class OpencodeAgentModal extends Modal { + private existing: OpencodeAgentDefinition | null; + private allAgents: OpencodeAgentDefinition[]; + private onSave: (agent: OpencodeAgentDefinition) => Promise; + + constructor( + app: App, + existing: OpencodeAgentDefinition | null, + allAgents: OpencodeAgentDefinition[], + onSave: (agent: OpencodeAgentDefinition) => Promise, + ) { + super(app); + this.existing = existing; + this.allAgents = allAgents; + this.onSave = onSave; + } + + onOpen() { + this.setTitle(this.existing ? 'Edit OpenCode Subagent' : 'Add OpenCode Subagent'); + this.modalEl.addClass('claudian-sp-modal'); + + const { contentEl } = this; + + let nameInput!: HTMLInputElement; + let descriptionInput!: HTMLInputElement; + let modelInput!: HTMLInputElement; + let variantInput!: HTMLInputElement; + let temperatureInput!: HTMLInputElement; + let topPInput!: HTMLInputElement; + let colorInput!: HTMLInputElement; + let stepsInput!: HTMLInputElement; + let hiddenValue = this.existing?.hidden ?? false; + let disableValue = this.existing?.disable ?? false; + let toolsInput!: HTMLTextAreaElement; + let permissionInput!: HTMLTextAreaElement; + let optionsInput!: HTMLTextAreaElement; + + new Setting(contentEl) + .setName('Name') + .setDesc('OpenCode agent name. Use slash-separated segments for nested agents.') + .addText((text) => { + nameInput = text.inputEl; + text.setValue(this.existing?.name ?? '') + .setPlaceholder('Review'); + }); + + new Setting(contentEl) + .setName('Description') + .setDesc('When OpenCode should use this subagent') + .addText((text) => { + descriptionInput = text.inputEl; + text.setValue(this.existing?.description ?? '') + .setPlaceholder('Reviews code for correctness and maintainability'); + }); + + const details = contentEl.createEl('details', { cls: 'claudian-sp-advanced-section' }); + details.createEl('summary', { + text: 'Advanced options', + cls: 'claudian-sp-advanced-summary', + }); + if ( + this.existing?.model || + this.existing?.variant || + this.existing?.temperature !== undefined || + this.existing?.topP !== undefined || + this.existing?.color || + this.existing?.steps !== undefined || + this.existing?.hidden || + this.existing?.disable || + this.existing?.tools || + this.existing?.permission !== undefined || + this.existing?.options + ) { + details.open = true; + } + + new Setting(details) + .setName('Model') + .setDesc('Model override in provider/model format') + .addText((text) => { + modelInput = text.inputEl; + text.setValue(this.existing?.model ?? '') + .setPlaceholder('Anthropic/Claude-sonnet-4-20250514'); + }); + + new Setting(details) + .setName('Variant') + .setDesc('Model variant override') + .addText((text) => { + variantInput = text.inputEl; + text.setValue(this.existing?.variant ?? '') + .setPlaceholder('High'); + }); + + new Setting(details) + .setName('Temperature') + .setDesc('Optional sampling temperature') + .addText((text) => { + temperatureInput = text.inputEl; + text.setValue(this.existing?.temperature !== undefined ? String(this.existing.temperature) : '') + .setPlaceholder('0.1'); + }); + + new Setting(details) + .setName('Top p') + .setDesc('Optional nucleus sampling value') + .addText((text) => { + topPInput = text.inputEl; + text.setValue(this.existing?.topP !== undefined ? String(this.existing.topP) : '') + .setPlaceholder('0.9'); + }); + + new Setting(details) + .setName('Color') + .setDesc('Hex color or theme token') + .addText((text) => { + colorInput = text.inputEl; + text.setValue(this.existing?.color ?? '') + .setPlaceholder('#Ff5733'); + }); + + new Setting(details) + .setName('Steps') + .setDesc('Maximum agentic iterations before forcing text-only output') + .addText((text) => { + stepsInput = text.inputEl; + text.setValue(this.existing?.steps !== undefined ? String(this.existing.steps) : '') + .setPlaceholder('10'); + }); + + new Setting(details) + .setName('Hide from @mention') + .setDesc('Hide this subagent from the @ autocomplete menu') + .addToggle((toggle) => { + toggle.setValue(hiddenValue).onChange((value) => { + hiddenValue = value; + }); + }); + + new Setting(details) + .setName('Disable agent') + .setDesc('Disable the agent without deleting the file') + .addToggle((toggle) => { + toggle.setValue(disableValue).onChange((value) => { + disableValue = value; + }); + }); + + new Setting(details) + .setName('Enabled tools (JSON)') + .setDesc('Optional deprecated tools map, e.g. {"write":false,"edit":false}') + .addTextArea((text) => { + toolsInput = text.inputEl; + text.setValue(this.existing?.tools ? JSON.stringify(this.existing.tools, null, 2) : '') + .setPlaceholder('{\n "write": false,\n "edit": false\n}'); + }); + + new Setting(details) + .setName('Permission (JSON)') + .setDesc('Optional permission config, e.g. {"edit":"deny","bash":"allow"}') + .addTextArea((text) => { + permissionInput = text.inputEl; + text.setValue(this.existing?.permission !== undefined ? JSON.stringify(this.existing.permission, null, 2) : '') + .setPlaceholder('{\n "edit": "deny"\n}'); + }); + + new Setting(details) + .setName('Options (JSON)') + .setDesc('Optional custom agent options') + .addTextArea((text) => { + optionsInput = text.inputEl; + text.setValue(this.existing?.options ? JSON.stringify(this.existing.options, null, 2) : '') + .setPlaceholder('{\n "focus": "security"\n}'); + }); + + new Setting(contentEl) + .setName('Prompt') + .setDesc('Markdown body used as the agent prompt'); + + const promptArea = contentEl.createEl('textarea', { + cls: 'claudian-sp-content-area', + attr: { + rows: '10', + placeholder: 'Review code changes carefully and call out correctness, regressions, and missing coverage.', + }, + }); + promptArea.value = this.existing?.prompt ?? ''; + + const buttonContainer = contentEl.createDiv({ cls: 'claudian-sp-modal-buttons' }); + + const cancelBtn = buttonContainer.createEl('button', { + text: 'Cancel', + cls: 'claudian-cancel-btn', + }); + cancelBtn.addEventListener('click', () => this.close()); + + const saveBtn = buttonContainer.createEl('button', { + text: 'Save', + cls: 'claudian-save-btn', + }); + saveBtn.addEventListener('click', () => { + void (async (): Promise => { + const name = nameInput.value.trim(); + const nameError = validateOpencodeAgentName(name); + if (nameError) { + new Notice(nameError); + return; + } + + const description = descriptionInput.value.trim(); + if (!description) { + new Notice('Description is required'); + return; + } + + const prompt = promptArea.value; + if (!prompt.trim()) { + new Notice('Prompt is required'); + return; + } + + const duplicate = findOpencodeAgentNameConflict( + this.allAgents, + name, + this.existing?.persistenceKey, + ); + if (duplicate) { + new Notice(`A subagent named "${name}" already exists`); + return; + } + + const temperature = parseOptionalNumber(temperatureInput.value, 'Temperature'); + if (temperature.error) { + new Notice(temperature.error); + return; + } + + const topP = parseOptionalNumber(topPInput.value, 'Top P'); + if (topP.error) { + new Notice(topP.error); + return; + } + + const steps = parseOptionalPositiveInteger(stepsInput.value, 'Steps'); + if (steps.error) { + new Notice(steps.error); + return; + } + + const tools = parseOptionalJsonObjectOfBooleans(toolsInput.value, 'Enabled Tools'); + if (tools.error) { + new Notice(tools.error); + return; + } + + const permission = parseOptionalJson(permissionInput.value, 'Permission'); + if (permission.error) { + new Notice(permission.error); + return; + } + + const options = parseOptionalJsonObject(optionsInput.value, 'Options'); + if (options.error) { + new Notice(options.error); + return; + } + + const agent: OpencodeAgentDefinition = { + name, + description, + prompt, + mode: 'subagent', + hidden: hiddenValue || undefined, + disable: disableValue || undefined, + model: modelInput.value.trim() || undefined, + variant: variantInput.value.trim() || undefined, + temperature: temperature.value, + topP: topP.value, + color: colorInput.value.trim() || undefined, + steps: steps.value, + tools: tools.value, + permission: permission.value, + options: options.value, + persistenceKey: this.existing?.persistenceKey, + extraFrontmatter: this.existing?.extraFrontmatter, + }; + + try { + await this.onSave(agent); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + new Notice(`Failed to save subagent: ${message}`); + return; + } + this.close(); + })(); + }); + } + + onClose() { + this.contentEl.empty(); + } +} + +export class OpencodeAgentSettings { + private containerEl: HTMLElement; + private storage: OpencodeAgentStorage; + private agents: OpencodeAgentDefinition[] = []; + private app?: App; + private onChanged?: () => Promise | void; + + constructor( + containerEl: HTMLElement, + storage: OpencodeAgentStorage, + app?: App, + onChanged?: () => Promise | void, + ) { + this.containerEl = containerEl; + this.storage = storage; + this.app = app; + this.onChanged = onChanged; + void this.render(); + } + + async render(): Promise { + this.containerEl.empty(); + + try { + this.agents = await this.storage.loadAll(); + } catch { + this.agents = []; + } + + const visibleAgents = this.agents.filter((agent) => agent.mode === 'subagent'); + + const headerEl = this.containerEl.createDiv({ cls: 'claudian-sp-header' }); + headerEl.createSpan({ text: 'OpenCode Subagents', cls: 'claudian-sp-label' }); + + const actionsEl = headerEl.createDiv({ cls: 'claudian-sp-header-actions' }); + + const refreshBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': 'Refresh' }, + }); + setIcon(refreshBtn, 'refresh-cw'); + refreshBtn.addEventListener('click', () => { void this.render(); }); + + const addBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': 'Add' }, + }); + setIcon(addBtn, 'plus'); + addBtn.addEventListener('click', () => this.openModal(null)); + + if (visibleAgents.length === 0) { + const emptyEl = this.containerEl.createDiv({ cls: 'claudian-sp-empty-state' }); + emptyEl.setText('No OpenCode subagents in vault. Click + to create one.'); + return; + } + + const listEl = this.containerEl.createDiv({ cls: 'claudian-sp-list' }); + for (const agent of visibleAgents) { + this.renderItem(listEl, agent); + } + } + + private renderItem(listEl: HTMLElement, agent: OpencodeAgentDefinition): void { + const itemEl = listEl.createDiv({ cls: 'claudian-sp-item' }); + const infoEl = itemEl.createDiv({ cls: 'claudian-sp-info' }); + + const headerRow = infoEl.createDiv({ cls: 'claudian-sp-item-header' }); + const nameEl = headerRow.createSpan({ cls: 'claudian-sp-item-name' }); + nameEl.setText(agent.name); + + headerRow.createSpan({ + text: 'subagent', + cls: 'claudian-slash-item-badge', + }); + + if (agent.model) { + headerRow.createSpan({ text: agent.model, cls: 'claudian-slash-item-badge' }); + } + + if (agent.description) { + const descEl = infoEl.createDiv({ cls: 'claudian-sp-item-desc' }); + descEl.setText(agent.description); + } + + const actionsEl = itemEl.createDiv({ cls: 'claudian-sp-item-actions' }); + + const editBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': 'Edit' }, + }); + setIcon(editBtn, 'pencil'); + editBtn.addEventListener('click', () => this.openModal(agent)); + + const deleteBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn claudian-settings-delete-btn', + attr: { 'aria-label': 'Delete' }, + }); + setIcon(deleteBtn, 'trash-2'); + deleteBtn.addEventListener('click', () => { + void (async (): Promise => { + if (!this.app) return; + const confirmed = await confirmDelete( + this.app, + `Delete subagent "${agent.name}"?`, + ); + if (!confirmed) return; + try { + await this.storage.delete(agent); + await this.render(); + await this.onChanged?.(); + new Notice(`Subagent "${agent.name}" deleted`); + } catch { + new Notice('Failed to delete subagent'); + } + })(); + }); + } + + private openModal(existing: OpencodeAgentDefinition | null): void { + if (!this.app) return; + + const modal = new OpencodeAgentModal( + this.app, + existing, + this.agents, + async (agent) => { + await this.storage.save(agent, existing); + await this.render(); + await this.onChanged?.(); + new Notice( + existing + ? `Subagent "${agent.name}" updated` + : `Subagent "${agent.name}" created`, + ); + }, + ); + modal.open(); + } +} + +function parseOptionalNumber( + value: string, + label: string, +): { error?: string; value?: number } { + const trimmed = value.trim(); + if (!trimmed) { + return {}; + } + + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) { + return { error: `${label} must be a valid number` }; + } + + return { value: parsed }; +} + +function parseOptionalPositiveInteger( + value: string, + label: string, +): { error?: string; value?: number } { + const trimmed = value.trim(); + if (!trimmed) { + return {}; + } + + const parsed = Number(trimmed); + if (!Number.isInteger(parsed) || parsed <= 0) { + return { error: `${label} must be a positive integer` }; + } + + return { value: parsed }; +} + +function parseOptionalJson( + value: string, + label: string, +): { error?: string; value?: unknown } { + const trimmed = value.trim(); + if (!trimmed) { + return {}; + } + + try { + return { value: JSON.parse(trimmed) }; + } catch { + return { error: `${label} must be valid JSON` }; + } +} + +function parseOptionalJsonObject( + value: string, + label: string, +): { error?: string; value?: Record } { + const parsed = parseOptionalJson(value, label); + if (parsed.error || parsed.value === undefined) { + return parsed.error ? { error: parsed.error } : {}; + } + + if (!isJsonObject(parsed.value)) { + return { error: `${label} must be a JSON object` }; + } + + return { value: parsed.value }; +} + +function parseOptionalJsonObjectOfBooleans( + value: string, + label: string, +): { error?: string; value?: Record } { + const parsed = parseOptionalJsonObject(value, label); + if (parsed.error || parsed.value === undefined) { + return parsed.error ? { error: parsed.error } : {}; + } + + if (!Object.values(parsed.value).every((entry) => typeof entry === 'boolean')) { + return { error: `${label} must map tool names to boolean values` }; + } + + return { value: parsed.value as Record }; +} + +function isJsonObject(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/providers/opencode/ui/OpencodeChatUIConfig.ts b/src/providers/opencode/ui/OpencodeChatUIConfig.ts new file mode 100644 index 0000000..85712b4 --- /dev/null +++ b/src/providers/opencode/ui/OpencodeChatUIConfig.ts @@ -0,0 +1,311 @@ +import type { + ProviderChatUIConfig, + ProviderPermissionModeToggleConfig, + ProviderReasoningOption, + ProviderUIOption, +} from '../../../core/providers/types'; +import { OPENCODE_PROVIDER_ICON } from '../../../shared/icons'; +import { + buildOpencodeBaseModels, + decodeOpencodeModelId, + encodeOpencodeModelId, + isOpencodeModelSelectionId, + OPENCODE_DEFAULT_THINKING_LEVEL, + OPENCODE_SYNTHETIC_MODEL_ID, + resolveOpencodeBaseModelRawId, + resolveOpencodeDefaultThinkingLevel, +} from '../models'; +import { + resolveOpencodeModeForPermissionMode, + resolvePermissionModeForManagedOpencodeMode, +} from '../modes'; +import { OpencodeChatRuntime } from '../runtime/OpencodeChatRuntime'; +import { getOpencodeProviderSettings, updateOpencodeProviderSettings } from '../settings'; + +const OPENCODE_MODELS: ProviderUIOption[] = [ + { value: OPENCODE_SYNTHETIC_MODEL_ID, label: 'OpenCode', description: 'ACP runtime' }, +]; +const DEFAULT_CONTEXT_WINDOW = 200_000; +const OPENCODE_METADATA_WARMUP_DB = ':memory:'; +const OPENCODE_PERMISSION_MODE_TOGGLE: ProviderPermissionModeToggleConfig = { + inactiveValue: 'normal', + inactiveLabel: 'Safe', + activeValue: 'yolo', + activeLabel: 'YOLO', + planValue: 'plan', + planLabel: 'Plan', +}; + +export const opencodeChatUIConfig: ProviderChatUIConfig = { + getModelOptions(settings): ProviderUIOption[] { + const opencodeSettings = getOpencodeProviderSettings(settings); + const applyAlias = (rawId: string, option: ProviderUIOption): ProviderUIOption => { + const alias = opencodeSettings.modelAliases[rawId]; + return alias ? { ...option, label: alias } : option; + }; + const discoveredModels = new Map(buildOpencodeBaseModels(opencodeSettings.discoveredModels).map((model) => [ + encodeOpencodeModelId(model.rawId), + applyAlias(model.rawId, { + description: model.description ?? 'ACP runtime', + label: model.label, + value: encodeOpencodeModelId(model.rawId), + }), + ])); + const savedProviderModel = ( + settings.savedProviderModel + && typeof settings.savedProviderModel === 'object' + && !Array.isArray(settings.savedProviderModel) + ) + ? settings.savedProviderModel as Record + : null; + + const seenValues = new Set(); + const options: ProviderUIOption[] = []; + for (const rawModelId of opencodeSettings.visibleModels) { + const encodedModelId = encodeOpencodeModelId(rawModelId); + pushOption( + options, + seenValues, + encodedModelId, + discoveredModels.get(encodedModelId) + ?? applyAlias(rawModelId, { + description: 'Configured model', + label: rawModelId, + value: encodedModelId, + }), + ); + } + + const selectedModelValues = [ + typeof settings.model === 'string' ? settings.model : '', + typeof savedProviderModel?.opencode === 'string' + ? savedProviderModel.opencode + : '', + ]; + + for (const model of selectedModelValues) { + const rawModelId = decodeOpencodeModelId(model); + if ( + !model + || !isOpencodeModelSelectionId(model) + || model === OPENCODE_SYNTHETIC_MODEL_ID + || !rawModelId + ) { + continue; + } + + const baseRawId = resolveOpencodeBaseModelRawId(rawModelId, opencodeSettings.discoveredModels); + const baseModelId = encodeOpencodeModelId(baseRawId); + pushOption( + options, + seenValues, + baseModelId, + discoveredModels.get(baseModelId) + ?? applyAlias(baseRawId, { + description: 'Selected in an existing session', + label: baseRawId, + value: baseModelId, + }), + ); + } + + return options.length > 0 ? options : [...OPENCODE_MODELS]; + }, + + ownsModel(model: string): boolean { + return isOpencodeModelSelectionId(model); + }, + + isAdaptiveReasoningModel(model: string, settings: Record): boolean { + return getOpencodeThinkingOptions(model, settings).length > 0; + }, + + getReasoningOptions(model: string, settings: Record): ProviderReasoningOption[] { + return getOpencodeThinkingOptions(model, settings) + .map((variant) => ({ + description: variant.description, + label: variant.label, + value: variant.value, + })); + }, + + getDefaultReasoningValue(model: string, settings: Record): string { + const rawModelId = decodeOpencodeModelId(model); + if (!rawModelId) { + return OPENCODE_DEFAULT_THINKING_LEVEL; + } + + const opencodeSettings = getOpencodeProviderSettings(settings); + const baseRawId = resolveOpencodeBaseModelRawId(rawModelId, opencodeSettings.discoveredModels); + return getDefaultThinkingLevelForModel(baseRawId, settings); + }, + + getContextWindowSize(model: string, customLimits?: Record): number { + return customLimits?.[model] ?? DEFAULT_CONTEXT_WINDOW; + }, + + isDefaultModel(model: string): boolean { + return isOpencodeModelSelectionId(model); + }, + + applyModelDefaults(model: string, settings: unknown): void { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + return; + } + + const settingsBag = settings as Record; + const rawModelId = decodeOpencodeModelId(model); + if (!rawModelId) { + settingsBag.effortLevel = OPENCODE_DEFAULT_THINKING_LEVEL; + return; + } + + const opencodeSettings = getOpencodeProviderSettings(settingsBag); + const baseRawId = resolveOpencodeBaseModelRawId(rawModelId, opencodeSettings.discoveredModels); + settingsBag.model = encodeOpencodeModelId(baseRawId); + settingsBag.effortLevel = getDefaultThinkingLevelForModel(baseRawId, settingsBag); + }, + + async prepareModelMetadata(model: string, _settings: Record, context): Promise { + const rawModelId = decodeOpencodeModelId(model); + if (!rawModelId) { + return; + } + + const opencodeSettings = getOpencodeProviderSettings(context.plugin.settings); + const baseRawId = resolveOpencodeBaseModelRawId(rawModelId, opencodeSettings.discoveredModels); + if (baseRawId && opencodeSettings.thinkingOptionsByModel[baseRawId]) { + return; + } + + const runtime = new OpencodeChatRuntime(context.plugin); + try { + runtime.syncConversationState({ + providerState: { databasePath: OPENCODE_METADATA_WARMUP_DB }, + sessionId: null, + }); + await runtime.warmModelMetadata(model); + } catch { + // Metadata warmup is opportunistic; the first real turn can still discover it. + } finally { + runtime.cleanup(); + } + }, + + applyReasoningSelection(model: string, value: string, settings: unknown): void { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + return; + } + + const settingsBag = settings as Record; + const rawModelId = decodeOpencodeModelId(model); + if (!rawModelId) { + return; + } + + const opencodeSettings = getOpencodeProviderSettings(settingsBag); + const baseRawId = resolveOpencodeBaseModelRawId(rawModelId, opencodeSettings.discoveredModels); + const supportedValues = new Set( + (opencodeSettings.thinkingOptionsByModel[baseRawId] ?? []).map((variant) => variant.value), + ); + const nextPreferredThinkingByModel = { + ...opencodeSettings.preferredThinkingByModel, + }; + + if (!value || value === OPENCODE_DEFAULT_THINKING_LEVEL || !supportedValues.has(value)) { + delete nextPreferredThinkingByModel[baseRawId]; + } else { + nextPreferredThinkingByModel[baseRawId] = value; + } + + updateOpencodeProviderSettings(settingsBag, { + preferredThinkingByModel: nextPreferredThinkingByModel, + }); + }, + + normalizeModelVariant(model: string, settings: Record): string { + const rawModelId = decodeOpencodeModelId(model); + if (!rawModelId) { + return model; + } + + const opencodeSettings = getOpencodeProviderSettings(settings); + const baseRawId = resolveOpencodeBaseModelRawId(rawModelId, opencodeSettings.discoveredModels); + return encodeOpencodeModelId(baseRawId); + }, + + getCustomModelIds(): Set { + return new Set(); + }, + + getModeSelector(): null { + return null; + }, + + getPermissionModeToggle(): ProviderPermissionModeToggleConfig { + return OPENCODE_PERMISSION_MODE_TOGGLE; + }, + + resolvePermissionMode(settings: Record): string | null { + const selectedMode = getOpencodeProviderSettings(settings).selectedMode; + return resolvePermissionModeForManagedOpencodeMode(selectedMode); + }, + + applyPermissionMode(value: string, settings: unknown): void { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + return; + } + + const settingsBag = settings as Record; + settingsBag.permissionMode = value; + updateOpencodeProviderSettings(settingsBag, { + selectedMode: resolveOpencodeModeForPermissionMode( + value, + getOpencodeProviderSettings(settingsBag).availableModes, + ), + }); + }, + + getProviderIcon() { + return OPENCODE_PROVIDER_ICON; + }, +}; + +function getDefaultThinkingLevelForModel( + baseRawId: string, + settings: Record, +): string { + const opencodeSettings = getOpencodeProviderSettings(settings); + return resolveOpencodeDefaultThinkingLevel( + opencodeSettings.thinkingOptionsByModel[baseRawId] ?? [], + opencodeSettings.preferredThinkingByModel[baseRawId], + ); +} + +function getOpencodeThinkingOptions( + model: string, + settings: Record, +): ProviderReasoningOption[] { + const rawModelId = decodeOpencodeModelId(model); + if (!rawModelId) { + return []; + } + + const opencodeSettings = getOpencodeProviderSettings(settings); + const baseRawId = resolveOpencodeBaseModelRawId(rawModelId, opencodeSettings.discoveredModels); + return opencodeSettings.thinkingOptionsByModel[baseRawId] ?? []; +} + +function pushOption( + target: ProviderUIOption[], + seenValues: Set, + value: string, + option: ProviderUIOption, +): void { + if (seenValues.has(value)) { + return; + } + + seenValues.add(value); + target.push(option); +} diff --git a/src/providers/opencode/ui/OpencodeSettingsTab.ts b/src/providers/opencode/ui/OpencodeSettingsTab.ts new file mode 100644 index 0000000..7227a0e --- /dev/null +++ b/src/providers/opencode/ui/OpencodeSettingsTab.ts @@ -0,0 +1,320 @@ +import * as fs from 'fs'; +import { Setting } from 'obsidian'; + +import type { + ProviderSettingsTabRenderer, + ProviderSettingsTabRendererContext, +} from '../../../core/providers/types'; +import { renderEnvironmentSettingsSection } from '../../../shared/settings/EnvironmentSettingsSection'; +import { + type ProviderModelPickerModel, + type ProviderModelPickerState, + renderProviderModelPicker, +} from '../../../shared/settings/ProviderModelPicker'; +import { getHostnameKey } from '../../../utils/env'; +import { expandHomePath } from '../../../utils/path'; +import { maybeGetOpencodeWorkspaceServices } from '../app/OpencodeWorkspaceServices'; +import { clearOpencodeDiscoveryState } from '../discoveryState'; +import { sameStringList } from '../internal/compareCollections'; +import { + buildOpencodeBaseModels, + encodeOpencodeModelId, + type OpencodeDiscoveredModel, + splitOpencodeModelLabel, +} from '../models'; +import { OpencodeChatRuntime } from '../runtime/OpencodeChatRuntime'; +import { + getOpencodeProviderSettings, + normalizeOpencodeVisibleModels, + OPENCODE_DEFAULT_ENVIRONMENT_VARIABLES, + updateOpencodeProviderSettings, +} from '../settings'; +import { OpencodeAgentSettings } from './OpencodeAgentSettings'; + +const OPENCODE_METADATA_WARMUP_DB = ':memory:'; + +export const opencodeSettingsTabRenderer: ProviderSettingsTabRenderer = { + render(container, context) { + const opencodeWorkspace = maybeGetOpencodeWorkspaceServices(); + const settingsBag = context.plugin.settings as unknown as Record; + const opencodeSettings = getOpencodeProviderSettings(settingsBag); + const hostnameKey = getHostnameKey(); + + new Setting(container).setName('Setup').setHeading(); + + new Setting(container) + .setName('Enable OpenCode') + .setDesc('Launch `opencode acp` as a provider.') + .addToggle((toggle) => + toggle + .setValue(opencodeSettings.enabled) + .onChange(async (value) => { + await context.plugin.mutateSettings((settings) => { + updateOpencodeProviderSettings(settings, { enabled: value }); + }); + context.refreshModelSelectors(); + }) + ); + + const cliPathSetting = new Setting(container) + .setName('CLI path') + .setDesc('Optional absolute path to the OpenCode CLI for this computer. Leave empty to use `opencode` from PATH.'); + + const validationEl = container.createDiv({ + cls: 'claudian-cli-path-validation claudian-setting-validation claudian-setting-validation-error claudian-hidden', + }); + const cliPathsByHost = { ...opencodeSettings.cliPathsByHost }; + const currentValue = opencodeSettings.cliPathsByHost[hostnameKey] || ''; + let cliPathInputEl: HTMLInputElement | null = null; + + const updateCliPathValidation = (value: string, inputEl?: HTMLInputElement): boolean => { + const error = validateCliPath(value); + if (error) { + validationEl.setText(error); + validationEl.toggleClass('claudian-hidden', false); + inputEl?.toggleClass('claudian-input-error', true); + return false; + } + + validationEl.toggleClass('claudian-hidden', true); + inputEl?.toggleClass('claudian-input-error', false); + return true; + }; + + const recycleOpencodeRuntime = async (): Promise => { + await context.plugin.recycleProviderRuntimes?.('opencode'); + }; + + const persistCliPath = async (value: string): Promise => { + if (!updateCliPathValidation(value, cliPathInputEl ?? undefined)) { + return false; + } + + const trimmed = value.trim(); + if (trimmed) { + cliPathsByHost[hostnameKey] = trimmed; + } else { + delete cliPathsByHost[hostnameKey]; + } + + await context.plugin.mutateSettings((settings) => { + updateOpencodeProviderSettings(settings, { cliPathsByHost: { ...cliPathsByHost } }); + clearOpencodeDiscoveryState(settings); + }); + opencodeWorkspace?.cliResolver?.reset(); + await recycleOpencodeRuntime(); + return true; + }; + + cliPathSetting.addText((text) => { + text + .setPlaceholder(process.platform === 'win32' + ? 'C:\\Users\\you\\AppData\\Roaming\\npm\\opencode.cmd' + : '/usr/local/bin/opencode') + .setValue(currentValue) + .onChange(async (value) => { + await persistCliPath(value); + }); + text.inputEl.addClass('claudian-settings-cli-path-input'); + cliPathInputEl = text.inputEl; + updateCliPathValidation(currentValue, text.inputEl); + }); + + new Setting(container).setName('Models').setHeading(); + renderOpencodeModelPicker(container, context, settingsBag); + + new Setting(container).setName('Commands and skills').setHeading(); + + const commandsDesc = container.createDiv({ cls: 'claudian-sp-settings-desc' }); + commandsDesc.createEl('p', { + cls: 'setting-item-description', + text: 'OpenCode can auto-detect vault-level Claude slash commands from .claude/commands/ and skills from .claude/skills/, .codex/skills/, and .agents/skills/. Manage those entries in the Claude or Codex settings tab. This setting only hides entries from the OpenCode dropdown.', + }); + + context.renderHiddenProviderCommandSetting(container, 'opencode', { + name: 'Hidden Commands and Skills', + desc: 'Hide specific OpenCode commands and skills from the dropdown. Enter names without the leading slash, one per line.', + placeholder: 'compact\nreview\nfix', + }); + + if (opencodeWorkspace?.agentStorage) { + new Setting(container).setName('Subagents').setHeading(); + + const subagentsDesc = container.createDiv({ cls: 'claudian-sp-settings-desc' }); + subagentsDesc.createEl('p', { + cls: 'setting-item-description', + text: 'Manage vault-level OpenCode subagents from .opencode/agent/ and legacy .opencode/agents/. New entries are saved as subagent-only files and appear in the @mention menu.', + }); + + const subagentsContainer = container.createDiv({ cls: 'claudian-slash-commands-container' }); + new OpencodeAgentSettings( + subagentsContainer, + opencodeWorkspace.agentStorage, + context.plugin.app, + async () => { + await opencodeWorkspace.refreshAgentMentions?.(); + await recycleOpencodeRuntime(); + }, + ); + } + + renderEnvironmentSettingsSection({ + container, + plugin: context.plugin, + scope: 'provider:opencode', + heading: 'Environment', + name: 'Environment Variables', + desc: 'Extra environment variables passed to OpenCode. `OPENCODE_ENABLE_EXA=1` is enabled by default.', + placeholder: `${OPENCODE_DEFAULT_ENVIRONMENT_VARIABLES}\nOPENCODE_DB=/path/to/opencode.db`, + renderCustomContextLimits: (target) => context.renderCustomContextLimits(target, 'opencode'), + }); + }, +}; + +function renderOpencodeModelPicker( + container: HTMLElement, + context: ProviderSettingsTabRendererContext, + settingsBag: Record, +): void { + const getState = (): ProviderModelPickerState => { + const current = getOpencodeProviderSettings(settingsBag); + return { + aliases: current.modelAliases, + discoveredCount: current.discoveredModels.length, + models: buildOpencodePickerModels(current.discoveredModels, current.visibleModels), + selectedIds: current.visibleModels, + }; + }; + + const warmModelMetadata = async (rawId: string): Promise => { + const runtime = new OpencodeChatRuntime(context.plugin); + try { + runtime.syncConversationState({ + providerState: { databasePath: OPENCODE_METADATA_WARMUP_DB }, + sessionId: null, + }); + if (await runtime.warmModelMetadata(encodeOpencodeModelId(rawId))) { + context.refreshModelSelectors(); + } + } catch { + // Metadata warmup is opportunistic; the first chat turn can still discover it. + } finally { + runtime.cleanup(); + } + }; + + renderProviderModelPicker({ + container, + emptyCatalogText: 'Start OpenCode once to load its model catalog. Claudian will then let you pick visible models.', + failedCatalogText: 'Could not load the OpenCode model catalog. Check the CLI path and login state, then try again.', + getState, + async loadCatalog() { + const runtime = new OpencodeChatRuntime(context.plugin); + try { + runtime.syncConversationState({ + providerState: { databasePath: OPENCODE_METADATA_WARMUP_DB }, + sessionId: null, + }); + const loaded = await runtime.ensureReady({ allowSessionCreation: true }); + const discoveredCount = getOpencodeProviderSettings(settingsBag).discoveredModels.length; + if (!loaded) { + return 'failed'; + } + if (discoveredCount > 0) { + context.refreshModelSelectors(); + return 'loaded'; + } + return 'empty'; + } catch { + return 'failed'; + } finally { + runtime.cleanup(); + } + }, + loadCatalogOnRender: true, + loadingCatalogText: 'Loading OpenCode model catalog...', + modifier: 'opencode', + async onAliasesChange(modelAliases) { + await context.plugin.mutateSettings((settings) => { + updateOpencodeProviderSettings(settings, { modelAliases }); + }); + context.refreshModelSelectors(); + }, + onModelSelected: async (model) => warmModelMetadata(model.id), + async onSelectedIdsChange(visibleModels) { + const current = getOpencodeProviderSettings(settingsBag); + const normalized = normalizeOpencodeVisibleModels(visibleModels, current.discoveredModels); + if (sameStringList(current.visibleModels, normalized)) { + return; + } + + await context.plugin.mutateSettings((settings) => { + updateOpencodeProviderSettings(settings, { visibleModels: normalized }); + }); + context.refreshModelSelectors(); + }, + providerName: 'OpenCode', + settingDescription: 'Choose which OpenCode models appear in the chat selector. Filter by provider or type to search. The current session model stays pinned even if it is not selected here.', + }); +} + +function validateCliPath(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const expandedPath = expandHomePath(trimmed); + if (!fs.existsSync(expandedPath)) { + return 'Path does not exist'; + } + if (!fs.statSync(expandedPath).isFile()) { + return 'Path must point to a file'; + } + return null; +} + +function buildOpencodePickerModels( + discoveredModels: OpencodeDiscoveredModel[], + visibleModels: string[], +): ProviderModelPickerModel[] { + const models: ProviderModelPickerModel[] = []; + const discoveredIds = new Set(); + + for (const model of buildOpencodeBaseModels(discoveredModels)) { + const { modelLabel, providerLabel } = splitOpencodeModelLabel(model.label || model.rawId); + discoveredIds.add(model.rawId); + models.push({ + description: model.description ?? '', + id: model.rawId, + isAvailable: true, + name: modelLabel, + providerKey: providerLabel.toLowerCase(), + providerLabel, + }); + } + + for (const rawId of visibleModels) { + if (discoveredIds.has(rawId)) { + continue; + } + + const { modelLabel, providerLabel } = splitOpencodeModelLabel(rawId); + models.push({ + id: rawId, + isAvailable: false, + name: modelLabel, + providerKey: providerLabel.toLowerCase(), + providerLabel, + unavailableMessage: 'Not currently reported by OpenCode', + }); + } + + return models.sort((left, right) => { + const providerCmp = (left.providerLabel ?? '').localeCompare(right.providerLabel ?? ''); + if (providerCmp !== 0) { + return providerCmp; + } + return left.name.localeCompare(right.name); + }); +} diff --git a/src/providers/pi/AGENTS.md b/src/providers/pi/AGENTS.md new file mode 100644 index 0000000..9d7021c --- /dev/null +++ b/src/providers/pi/AGENTS.md @@ -0,0 +1,37 @@ +# Pi Provider + +`src/providers/pi/` adapts Pi through a `pi --mode rpc` subprocess. + +## Ownership + +- RPC process management, prompt encoding, event normalization, JSONL history hydration, model discovery, command discovery, extension UI bridging, settings UI, and Pi-specific settings reconciliation live here. +- Shared code should consume Pi behavior through `ChatRuntime`, provider capabilities, and workspace-service contracts. + +## Protocol Rules + +- Launch arguments are built in `PiLaunchSpec.ts`. Keep command-line shape there instead of scattering flags across runtime code. +- Live events are normalized through `normalizePiRpcEvent()` and `PiEventNormalizationState`. +- Extension UI requests are routed through `PiExtensionUiBridge` and rendered by `ObsidianPiExtensionUiRenderer`. +- Compact turns call the `compact` RPC request and emit a `context_compacted` stream chunk. + +## Session and History Rules + +- `PiProviderState` may store `sessionId`, `sessionFile`, `leafEntryId`, `parentSession`, and fork metadata. Do not infer these fields in feature code. +- Pi can resume by session ID or absolute session file. Absolute session files can be switched in a live process; other target changes require process restart. +- History hydration reads Pi JSONL sessions from vault-local and user-level session roots. Never mutate native history during hydration. +- Forking creates a new Pi session file by copying the source branch up to `resumeAt`. Keep fork materialization provider-owned. +- Environment keys that affect Pi data or package locations invalidate existing Pi sessions. + +## Commands and Models + +- Runtime commands come from the `get_commands` RPC and are exposed through `PiCommandCatalog`. +- Pi runtime commands are not editable or deletable from Claudian. +- Model discovery uses a separate subprocess and may receive extension UI requests. Keep model normalization in `models.ts`. +- Use model-provided context windows when available; otherwise preserve the existing fallback behavior. + +## Gotchas + +- `PiAuxQueryRunner` owns its own process and is independent from the chat runtime. +- Images are passed as prompt image blocks only when attachment data is available. +- `new_session` invalidates persisted session state until the provider reports a replacement session. +- Tool mode can launch Pi with readonly tools or no tools. Keep that logic in launch-spec construction. diff --git a/src/providers/pi/CLAUDE.md b/src/providers/pi/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/src/providers/pi/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/providers/pi/app/PiRuntimeCommandLoader.ts b/src/providers/pi/app/PiRuntimeCommandLoader.ts new file mode 100644 index 0000000..79492e4 --- /dev/null +++ b/src/providers/pi/app/PiRuntimeCommandLoader.ts @@ -0,0 +1,57 @@ +import type { + ProviderRuntimeCommandLoader, + ProviderRuntimeCommandLoaderContext, +} from '../../../core/providers/types'; +import { PiChatRuntime } from '../runtime/PiChatRuntime'; +import { getPiProviderSettings } from '../settings'; +import { getPiState } from '../types'; + +export class PiRuntimeCommandLoader implements ProviderRuntimeCommandLoader { + isAvailable(settings: Record): boolean { + return getPiProviderSettings(settings).enabled; + } + + async loadCommands(context: ProviderRuntimeCommandLoaderContext) { + const persistedState = getPiState(context.conversation?.providerState); + const hasPersistedSession = Boolean( + context.conversation?.sessionId + || persistedState.sessionId + || persistedState.sessionFile, + ); + const shouldWarmBlankSession = context.allowSessionCreation === true + && !context.conversation; + const shouldWarmPreSessionConversation = context.allowSessionCreation === true + && !!context.conversation + && !hasPersistedSession + && context.conversation.messages.length > 0; + + if (!hasPersistedSession && !shouldWarmBlankSession && !shouldWarmPreSessionConversation) { + return []; + } + + const canReuseRuntime = context.runtime?.providerId === 'pi' + && context.runtime.isReady(); + const runtime = canReuseRuntime + ? context.runtime! + : new PiChatRuntime(context.plugin); + + try { + if (canReuseRuntime && context.conversation) { + runtime.syncConversationState(context.conversation, context.externalContextPaths); + } + + const ready = await runtime.ensureReady({ + allowSessionCreation: false, + }); + if (!ready) { + return []; + } + + return await runtime.getSupportedCommands(); + } finally { + if (runtime !== context.runtime) { + runtime.cleanup(); + } + } + } +} diff --git a/src/providers/pi/app/PiWorkspaceServices.ts b/src/providers/pi/app/PiWorkspaceServices.ts new file mode 100644 index 0000000..2513764 --- /dev/null +++ b/src/providers/pi/app/PiWorkspaceServices.ts @@ -0,0 +1,39 @@ +import type { ProviderCommandCatalog } from '../../../core/providers/commands/ProviderCommandCatalog'; +import { ProviderWorkspaceRegistry } from '../../../core/providers/ProviderWorkspaceRegistry'; +import type { + ProviderTabWarmupPolicy, + ProviderWorkspaceRegistration, + ProviderWorkspaceServices, +} from '../../../core/providers/types'; +import { PiCommandCatalog } from '../commands/PiCommandCatalog'; +import { PiCliResolver } from '../runtime/PiCliResolver'; +import { piSettingsTabRenderer } from '../ui/PiSettingsTab'; +import { PiRuntimeCommandLoader } from './PiRuntimeCommandLoader'; + +export interface PiWorkspaceServices extends ProviderWorkspaceServices { + commandCatalog: ProviderCommandCatalog; +} + +const piTabWarmupPolicy: ProviderTabWarmupPolicy = { + resolveMode() { + return 'commands'; + }, +}; + +export async function createPiWorkspaceServices(): Promise { + return { + cliResolver: new PiCliResolver(), + commandCatalog: new PiCommandCatalog(), + runtimeCommandLoader: new PiRuntimeCommandLoader(), + settingsTabRenderer: piSettingsTabRenderer, + tabWarmupPolicy: piTabWarmupPolicy, + }; +} + +export const piWorkspaceRegistration: ProviderWorkspaceRegistration = { + initialize: async () => createPiWorkspaceServices(), +}; + +export function maybeGetPiWorkspaceServices(): PiWorkspaceServices | null { + return ProviderWorkspaceRegistry.getServices('pi') as PiWorkspaceServices | null; +} diff --git a/src/providers/pi/auxiliary/PiInlineEditService.ts b/src/providers/pi/auxiliary/PiInlineEditService.ts new file mode 100644 index 0000000..1e7c08c --- /dev/null +++ b/src/providers/pi/auxiliary/PiInlineEditService.ts @@ -0,0 +1,9 @@ +import { QueryBackedInlineEditService } from '../../../core/auxiliary/QueryBackedInlineEditService'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { PiAuxQueryRunner } from '../runtime/PiAuxQueryRunner'; + +export class PiInlineEditService extends QueryBackedInlineEditService { + constructor(plugin: ProviderHost) { + super(new PiAuxQueryRunner(plugin, { profile: 'readonly' })); + } +} diff --git a/src/providers/pi/auxiliary/PiInstructionRefineService.ts b/src/providers/pi/auxiliary/PiInstructionRefineService.ts new file mode 100644 index 0000000..54b1955 --- /dev/null +++ b/src/providers/pi/auxiliary/PiInstructionRefineService.ts @@ -0,0 +1,9 @@ +import { QueryBackedInstructionRefineService } from '../../../core/auxiliary/QueryBackedInstructionRefineService'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { PiAuxQueryRunner } from '../runtime/PiAuxQueryRunner'; + +export class PiInstructionRefineService extends QueryBackedInstructionRefineService { + constructor(plugin: ProviderHost) { + super(new PiAuxQueryRunner(plugin, { profile: 'passive' })); + } +} diff --git a/src/providers/pi/auxiliary/PiTaskResultInterpreter.ts b/src/providers/pi/auxiliary/PiTaskResultInterpreter.ts new file mode 100644 index 0000000..0d50061 --- /dev/null +++ b/src/providers/pi/auxiliary/PiTaskResultInterpreter.ts @@ -0,0 +1,29 @@ +import type { + ProviderTaskResultInterpreter, + ProviderTaskTerminalStatus, +} from '../../../core/providers/types'; + +export class PiTaskResultInterpreter implements ProviderTaskResultInterpreter { + hasAsyncLaunchMarker(_toolUseResult: unknown): boolean { + return false; + } + + extractAgentId(_toolUseResult: unknown): string | null { + return null; + } + + extractStructuredResult(_toolUseResult: unknown): string | null { + return null; + } + + resolveTerminalStatus( + _toolUseResult: unknown, + fallbackStatus: ProviderTaskTerminalStatus, + ): ProviderTaskTerminalStatus { + return fallbackStatus; + } + + extractTagValue(_payload: string, _tagName: string): string | null { + return null; + } +} diff --git a/src/providers/pi/auxiliary/PiTitleGenerationService.ts b/src/providers/pi/auxiliary/PiTitleGenerationService.ts new file mode 100644 index 0000000..7a2ca02 --- /dev/null +++ b/src/providers/pi/auxiliary/PiTitleGenerationService.ts @@ -0,0 +1,19 @@ +import { QueryBackedTitleGenerationService } from '../../../core/auxiliary/QueryBackedTitleGenerationService'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { PiAuxQueryRunner } from '../runtime/PiAuxQueryRunner'; +import { piChatUIConfig } from '../ui/PiChatUIConfig'; + +export class PiTitleGenerationService extends QueryBackedTitleGenerationService { + constructor(plugin: ProviderHost) { + super({ + createRunner: () => new PiAuxQueryRunner(plugin, { profile: 'passive' }), + resolveModel: () => { + const settings = plugin.settings as unknown as Record; + const titleModel = typeof settings.titleGenerationModel === 'string' + ? settings.titleGenerationModel + : ''; + return piChatUIConfig.ownsModel(titleModel, settings) ? titleModel : undefined; + }, + }); + } +} diff --git a/src/providers/pi/capabilities.ts b/src/providers/pi/capabilities.ts new file mode 100644 index 0000000..ec7c342 --- /dev/null +++ b/src/providers/pi/capabilities.ts @@ -0,0 +1,16 @@ +import type { ProviderCapabilities } from '../../core/providers/types'; + +export const PI_PROVIDER_CAPABILITIES: Readonly = Object.freeze({ + providerId: 'pi', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: false, + supportsRewind: false, + supportsFork: true, + supportsProviderCommands: true, + supportsImageAttachments: true, + supportsInstructionMode: true, + supportsMcpTools: false, + supportsTurnSteer: true, + reasoningControl: 'effort', +}); diff --git a/src/providers/pi/commands/PiCommandCatalog.ts b/src/providers/pi/commands/PiCommandCatalog.ts new file mode 100644 index 0000000..748cb70 --- /dev/null +++ b/src/providers/pi/commands/PiCommandCatalog.ts @@ -0,0 +1,92 @@ +import type { + ProviderCommandCatalog, + ProviderCommandDropdownConfig, +} from '../../../core/providers/commands/ProviderCommandCatalog'; +import type { ProviderCommandEntry } from '../../../core/providers/commands/ProviderCommandEntry'; +import type { SlashCommand } from '../../../core/types'; + +function slashCommandToEntry(command: SlashCommand): ProviderCommandEntry { + return { + agent: command.agent, + allowedTools: command.allowedTools, + argumentHint: command.argumentHint, + content: command.content, + context: command.context, + description: command.description, + disableModelInvocation: command.disableModelInvocation, + displayPrefix: '/', + hooks: command.hooks, + id: command.id, + insertPrefix: '/', + isDeletable: false, + isEditable: false, + kind: command.kind ?? 'command', + model: command.model, + name: command.name, + providerId: 'pi', + scope: 'runtime', + source: command.source ?? 'sdk', + userInvocable: command.userInvocable, + }; +} + +function dedupeRuntimeCommands(commands: SlashCommand[]): SlashCommand[] { + const deduped: SlashCommand[] = []; + const seen = new Set(); + + for (const command of commands) { + const normalizedName = command.name.trim().replace(/^\/+/, ''); + if (!normalizedName) { + continue; + } + + const key = normalizedName.toLowerCase(); + if (seen.has(key)) { + continue; + } + + seen.add(key); + deduped.push({ + ...command, + name: normalizedName, + }); + } + + return deduped; +} + +export class PiCommandCatalog implements ProviderCommandCatalog { + private runtimeCommands: SlashCommand[] = []; + + setRuntimeCommands(commands: SlashCommand[]): void { + this.runtimeCommands = dedupeRuntimeCommands(commands); + } + + async listDropdownEntries(_context: { includeBuiltIns: boolean }): Promise { + return this.runtimeCommands.map(slashCommandToEntry); + } + + async listVaultEntries(): Promise { + return []; + } + + async saveVaultEntry(_entry: ProviderCommandEntry): Promise { + throw new Error('Pi runtime commands are not editable from Claudian.'); + } + + async deleteVaultEntry(_entry: ProviderCommandEntry): Promise { + throw new Error('Pi runtime commands are not deletable from Claudian.'); + } + + getDropdownConfig(): ProviderCommandDropdownConfig { + return { + builtInPrefix: '/', + commandPrefix: '/', + providerId: 'pi', + skillPrefix: '/', + triggerChars: ['/'], + }; + } + + async refresh(): Promise {} +} diff --git a/src/providers/pi/env/PiSettingsReconciler.ts b/src/providers/pi/env/PiSettingsReconciler.ts new file mode 100644 index 0000000..c7d4120 --- /dev/null +++ b/src/providers/pi/env/PiSettingsReconciler.ts @@ -0,0 +1,180 @@ +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import type { ProviderSettingsReconciler } from '../../../core/providers/types'; +import type { Conversation } from '../../../core/types'; +import { parseEnvironmentVariables } from '../../../utils/env'; +import { sameStringList } from '../internal/compareCollections'; +import { + clampPiThinkingLevel, + decodePiModelId, + encodePiModelId, + findPiModel, + isPiModelSelectionId, + PI_DEFAULT_THINKING_LEVEL, +} from '../models'; +import { + getPiProviderSettings, + normalizePiVisibleModels, + updatePiProviderSettings, +} from '../settings'; +import { getPiState } from '../types'; + +const PI_ENV_HASH_KEYS = [ + 'PI_CODING_AGENT_DIR', + 'PI_CODING_AGENT_SESSION_DIR', + 'PI_PACKAGE_DIR', + 'PI_OFFLINE', + 'PI_SKIP_VERSION_CHECK', + 'PI_TELEMETRY', + 'PI_CACHE_RETENTION', +] as const; + +function computePiEnvHash(envText: string): string { + const envVars = parseEnvironmentVariables(envText || ''); + return PI_ENV_HASH_KEYS + .filter((key) => envVars[key]) + .map((key) => `${key}=${envVars[key]}`) + .sort() + .join('|'); +} + +export const piSettingsReconciler: ProviderSettingsReconciler = { + handleEnvironmentChange(settings: Record): boolean { + const current = getPiProviderSettings(settings); + if (current.discoveredModels.length === 0) { + return false; + } + updatePiProviderSettings(settings, { + discoveredModels: [], + }); + return true; + }, + + reconcileModelWithEnvironment( + settings: Record, + conversations: Conversation[], + ): { changed: boolean; invalidatedConversations: Conversation[] } { + const envText = getRuntimeEnvironmentText(settings, 'pi'); + const currentHash = computePiEnvHash(envText); + const savedHash = getPiProviderSettings(settings).environmentHash; + + if (currentHash === savedHash) { + return { changed: false, invalidatedConversations: [] }; + } + + const invalidatedConversations: Conversation[] = []; + for (const conversation of conversations) { + if (conversation.providerId !== 'pi') { + continue; + } + + const state = getPiState(conversation.providerState); + if (!conversation.sessionId && !state.sessionId && !state.sessionFile) { + continue; + } + + conversation.sessionId = null; + conversation.providerState = undefined; + invalidatedConversations.push(conversation); + } + + updatePiProviderSettings(settings, { environmentHash: currentHash }); + return { changed: true, invalidatedConversations }; + }, + + normalizeModelVariantSettings(settings: Record): boolean { + const piSettings = getPiProviderSettings(settings); + let changed = false; + + const normalizeSelection = ( + value: unknown, + fallback: 'clear' | 'synthetic', + ): string | null => { + if (typeof value !== 'string' || !isPiModelSelectionId(value)) { + return null; + } + + if (value === 'pi') { + return value; + } + + const decoded = decodePiModelId(value); + if (decoded) { + return encodePiModelId(decoded.provider, decoded.modelId); + } + + return fallback === 'synthetic' ? 'pi' : ''; + }; + + const modelSelection = normalizeSelection(settings.model, 'synthetic'); + if (typeof settings.model === 'string' && modelSelection && settings.model !== modelSelection) { + settings.model = modelSelection; + changed = true; + } + + const titleModelSelection = normalizeSelection(settings.titleGenerationModel, 'clear'); + if ( + typeof settings.titleGenerationModel === 'string' + && titleModelSelection !== null + && settings.titleGenerationModel !== titleModelSelection + ) { + settings.titleGenerationModel = titleModelSelection; + changed = true; + } + + const savedProviderModelRaw = settings.savedProviderModel; + if (savedProviderModelRaw && typeof savedProviderModelRaw === 'object' && !Array.isArray(savedProviderModelRaw)) { + const savedProviderModel = savedProviderModelRaw as Record; + const savedSelection = normalizeSelection(savedProviderModel.pi, 'clear'); + if ( + typeof savedProviderModel.pi === 'string' + && savedSelection !== null + && savedProviderModel.pi !== savedSelection + ) { + if (savedSelection) { + savedProviderModel.pi = savedSelection; + } else { + delete savedProviderModel.pi; + } + changed = true; + } + } + + const normalizedVisibleModels = normalizePiVisibleModels( + piSettings.visibleModels, + piSettings.discoveredModels, + ); + const shouldUpdateProviderSettings = !sameStringList(normalizedVisibleModels, piSettings.visibleModels); + if (shouldUpdateProviderSettings) { + updatePiProviderSettings(settings, { + visibleModels: normalizedVisibleModels, + }); + changed = true; + } + + if (typeof settings.effortLevel === 'string' && !settings.effortLevel.trim()) { + settings.effortLevel = getDefaultPiEffortForSelection(settings.model, piSettings); + changed = true; + } + + return changed; + }, +}; + +function getDefaultPiEffortForSelection( + selection: unknown, + piSettings: ReturnType, +): string { + if (typeof selection !== 'string') { + return 'off'; + } + + const decoded = decodePiModelId(selection); + if (!decoded) { + return 'off'; + } + + const model = findPiModel(piSettings, encodePiModelId(decoded.provider, decoded.modelId)); + return model + ? clampPiThinkingLevel(PI_DEFAULT_THINKING_LEVEL, model.thinkingLevels) + : PI_DEFAULT_THINKING_LEVEL; +} diff --git a/src/providers/pi/history/PiConversationHistoryService.ts b/src/providers/pi/history/PiConversationHistoryService.ts new file mode 100644 index 0000000..bcf8d9a --- /dev/null +++ b/src/providers/pi/history/PiConversationHistoryService.ts @@ -0,0 +1,167 @@ +import * as fs from 'node:fs/promises'; + +import type { + ProviderConversationHistoryService, + ProviderHistoryPathContext, +} from '../../../core/providers/types'; +import type { Conversation } from '../../../core/types'; +import { buildPersistedPiState, getPiState } from '../types'; +import { resolvePiSessionFileHint } from './PiHistoryPathResolver'; +import { parsePiSessionContent } from './PiHistoryStore'; + +export class PiConversationHistoryService implements ProviderConversationHistoryService { + private hydratedKeys = new Map(); + + async hydrateConversationHistory( + conversation: Conversation, + vaultPath: string | null, + pathContext?: ProviderHistoryPathContext, + ): Promise { + const state = getPiState(conversation.providerState); + if (this.isPendingForkConversation(conversation)) { + const sourceSessionFile = resolvePiSessionFileHint( + state.forkSourceSessionFile, + state.forkSource!.sessionId, + vaultPath, + pathContext, + ); + this.replaceResolvedPath( + conversation, + 'forkSourceSessionFile', + state.forkSourceSessionFile, + sourceSessionFile, + ); + if (conversation.messages.length > 0) { + return; + } + if (!sourceSessionFile) { + this.hydratedKeys.delete(conversation.id); + return; + } + + try { + const content = await fs.readFile(sourceSessionFile, 'utf-8'); + const messages = parsePiSessionContent(content, { + leafEntryId: state.forkSource!.resumeAt, + requireLeafEntryId: true, + }); + if (messages.length === 0) { + this.hydratedKeys.delete(conversation.id); + return; + } + + conversation.messages = messages; + this.hydratedKeys.set(conversation.id, `fork::${sourceSessionFile}::${state.forkSource!.resumeAt}`); + } catch { + this.hydratedKeys.delete(conversation.id); + } + return; + } + + const sessionTarget = state.sessionId ?? conversation.sessionId; + if (!state.sessionFile && !sessionTarget) { + this.hydratedKeys.delete(conversation.id); + return; + } + + const sessionFile = resolvePiSessionFileHint( + state.sessionFile, + sessionTarget, + vaultPath, + pathContext, + ); + this.replaceResolvedPath( + conversation, + 'sessionFile', + state.sessionFile, + sessionFile, + ); + if (!sessionFile) { + this.hydratedKeys.delete(conversation.id); + return; + } + + const hydrationKey = `${sessionFile}::${state.leafEntryId ?? ''}`; + if ( + conversation.messages.length > 0 + && this.hydratedKeys.get(conversation.id) === hydrationKey + ) { + return; + } + + try { + const content = await fs.readFile(sessionFile, 'utf-8'); + const messages = parsePiSessionContent(content, { + leafEntryId: state.leafEntryId, + }); + if (messages.length === 0) { + this.hydratedKeys.delete(conversation.id); + return; + } + + conversation.messages = messages; + this.hydratedKeys.set(conversation.id, hydrationKey); + } catch { + this.hydratedKeys.delete(conversation.id); + } + } + + async deleteConversationSession( + _conversation: Conversation, + _vaultPath: string | null, + ): Promise { + // Never mutate Pi native history. + } + + resolveSessionIdForConversation(conversation: Conversation | null): string | null { + const state = getPiState(conversation?.providerState); + return state.sessionFile + ?? state.sessionId + ?? conversation?.sessionId + ?? state.forkSource?.sessionId + ?? null; + } + + isPendingForkConversation(_conversation: Conversation): boolean { + const state = getPiState(_conversation.providerState); + return !!state.forkSource && !state.sessionId && !state.sessionFile && !_conversation.sessionId; + } + + buildForkProviderState( + sourceSessionId: string, + resumeAt: string, + sourceProviderState?: Record, + ): Record { + const sourceState = getPiState(sourceProviderState); + const sourceSessionFile = sourceState.sessionFile ?? sourceState.forkSourceSessionFile; + return buildPersistedPiState({ + forkSource: { sessionId: sourceSessionId, resumeAt }, + ...(sourceSessionFile ? { forkSourceSessionFile: sourceSessionFile } : {}), + }) as Record; + } + + buildPersistedProviderState( + conversation: Conversation, + ): Record | undefined { + return buildPersistedPiState(getPiState(conversation.providerState)) as Record | undefined; + } + + private replaceResolvedPath( + conversation: Conversation, + field: 'forkSourceSessionFile' | 'sessionFile', + persistedPath: string | undefined, + resolvedPath: string | null, + ): void { + if (!persistedPath || persistedPath === resolvedPath) { + return; + } + + const nextState = { ...getPiState(conversation.providerState) }; + if (resolvedPath) { + nextState[field] = resolvedPath; + } else { + delete nextState[field]; + } + conversation.providerState = buildPersistedPiState(nextState) as Record | undefined; + } +} diff --git a/src/providers/pi/history/PiHistoryPathResolver.ts b/src/providers/pi/history/PiHistoryPathResolver.ts new file mode 100644 index 0000000..8f40070 --- /dev/null +++ b/src/providers/pi/history/PiHistoryPathResolver.ts @@ -0,0 +1,73 @@ +import * as os from 'node:os'; +import * as path from 'node:path'; + +import type { ProviderHistoryPathContext } from '../../../core/providers/types'; +import { isPathWithinRoot } from '../../../core/storage/pathContainment'; +import { findPiSessionFile, findPiSessionFileInRoot } from './PiHistoryStore'; + +function getConfiguredSessionDir(context: ProviderHistoryPathContext): string | null { + const configured = context.environment.PI_CODING_AGENT_SESSION_DIR?.trim(); + return configured && path.isAbsolute(configured) ? configured : null; +} + +function getTrustedRoots( + vaultPath: string | null, + context: ProviderHistoryPathContext, +): string[] { + const roots: string[] = []; + const configuredSessionDir = getConfiguredSessionDir(context); + if (configuredSessionDir) { + roots.push(configuredSessionDir); + } + + const configuredAgentDir = context.environment.PI_CODING_AGENT_DIR?.trim(); + if (configuredAgentDir && path.isAbsolute(configuredAgentDir)) { + roots.push(path.join(configuredAgentDir, 'sessions')); + } + if (vaultPath) { + const vaultSessionRoot = path.join(vaultPath, '.pi', 'agent', 'sessions'); + if (isPathWithinRoot(vaultSessionRoot, vaultPath)) { + roots.push(vaultSessionRoot); + } + } + const home = context.environment.HOME?.trim() + || context.environment.USERPROFILE?.trim() + || os.homedir(); + roots.push(path.join(home, '.pi', 'agent', 'sessions')); + return [...new Set(roots)]; +} + +function isLogicalSessionId(value: string | null | undefined): value is string { + return typeof value === 'string' + && value.trim().length > 0 + && !path.isAbsolute(value) + && !/[\\/]/.test(value); +} + +export function resolvePiSessionFileHint( + persistedPath: string | null | undefined, + logicalSessionId: string | null | undefined, + vaultPath: string | null, + context?: ProviderHistoryPathContext, +): string | null { + if (!context) { + const target = persistedPath ?? logicalSessionId; + return target ? findPiSessionFile(target, vaultPath) : null; + } + + const roots = getTrustedRoots(vaultPath, context); + if (persistedPath && roots.some(root => isPathWithinRoot(persistedPath, root))) { + return persistedPath; + } + if (!isLogicalSessionId(logicalSessionId)) { + return null; + } + + for (const root of roots) { + const resolved = findPiSessionFileInRoot(logicalSessionId, root); + if (resolved && isPathWithinRoot(resolved, root)) { + return resolved; + } + } + return null; +} diff --git a/src/providers/pi/history/PiHistoryStore.ts b/src/providers/pi/history/PiHistoryStore.ts new file mode 100644 index 0000000..50f06e7 --- /dev/null +++ b/src/providers/pi/history/PiHistoryStore.ts @@ -0,0 +1,718 @@ +import { randomUUID } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as fsp from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { isWriteEditTool } from '../../../core/tools/toolNames'; +import type { ChatMessage, ContentBlock, ImageAttachment, ToolCallInfo } from '../../../core/types'; +import { extractDiffData } from '../../../utils/diff'; +import { buildImageAttachmentFromBase64 } from '../../../utils/imageAttachment'; +import { + extractPiToolTextContent, + normalizePiToolInput, + normalizePiToolName, +} from '../normalizations/piToolNormalization'; + +export interface PiSessionEntry { + id?: string; + message?: Record; + parentId?: string; + raw: Record; + type: string; +} + +export interface ParsedPiSessionEntries { + entries: PiSessionEntry[]; + header: Record | null; +} + +export interface ParsePiSessionContentOptions { + leafEntryId?: string; + requireLeafEntryId?: boolean; +} + +export interface CreatePiForkSessionFileOptions { + now?: Date; + sessionDir?: string; + sessionId?: string; + targetCwd?: string; +} + +export interface CreatedPiForkSessionFile { + leafEntryId: string; + parentSession: string; + sessionFile: string; + sessionId: string; +} + +export function parsePiSessionContent( + content: string, + options: ParsePiSessionContentOptions = {}, +): ChatMessage[] { + const parsed = parsePiSessionEntries(content); + const leafEntryId = options.leafEntryId?.trim(); + if ( + options.requireLeafEntryId + && (!leafEntryId || !parsed.entries.some(entry => entry.id === leafEntryId)) + ) { + return []; + } + + return mapPiSessionEntries(resolvePiActivePath( + parsed.entries, + leafEntryId, + )); +} + +export function parsePiSessionEntries(content: string): ParsedPiSessionEntries { + const entries: PiSessionEntry[] = []; + let header: Record | null = null; + + for (const line of content.split(/\r?\n/)) { + if (!line.trim()) { + continue; + } + + let record: Record; + try { + const parsed = JSON.parse(line) as unknown; + if (!isPlainObject(parsed)) { + continue; + } + record = parsed; + } catch { + continue; + } + + const type = getString(record.type) ?? getString(record.kind) ?? ''; + if (type === 'session') { + header = record; + continue; + } + + const message = getRecord(record.message) ?? getRecord(record.data) ?? inferMessageRecord(record); + entries.push({ + ...(getString(record.id) ? { id: getString(record.id)! } : {}), + ...(message ? { message } : {}), + ...(getString(record.parentId) ?? getString(record.parent_id) + ? { parentId: (getString(record.parentId) ?? getString(record.parent_id))! } + : {}), + raw: record, + type, + }); + } + + return { entries, header }; +} + +export function resolvePiActivePath(entries: PiSessionEntry[], leafId?: string): PiSessionEntry[] { + const entriesWithIds = entries.filter((entry): entry is PiSessionEntry & { id: string } => !!entry.id); + if (entriesWithIds.length === 0) { + return entries; + } + + const hasBranchGraph = hasPiBranchGraph(entriesWithIds); + if (!leafId && !hasBranchGraph) { + return entries; + } + + const byId = new Map(entriesWithIds.map(entry => [entry.id, entry] as const)); + const targetLeafId = leafId && byId.has(leafId) + ? leafId + : entriesWithIds[entriesWithIds.length - 1]?.id; + if (!targetLeafId) { + return entries; + } + + const activePath = hasBranchGraph + ? resolvePiGraphEntryPath(byId, targetLeafId) + : resolvePiLinearEntryPath(entries, targetLeafId); + if (activePath.length === 0) { + return entries; + } + + return hasBranchGraph + ? includePiGraphPathEntries(entries, activePath) + : includePiLinearPathEntries(entries, activePath); +} + +export function resolvePiEntryPath(entries: PiSessionEntry[], leafId: string): PiSessionEntry[] { + const entriesWithIds = entries.filter((entry): entry is PiSessionEntry & { id: string } => !!entry.id); + const byId = new Map(entriesWithIds.map(entry => [entry.id, entry] as const)); + if (!byId.has(leafId)) { + return []; + } + + const hasBranchGraph = hasPiBranchGraph(entriesWithIds); + const activePath = hasBranchGraph + ? resolvePiGraphEntryPath(byId, leafId) + : resolvePiLinearEntryPath(entries, leafId); + if (activePath.length === 0) { + return []; + } + + return hasBranchGraph + ? includePiGraphPathEntries(entries, activePath) + : includePiLinearPathEntries(entries, activePath); +} + +function hasPiBranchGraph(entriesWithIds: PiSessionEntry[]): boolean { + return entriesWithIds.some(entry => !!entry.parentId && !isToolResultEntry(entry)); +} + +function resolvePiGraphEntryPath( + byId: Map, + targetLeafId: string, +): PiSessionEntry[] { + const activePath: PiSessionEntry[] = []; + const seen = new Set(); + let current: PiSessionEntry | undefined = byId.get(targetLeafId); + while (current) { + if (!current.id || seen.has(current.id)) { + break; + } + seen.add(current.id); + activePath.push(current); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + + return activePath; +} + +function resolvePiLinearEntryPath(entries: PiSessionEntry[], targetLeafId: string): PiSessionEntry[] { + const leafIndex = entries.findIndex(entry => entry.id === targetLeafId); + if (leafIndex < 0) { + return []; + } + return entries.slice(0, leafIndex + 1); +} + +function includePiGraphPathEntries( + entries: PiSessionEntry[], + activePath: PiSessionEntry[], +): PiSessionEntry[] { + const activeIds = new Set(activePath.map(entry => entry.id).filter((id): id is string => !!id)); + const activeToolCallIds = collectToolCallIds(activePath); + return entries.filter((entry) => { + if (isToolResultEntry(entry)) { + const toolCallId = getToolResultCallId(entry); + return (!!toolCallId && activeToolCallIds.has(toolCallId)) + || (!!entry.id && activeIds.has(entry.id)) + || (!!entry.parentId && activeIds.has(entry.parentId)); + } + if (entry.id) { + return activeIds.has(entry.id); + } + if (entry.parentId && activeIds.has(entry.parentId)) { + return true; + } + return false; + }); +} + +function includePiLinearPathEntries( + entries: PiSessionEntry[], + activePath: PiSessionEntry[], +): PiSessionEntry[] { + const activeEntries = new Set(activePath); + const activeToolCallIds = collectToolCallIds(activePath); + return entries.filter((entry) => { + if (activeEntries.has(entry)) { + return true; + } + if (!isToolResultEntry(entry)) { + return false; + } + const toolCallId = getToolResultCallId(entry); + return !!toolCallId && activeToolCallIds.has(toolCallId); + }); +} + +export async function createPiForkSessionFile( + sourceSessionFile: string, + resumeAt: string, + options: CreatePiForkSessionFileOptions = {}, +): Promise { + const sourceContent = await fsp.readFile(sourceSessionFile, 'utf-8'); + const parsed = parsePiSessionEntries(sourceContent); + const branchEntries = resolvePiEntryPath(parsed.entries, resumeAt); + if (branchEntries.length === 0) { + throw new Error(`Pi fork checkpoint not found: ${resumeAt}`); + } + + const timestamp = options.now ?? new Date(); + const timestampText = timestamp.toISOString(); + const sessionId = options.sessionId ?? randomUUID(); + const sessionDir = options.sessionDir ?? path.dirname(sourceSessionFile); + const sessionFile = path.join( + sessionDir, + `${timestampText.replace(/[:.]/g, '-')}_${sessionId}.jsonl`, + ); + const sourceCwd = typeof parsed.header?.cwd === 'string' && parsed.header.cwd.trim() + ? parsed.header.cwd.trim() + : process.cwd(); + const header = { + type: 'session', + version: 3, + id: sessionId, + timestamp: timestampText, + cwd: options.targetCwd ?? sourceCwd, + parentSession: sourceSessionFile, + }; + const lines = [ + JSON.stringify(header), + ...branchEntries.map(entry => JSON.stringify(entry.raw)), + ]; + + await fsp.mkdir(sessionDir, { recursive: true }); + await fsp.writeFile(sessionFile, `${lines.join('\n')}\n`, { flag: 'wx' }); + + return { + leafEntryId: resumeAt, + parentSession: sourceSessionFile, + sessionFile, + sessionId, + }; +} + +export function findPiSessionFile( + sessionIdOrFile: string, + cwd?: string | null, + sessionDir?: string | null, +): string | null { + const trimmed = sessionIdOrFile.trim(); + if (!trimmed) { + return null; + } + + if (path.isAbsolute(trimmed) && fileExists(trimmed)) { + return trimmed; + } + + const roots = [ + sessionDir, + cwd ? path.join(cwd, '.pi', 'agent', 'sessions') : null, + path.join(os.homedir(), '.pi', 'agent', 'sessions'), + ].filter((root): root is string => !!root); + + for (const root of roots) { + const direct = path.join(root, trimmed.endsWith('.jsonl') ? trimmed : `${trimmed}.jsonl`); + if (fileExists(direct)) { + return direct; + } + + const found = findSessionFileInRoot(root, trimmed); + if (found) { + return found; + } + } + + return null; +} + +/** Searches exactly one caller-owned root without adding implicit fallback roots. */ +export function findPiSessionFileInRoot( + sessionId: string, + root: string, +): string | null { + const trimmed = sessionId.trim(); + if (!trimmed || path.isAbsolute(trimmed) || /[\\/]/.test(trimmed)) { + return null; + } + + const direct = path.join(root, trimmed.endsWith('.jsonl') ? trimmed : `${trimmed}.jsonl`); + if (fileExists(direct)) { + return direct; + } + return findSessionFileInRoot(root, trimmed); +} + +export function derivePiSessionsRootFromSessionPath(sessionPath: string): string | null { + const normalized = sessionPath.trim(); + if (!normalized) { + return null; + } + + return path.dirname(normalized); +} + +function mapPiSessionEntries(entries: PiSessionEntry[]): ChatMessage[] { + const messages: ChatMessage[] = []; + + for (const entry of entries) { + const mapped = mapPiSessionEntry(entry, messages); + if (mapped) { + const previous = messages[messages.length - 1]; + if (isAssistantMessageEntry(entry) && canMergeAssistantContinuation(previous, mapped)) { + mergeAssistantContinuation(previous, mapped); + } else { + messages.push(mapped); + } + } + } + + return messages; +} + +function isAssistantMessageEntry(entry: PiSessionEntry): boolean { + const message = entry.message ?? entry.raw; + return (getString(message.role) ?? inferRole(entry.type)) === 'assistant'; +} + +function isBoundaryMessage(message: ChatMessage): boolean { + return message.contentBlocks?.some(block => block.type === 'context_compacted') === true; +} + +function canMergeAssistantContinuation( + previous: ChatMessage | undefined, + next: ChatMessage, +): previous is ChatMessage { + return previous?.role === 'assistant' + && next.role === 'assistant' + && !isBoundaryMessage(previous) + && !isBoundaryMessage(next); +} + +function mergeAssistantContinuation(target: ChatMessage, source: ChatMessage): void { + target.content += source.content; + target.assistantMessageId = source.assistantMessageId ?? target.assistantMessageId; + + if (source.contentBlocks && source.contentBlocks.length > 0) { + target.contentBlocks = [ + ...(target.contentBlocks ?? []), + ...source.contentBlocks, + ]; + } + + if (source.toolCalls && source.toolCalls.length > 0) { + const existingToolIds = new Set(target.toolCalls?.map(toolCall => toolCall.id) ?? []); + const newToolCalls = source.toolCalls.filter(toolCall => !existingToolIds.has(toolCall.id)); + if (newToolCalls.length > 0) { + target.toolCalls = [ + ...(target.toolCalls ?? []), + ...newToolCalls, + ]; + } + } +} + +function mapPiSessionEntry( + entry: PiSessionEntry, + messages: ChatMessage[], +): ChatMessage | null { + const message = entry.message ?? entry.raw; + const role = getString(message.role) ?? inferRole(entry.type); + const timestamp = getTimestamp(message.timestamp ?? entry.raw.timestamp); + + if (role === 'user') { + const images = extractUserImages(message.content ?? message.parts ?? message.blocks, entry.id ?? `pi-user-${messages.length}`); + return { + content: extractTextContent(message.content ?? message.text ?? message.message), + id: entry.id ?? `pi-user-${messages.length}`, + ...(images.length > 0 ? { images } : {}), + role: 'user', + timestamp, + userMessageId: entry.id, + }; + } + + if (role === 'assistant') { + const contentBlocks = extractAssistantContentBlocks(message.content ?? message.parts ?? message.blocks); + const toolCalls = extractAssistantToolCalls(message.content ?? message.parts ?? message.blocks); + const text = contentBlocks + .filter((block): block is Extract => block.type === 'text') + .map(block => block.content) + .join(''); + + return { + assistantMessageId: entry.id, + content: text, + ...(contentBlocks.length > 0 ? { contentBlocks } : {}), + id: entry.id ?? `pi-assistant-${messages.length}`, + role: 'assistant', + timestamp, + ...(toolCalls.length > 0 ? { toolCalls } : {}), + }; + } + + if (isToolResultEntry(entry)) { + applyToolResult(messages, entry); + return null; + } + + if (entry.type === 'compaction') { + return { + content: '', + contentBlocks: [{ type: 'context_compacted' }], + id: entry.id ?? `pi-compaction-${messages.length}`, + role: 'assistant', + timestamp, + }; + } + + if ( + (entry.type === 'branch_summary' || entry.type === 'compactionSummary' || entry.type === 'custom_message') + && entry.raw.display !== false + ) { + const content = extractTextContent(entry.raw.content ?? entry.raw.summary ?? entry.raw.message); + if (!content) { + return null; + } + return { + content, + contentBlocks: [{ type: 'text', content }], + id: entry.id ?? `pi-notice-${messages.length}`, + role: 'assistant', + timestamp, + }; + } + + return null; +} + +function extractAssistantContentBlocks(value: unknown): ContentBlock[] { + const blocks: ContentBlock[] = []; + const parts = Array.isArray(value) ? value : [{ type: 'text', text: extractTextContent(value) }]; + + for (const part of parts) { + if (!isPlainObject(part)) { + continue; + } + + const type = getString(part.type); + if (type === 'thinking' || type === 'reasoning') { + const content = extractTextContent(part.thinking ?? part.text ?? part.content); + if (content) { + blocks.push({ type: 'thinking', content }); + } + continue; + } + + if (type === 'toolCall' || type === 'tool_call' || type === 'toolUse' || type === 'tool_use') { + const toolId = getString(part.id) ?? getString(part.toolCallId) ?? getString(part.callId); + if (toolId) { + blocks.push({ type: 'tool_use', toolId }); + } + continue; + } + + const content = extractTextContent(part.text ?? part.content); + if (content) { + blocks.push({ type: 'text', content }); + } + } + + return blocks; +} + +function extractUserImages(value: unknown, messageId: string): ImageAttachment[] { + const parts = Array.isArray(value) ? value : []; + const images: ImageAttachment[] = []; + + for (const part of parts) { + if (!isPlainObject(part)) { + continue; + } + + const type = getString(part.type); + if (type !== 'image') { + continue; + } + + const data = getString(part.data); + const mediaType = getString(part.mimeType) ?? getString(part.mime_type) ?? getString(part.mediaType); + if (!data || !mediaType) { + continue; + } + + const image = buildImageAttachmentFromBase64({ + data, + id: `pi-img-${messageId}-${images.length}`, + mediaType, + name: getString(part.name) ?? getString(part.filename) ?? `image-${images.length + 1}.${mediaType.split('/')[1] ?? 'img'}`, + }); + if (image) { + images.push(image); + } + } + + return images; +} + +function extractAssistantToolCalls(value: unknown): ToolCallInfo[] { + const parts = Array.isArray(value) ? value : []; + return parts.flatMap((part): ToolCallInfo[] => { + if (!isPlainObject(part)) { + return []; + } + + const type = getString(part.type); + if (type !== 'toolCall' && type !== 'tool_call' && type !== 'toolUse' && type !== 'tool_use') { + return []; + } + + const id = getString(part.id) ?? getString(part.toolCallId) ?? getString(part.callId); + const rawName = getString(part.name) ?? getString(part.tool) ?? getString(part.toolName); + if (!id || !rawName) { + return []; + } + const name = normalizePiToolName(rawName); + + return [{ + id, + input: normalizePiToolInput(part.input ?? part.arguments ?? part.args, name), + name, + status: 'running', + }]; + }); +} + +function collectToolCallIds(entries: PiSessionEntry[]): Set { + const ids = new Set(); + for (const entry of entries) { + const message = entry.message ?? entry.raw; + const parts = message.content ?? message.parts ?? message.blocks; + for (const toolCall of extractAssistantToolCalls(parts)) { + ids.add(toolCall.id); + } + } + return ids; +} + +function isToolResultEntry(entry: PiSessionEntry): boolean { + const message = entry.message ?? entry.raw; + return entry.type === 'toolResult' + || entry.type === 'tool_result' + || getString(message.role) === 'toolResult' + || getString(message.role) === 'tool_result'; +} + +function getToolResultCallId(entry: PiSessionEntry): string | null { + const message = entry.message ?? entry.raw; + return getString(message.toolCallId) + ?? getString(message.tool_call_id) + ?? getString(message.id) + ?? getString(entry.raw.toolCallId) + ?? getString(entry.raw.tool_call_id) + ?? getString(entry.raw.id); +} + +function applyToolResult(messages: ChatMessage[], entry: PiSessionEntry): void { + const toolCallId = getToolResultCallId(entry); + if (!toolCallId) { + return; + } + + for (let index = messages.length - 1; index >= 0; index--) { + const chatMessage = messages[index]; + const toolCall = chatMessage.toolCalls?.find(call => call.id === toolCallId); + if (!toolCall) { + continue; + } + + const resultMessage = entry.message ?? entry.raw; + toolCall.status = resultMessage.error === true || resultMessage.isError === true ? 'error' : 'completed'; + toolCall.result = extractPiToolTextContent(resultMessage.result ?? resultMessage.content ?? resultMessage.output); + if (toolCall.status === 'completed' && isWriteEditTool(toolCall.name)) { + const diffData = extractDiffData(resultMessage, toolCall); + if (diffData) { + toolCall.diffData = diffData; + } + } + return; + } +} + +function inferMessageRecord(record: Record): Record | undefined { + return getString(record.role) ? record : undefined; +} + +function inferRole(type: string): string | null { + if (type === 'user' || type === 'assistant') { + return type; + } + return null; +} + +function extractTextContent(value: unknown): string { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + return value.map(extractTextContent).filter(Boolean).join(''); + } + + if (!isPlainObject(value)) { + return ''; + } + + if (typeof value.text === 'string') { + return value.text; + } + + if (typeof value.content === 'string') { + return value.content; + } + + return ''; +} + +function findSessionFileInRoot(root: string, sessionId: string): string | null { + try { + const entries = fs.readdirSync(root, { withFileTypes: true }); + for (const entry of entries) { + const candidate = path.join(root, entry.name); + if (entry.isDirectory()) { + const nested = findSessionFileInRoot(candidate, sessionId); + if (nested) { + return nested; + } + } else if ( + entry.isFile() + && entry.name.endsWith('.jsonl') + && entry.name.includes(sessionId) + ) { + return candidate; + } + } + } catch { + return null; + } + + return null; +} + +function fileExists(filePath: string): boolean { + try { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +function getTimestamp(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) { + return parsed; + } + } + return Date.now(); +} + +function getRecord(value: unknown): Record | null { + return isPlainObject(value) ? value : null; +} + +function getString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/providers/pi/internal/compareCollections.ts b/src/providers/pi/internal/compareCollections.ts new file mode 100644 index 0000000..f407ed6 --- /dev/null +++ b/src/providers/pi/internal/compareCollections.ts @@ -0,0 +1,4 @@ +export function sameStringList(left: string[], right: string[]): boolean { + return left.length === right.length + && left.every((value, index) => value === right[index]); +} diff --git a/src/providers/pi/internal/providerProjection.ts b/src/providers/pi/internal/providerProjection.ts new file mode 100644 index 0000000..5b8892d --- /dev/null +++ b/src/providers/pi/internal/providerProjection.ts @@ -0,0 +1,37 @@ +type ProviderProjectionKey = + | 'savedProviderEffort' + | 'savedProviderModel' + | 'savedProviderPermissionMode' + | 'savedProviderServiceTier' + | 'savedProviderThinkingBudget'; + +type ProviderProjectionMap = Partial>; + +function normalizeProviderProjectionMap(value: unknown): ProviderProjectionMap | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + const normalized: ProviderProjectionMap = {}; + for (const [providerId, projectedValue] of Object.entries(value)) { + if (typeof projectedValue === 'string') { + normalized[providerId] = projectedValue; + } + } + return normalized; +} + +export function ensureProviderProjectionMap( + settings: Record, + key: ProviderProjectionKey, +): ProviderProjectionMap { + const current = normalizeProviderProjectionMap(settings[key]); + if (current) { + settings[key] = current; + return current; + } + + const next: ProviderProjectionMap = {}; + settings[key] = next; + return next; +} diff --git a/src/providers/pi/models.ts b/src/providers/pi/models.ts new file mode 100644 index 0000000..46c3fd1 --- /dev/null +++ b/src/providers/pi/models.ts @@ -0,0 +1,307 @@ +import { + DEFAULT_REASONING_VALUE, + resolvePreferredReasoningDefault, +} from '../../core/providers/reasoning'; + +export type PiThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; + +export interface PiDiscoveredModel { + api?: string; + contextWindow?: number; + encodedId: string; + id: string; + input: Array<'text' | 'image'>; + label: string; + maxTokens?: number; + provider: string; + reasoning: boolean; + thinkingLevels: PiThinkingLevel[]; +} + +export interface DecodedPiModelId { + modelId: string; + provider: string; +} + +export const PI_SYNTHETIC_MODEL_ID = 'pi'; +export const PI_MODEL_PREFIX = 'pi:'; +export const PI_DEFAULT_THINKING_LEVEL: PiThinkingLevel = DEFAULT_REASONING_VALUE; + +const VALID_THINKING_LEVELS = new Set([ + 'off', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', +]); + +const DEFAULT_REASONING_LEVELS: PiThinkingLevel[] = [ + 'off', + 'minimal', + 'low', + 'medium', + 'high', +]; + +export function isPiModelSelectionId(model: string): boolean { + return model === PI_SYNTHETIC_MODEL_ID || model.startsWith(PI_MODEL_PREFIX); +} + +export function encodePiModelId(provider: string, modelId: string): string { + const normalizedProvider = provider.trim(); + const normalizedModelId = modelId.trim(); + if (!normalizedProvider || !normalizedModelId) { + return PI_SYNTHETIC_MODEL_ID; + } + + return `${PI_MODEL_PREFIX}${normalizedProvider}/${normalizedModelId}`; +} + +export function decodePiModelId(model: string): DecodedPiModelId | null { + if (!model.startsWith(PI_MODEL_PREFIX)) { + return null; + } + + const raw = model.slice(PI_MODEL_PREFIX.length).trim(); + const slashIndex = raw.indexOf('/'); + if (slashIndex <= 0 || slashIndex >= raw.length - 1) { + return null; + } + + const provider = raw.slice(0, slashIndex).trim(); + const modelId = raw.slice(slashIndex + 1).trim(); + return provider && modelId ? { provider, modelId } : null; +} + +export function normalizePiThinkingLevel(value: unknown): PiThinkingLevel | null { + if (typeof value !== 'string') { + return null; + } + + const normalized = value.trim().toLowerCase(); + return VALID_THINKING_LEVELS.has(normalized as PiThinkingLevel) + ? normalized as PiThinkingLevel + : null; +} + +export function getPiSupportedThinkingLevels(value: unknown): PiThinkingLevel[] { + const record = isPlainObject(value) ? value : {}; + const explicitLevels = collectExplicitThinkingLevels(record); + const mappedLevels = collectThinkingLevelMapLevels(record); + const reasoning = record.reasoning === true + || record.supportsReasoning === true + || record.thinking === true + || record.canReason === true + || explicitLevels.length > 0 + || mappedLevels.levels.length > 0; + if (!reasoning) { + return ['off']; + } + + if (explicitLevels.length === 0 && mappedLevels.levels.length === 0) { + return [...DEFAULT_REASONING_LEVELS]; + } + + const result: PiThinkingLevel[] = []; + const seen = new Set(); + for (const level of [...explicitLevels, ...mappedLevels.levels]) { + if (level === null || mappedLevels.disabledLevels.has(level)) { + continue; + } + + if (!seen.has(level)) { + seen.add(level); + result.push(level); + } + } + + return result.length > 0 ? sortThinkingLevels(result) : [...DEFAULT_REASONING_LEVELS]; +} + +export function normalizePiDiscoveredModels(value: unknown): PiDiscoveredModel[] { + if (!Array.isArray(value)) { + return []; + } + + const normalized: PiDiscoveredModel[] = []; + const seen = new Set(); + for (const entry of value) { + if (!isPlainObject(entry)) { + continue; + } + + const provider = firstString(entry.provider, entry.providerId, entry.api)?.trim() ?? ''; + const id = firstString(entry.id, entry.modelId, entry.model, entry.name)?.trim() ?? ''; + if (!provider || !id) { + continue; + } + + const key = `${provider}\0${id}`; + if (seen.has(key)) { + continue; + } + + seen.add(key); + const label = firstString(entry.label, entry.displayName, entry.name)?.trim() + || `${provider}/${id}`; + const api = firstString(entry.api)?.trim(); + const contextWindow = firstFinitePositiveNumber( + entry.contextWindow, + entry.context_window, + entry.context, + entry.maxContextTokens, + entry.max_context_tokens, + ); + const maxTokens = firstFinitePositiveNumber( + entry.maxTokens, + entry.max_tokens, + entry.outputTokens, + entry.output_tokens, + ); + const input = normalizeModelInputs( + entry.input ?? entry.inputs ?? entry.modalities ?? entry.supportedInputs, + ); + const thinkingLevels = getPiSupportedThinkingLevels(entry); + const reasoning = thinkingLevels.some(level => level !== 'off'); + + normalized.push({ + ...(api ? { api } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + encodedId: encodePiModelId(provider, id), + id, + input, + label, + ...(maxTokens !== undefined ? { maxTokens } : {}), + provider, + reasoning, + thinkingLevels, + }); + } + + return normalized; +} + +export function findPiModel( + settings: { discoveredModels: PiDiscoveredModel[] }, + encodedId: string, +): PiDiscoveredModel | null { + return settings.discoveredModels.find(model => model.encodedId === encodedId) ?? null; +} + +export function clampPiThinkingLevel( + level: string | undefined, + supportedLevels: PiThinkingLevel[], +): PiThinkingLevel { + const normalized = normalizePiThinkingLevel(level); + if (normalized && supportedLevels.includes(normalized)) { + return normalized; + } + + if (supportedLevels.length === 0) { + return 'off'; + } + + return resolvePreferredReasoningDefault(supportedLevels, 'medium') as PiThinkingLevel; +} + +function collectExplicitThinkingLevels(record: Record): Array { + const rawLevels = [ + record.thinkingLevels, + record.thinking_levels, + record.reasoningLevels, + record.reasoning_levels, + isPlainObject(record.thinking) ? record.thinking.levels : undefined, + isPlainObject(record.reasoning) ? record.reasoning.levels : undefined, + ].find(Array.isArray); + + if (!Array.isArray(rawLevels)) { + return []; + } + + return rawLevels + .map((level): PiThinkingLevel | null | undefined => { + if (level === null) { + return null; + } + return normalizePiThinkingLevel(level) ?? undefined; + }) + .filter((level): level is PiThinkingLevel | null => level !== undefined); +} + +function collectThinkingLevelMapLevels(record: Record): { + disabledLevels: Set; + levels: PiThinkingLevel[]; +} { + const rawMap = isPlainObject(record.thinkingLevelMap) + ? record.thinkingLevelMap + : isPlainObject(record.thinking_level_map) + ? record.thinking_level_map + : null; + if (!rawMap) { + return { disabledLevels: new Set(), levels: [] }; + } + + const disabledLevels = new Set(); + const levels: PiThinkingLevel[] = [...DEFAULT_REASONING_LEVELS]; + for (const [rawLevel, mappedLevel] of Object.entries(rawMap)) { + const level = normalizePiThinkingLevel(rawLevel); + if (!level) { + continue; + } + if (mappedLevel === null) { + disabledLevels.add(level); + } else { + levels.push(level); + } + } + return { disabledLevels, levels }; +} + +function sortThinkingLevels(levels: PiThinkingLevel[]): PiThinkingLevel[] { + const rank = new Map(DEFAULT_REASONING_LEVELS.concat('xhigh').map((level, index) => [level, index] as const)); + return [...levels].sort((left, right) => (rank.get(left) ?? 99) - (rank.get(right) ?? 99)); +} + +function normalizeModelInputs(value: unknown): Array<'text' | 'image'> { + const rawInputs = Array.isArray(value) ? value : ['text']; + const inputs: Array<'text' | 'image'> = []; + const seen = new Set<'text' | 'image'>(); + + for (const entry of rawInputs) { + const normalized = typeof entry === 'string' ? entry.trim().toLowerCase() : ''; + const input = normalized === 'image' || normalized === 'images' + ? 'image' + : normalized === 'text' + ? 'text' + : null; + if (input && !seen.has(input)) { + seen.add(input); + inputs.push(input); + } + } + + return inputs.length > 0 ? inputs : ['text']; +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string') { + return value; + } + } + return undefined; +} + +function firstFinitePositiveNumber(...values: unknown[]): number | undefined { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value; + } + } + return undefined; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/providers/pi/normalizations/piEventNormalization.ts b/src/providers/pi/normalizations/piEventNormalization.ts new file mode 100644 index 0000000..6c26dbf --- /dev/null +++ b/src/providers/pi/normalizations/piEventNormalization.ts @@ -0,0 +1,211 @@ +import type { StreamChunk } from '../../../core/types'; +import { + extractPiToolTextContent, + getPiToolId, + getPiToolName, + normalizePiToolInput, +} from './piToolNormalization'; + +export interface PiEventNormalizationState { + emittedToolIds: Set; + toolOutputs: Map; +} + +export function createPiEventNormalizationState(): PiEventNormalizationState { + return { + emittedToolIds: new Set(), + toolOutputs: new Map(), + }; +} + +export function normalizePiRpcEvent( + event: Record, + state: PiEventNormalizationState, +): StreamChunk[] { + switch (event.type) { + case 'agent_start': + return []; + case 'message_update': + return normalizeMessageUpdate(event); + case 'toolcall_end': + return normalizeToolUse(getNestedRecord(event, 'toolCall') ?? event, state); + case 'tool_execution_start': + return normalizeToolUse(getNestedRecord(event, 'toolCall') ?? event, state); + case 'tool_execution_update': + return normalizeToolOutput(event, state); + case 'tool_execution_end': + return normalizeToolResult(event, state); + case 'message_end': + case 'turn_end': + return normalizeTerminalError(event); + case 'compaction_end': + return [{ type: 'context_compacted' }]; + case 'auto_retry_start': + return [{ type: 'notice', content: 'Pi is retrying the turn.', level: 'warning' }]; + case 'auto_retry_end': + return [{ type: 'notice', content: 'Pi retry finished.', level: 'info' }]; + case 'extension_error': + return [{ type: 'notice', content: getString(event.error) ?? 'Pi extension error.', level: 'warning' }]; + default: + return []; + } +} + +export function getPiTerminalErrorMessage(event: Record): string | null { + if (event.type !== 'message_end' && event.type !== 'turn_end') { + return null; + } + + const terminalEvent = getNestedRecord(event, 'assistantMessageEvent') + ?? getNestedRecord(event, 'assistant_message_event') + ?? event; + const records = terminalEvent === event ? [event] : [terminalEvent, event]; + const stopReason = getStringField(records, ['stopReason', 'stop_reason']); + if (stopReason?.toLowerCase() !== 'error') { + return null; + } + + return getStringField(records, ['errorMessage', 'error_message', 'error', 'message']) + ?? getNestedStringField(records, 'error', ['message']) + ?? 'Pi turn failed.'; +} + +function normalizeMessageUpdate(event: Record): StreamChunk[] { + const assistantEvent = getNestedRecord(event, 'assistantMessageEvent') + ?? getNestedRecord(event, 'assistant_message_event') + ?? event; + const textDelta = getString(assistantEvent.text_delta) + ?? getString(assistantEvent.textDelta) + ?? ( + assistantEvent.type === 'text_delta' + ? getString(assistantEvent.delta) + : null + ); + if (textDelta) { + return [{ type: 'text', content: textDelta }]; + } + + const thinkingDelta = getString(assistantEvent.thinking_delta) + ?? getString(assistantEvent.thinkingDelta) + ?? ( + assistantEvent.type === 'thinking_delta' + ? getString(assistantEvent.delta) + : null + ); + if (thinkingDelta) { + return [{ type: 'thinking', content: thinkingDelta }]; + } + + return []; +} + +function normalizeTerminalError(event: Record): StreamChunk[] { + const message = getPiTerminalErrorMessage(event); + return message ? [{ type: 'error', content: message }] : []; +} + +function normalizeToolUse( + event: Record, + state: PiEventNormalizationState, +): StreamChunk[] { + const id = getPiToolId(event); + if (!id || state.emittedToolIds.has(id)) { + return []; + } + + state.emittedToolIds.add(id); + const name = getPiToolName(event); + return [{ + type: 'tool_use', + id, + input: normalizePiToolInput(event.input ?? event.arguments ?? event.args, name), + name, + }]; +} + +function normalizeToolOutput( + event: Record, + state: PiEventNormalizationState, +): StreamChunk[] { + const id = getPiToolId(event); + if (!id) { + return []; + } + + const content = extractPiToolTextContent(event.partialResult ?? event.output ?? event.result ?? event.content); + if (!content) { + return []; + } + + state.toolOutputs.set(id, content); + return [{ type: 'tool_output', id, content }]; +} + +function normalizeToolResult( + event: Record, + state: PiEventNormalizationState, +): StreamChunk[] { + const id = getPiToolId(event); + if (!id) { + return []; + } + + const content = extractPiToolTextContent(event.result ?? event.output ?? event.content) + || state.toolOutputs.get(id) + || ''; + const toolUseResult = getNestedRecord(event, 'result'); + return [{ + type: 'tool_result', + content, + id, + isError: event.isError === true || event.error === true || event.success === false, + ...(toolUseResult ? { toolUseResult } : {}), + }]; +} + +function getNestedRecord( + event: Record, + key: string, +): Record | null { + const value = event[key]; + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function getString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function getStringField( + records: Array>, + keys: string[], +): string | null { + for (const record of records) { + for (const key of keys) { + const value = getString(record[key]); + if (value) { + return value; + } + } + } + return null; +} + +function getNestedStringField( + records: Array>, + parentKey: string, + keys: string[], +): string | null { + for (const record of records) { + const nested = getNestedRecord(record, parentKey); + if (!nested) { + continue; + } + const value = getStringField([nested], keys); + if (value) { + return value; + } + } + return null; +} diff --git a/src/providers/pi/normalizations/piToolNormalization.ts b/src/providers/pi/normalizations/piToolNormalization.ts new file mode 100644 index 0000000..fb57d6d --- /dev/null +++ b/src/providers/pi/normalizations/piToolNormalization.ts @@ -0,0 +1,97 @@ +import { + TOOL_BASH, + TOOL_EDIT, + TOOL_GLOB, + TOOL_GREP, + TOOL_LS, + TOOL_READ, + TOOL_WRITE, +} from '../../../core/tools/toolNames'; + +const PI_BUILT_IN_TOOL_NAMES: Record = { + bash: TOOL_BASH, + edit: TOOL_EDIT, + find: TOOL_GLOB, + grep: TOOL_GREP, + ls: TOOL_LS, + read: TOOL_READ, + write: TOOL_WRITE, +}; + +export function extractPiToolTextContent(value: unknown): string { + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + return value + .map(extractPiToolTextContent) + .filter(Boolean) + .join('\n'); + } + + if (!isPlainObject(value)) { + return ''; + } + + if (typeof value.text === 'string') { + return value.text; + } + if (typeof value.content === 'string') { + return value.content; + } + if (Array.isArray(value.content)) { + return extractPiToolTextContent(value.content); + } + if (isPlainObject(value.partialResult)) { + return extractPiToolTextContent(value.partialResult.content); + } + if (isPlainObject(value.result)) { + return extractPiToolTextContent(value.result.content ?? value.result); + } + + return ''; +} + +export function normalizePiToolInput(value: unknown, toolName?: string): Record { + const input = isPlainObject(value) ? { ...value } : {}; + const normalizedToolName = toolName ? normalizePiToolName(toolName) : ''; + + if ( + (normalizedToolName === TOOL_READ || normalizedToolName === TOOL_WRITE || normalizedToolName === TOOL_EDIT) + && typeof input.path === 'string' + && typeof input.file_path !== 'string' + ) { + input.file_path = input.path; + } + + return input; +} + +export function getPiToolId(value: Record): string { + return firstString(value.id, value.toolCallId, value.callId, value.call_id) ?? ''; +} + +export function getPiToolName(value: Record): string { + return normalizePiToolName(firstString(value.name, value.tool, value.toolName, value.tool_name) ?? 'tool'); +} + +export function normalizePiToolName(name: string): string { + return PI_BUILT_IN_TOOL_NAMES[name.trim().toLowerCase()] ?? name; +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed) { + return trimmed; + } + } + } + return undefined; +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/providers/pi/registration.ts b/src/providers/pi/registration.ts new file mode 100644 index 0000000..edbc345 --- /dev/null +++ b/src/providers/pi/registration.ts @@ -0,0 +1,40 @@ +import type { ProviderModule } from '../../core/providers/types'; +import { piWorkspaceRegistration } from './app/PiWorkspaceServices'; +import { PiInlineEditService } from './auxiliary/PiInlineEditService'; +import { PiInstructionRefineService } from './auxiliary/PiInstructionRefineService'; +import { PiTaskResultInterpreter } from './auxiliary/PiTaskResultInterpreter'; +import { PiTitleGenerationService } from './auxiliary/PiTitleGenerationService'; +import { PI_PROVIDER_CAPABILITIES } from './capabilities'; +import { piSettingsReconciler } from './env/PiSettingsReconciler'; +import { PiConversationHistoryService } from './history/PiConversationHistoryService'; +import { PiChatRuntime } from './runtime/PiChatRuntime'; +import { getPiProviderSettings, updatePiProviderSettings } from './settings'; +import { ObsidianPiExtensionUiRenderer } from './ui/ObsidianPiExtensionUiRenderer'; +import { piChatUIConfig } from './ui/PiChatUIConfig'; + +export const piProviderRegistration: ProviderModule = { + id: 'pi', + blankTabOrder: 11, + capabilities: PI_PROVIDER_CAPABILITIES, + chatUIConfig: piChatUIConfig, + createInlineEditService: (plugin) => new PiInlineEditService(plugin), + createInstructionRefineService: (plugin) => new PiInstructionRefineService(plugin), + createRuntime: ({ plugin }) => new PiChatRuntime(plugin, { + extensionUiRenderer: new ObsidianPiExtensionUiRenderer(plugin.app), + }), + createTitleGenerationService: (plugin) => new PiTitleGenerationService(plugin), + displayName: 'Pi', + environmentKeyPatterns: [/^PI_/i], + historyService: new PiConversationHistoryService(), + isEnabled: (settings) => getPiProviderSettings(settings).enabled, + settingsReconciler: piSettingsReconciler, + settingsStorage: { + hostScopedFields: ['cliPathsByHost'], + normalizeStored(target, stored) { + updatePiProviderSettings(target, getPiProviderSettings(stored)); + return false; + }, + }, + taskResultInterpreter: new PiTaskResultInterpreter(), + workspace: piWorkspaceRegistration, +}; diff --git a/src/providers/pi/runtime/PiAuxQueryRunner.ts b/src/providers/pi/runtime/PiAuxQueryRunner.ts new file mode 100644 index 0000000..da85c58 --- /dev/null +++ b/src/providers/pi/runtime/PiAuxQueryRunner.ts @@ -0,0 +1,216 @@ +import type { AuxQueryConfig, AuxQueryRunner } from '../../../core/auxiliary/AuxQueryRunner'; +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import { parseEnvironmentVariables } from '../../../utils/env'; +import { getVaultPath } from '../../../utils/path'; +import { decodePiModelId } from '../models'; +import { + createPiEventNormalizationState, + normalizePiRpcEvent, +} from '../normalizations/piEventNormalization'; +import { getPiProviderSettings } from '../settings'; +import { buildPiLaunchSpec } from './PiLaunchSpec'; +import { buildPiSetModelPayload } from './PiRpcPayloads'; +import { PiRpcTransport } from './PiRpcTransport'; +import { PiSubprocess } from './PiSubprocess'; + +type PiAuxProfile = 'passive' | 'readonly'; + +export interface PiAuxQueryRunnerOptions { + profile: PiAuxProfile; +} + +export class PiAuxQueryRunner implements AuxQueryRunner { + private currentLaunchKey: string | null = null; + private process: PiSubprocess | null = null; + private transport: PiRpcTransport | null = null; + + constructor( + private readonly plugin: ProviderHost, + private readonly options: PiAuxQueryRunnerOptions, + ) {} + + async query(config: AuxQueryConfig, prompt: string): Promise { + await this.ensureReady(config.systemPrompt); + const transport = this.transport; + if (!transport) { + throw new Error('Pi auxiliary runtime is not ready.'); + } + + const model = this.resolveSelectedModel(config.model); + if (model) { + const payload = buildPiSetModelPayload(model); + if (payload) { + await transport.request('set_model', payload); + } + } + + let accumulatedText = ''; + const normalizationState = createPiEventNormalizationState(); + let terminalSettled = false; + let resolveTerminal!: () => void; + let rejectTerminal!: (error: Error) => void; + const terminalPromise = new Promise((resolve, reject) => { + resolveTerminal = () => { + if (terminalSettled) { + return; + } + terminalSettled = true; + resolve(); + }; + rejectTerminal = (error) => { + if (terminalSettled) { + return; + } + terminalSettled = true; + reject(error); + }; + }); + terminalPromise.catch(() => {}); + const removeListener = transport.onEvent((event) => { + if (event.type === 'extension_ui_request') { + const id = typeof event.id === 'string' && event.id.trim() ? event.id.trim() : ''; + if (id) { + transport.send({ + cancelled: true, + id, + type: 'extension_ui_response', + }); + } + return; + } + + if (event.type === 'agent_end') { + resolveTerminal(); + return; + } + if (event.type === 'error') { + rejectTerminal(new Error( + typeof event.error === 'string' ? event.error : 'Pi auxiliary request failed', + )); + return; + } + for (const chunk of normalizePiRpcEvent(event, normalizationState)) { + if (chunk.type === 'error') { + rejectTerminal(new Error(chunk.content)); + continue; + } + if (chunk.type !== 'text') { + continue; + } + accumulatedText += chunk.content; + config.onTextChunk?.(accumulatedText); + } + }); + const removeCloseListener = transport.onClose((error) => { + rejectTerminal(error ?? new Error('Pi auxiliary runtime closed before completion.')); + }); + const abortHandler = () => { + transport.send({ type: 'abort' }); + rejectTerminal(new Error('Cancelled')); + }; + config.abortController?.signal.addEventListener('abort', abortHandler, { once: true }); + + try { + if (config.abortController?.signal.aborted) { + throw new Error('Cancelled'); + } + + const promptRequest = transport.request('prompt', { message: prompt }); + promptRequest.catch(() => {}); + await Promise.race([promptRequest, terminalPromise]); + await terminalPromise; + if (config.abortController?.signal.aborted) { + throw new Error('Cancelled'); + } + + return accumulatedText; + } catch (error) { + const message = error instanceof Error ? error.message : 'Pi request failed'; + const stderr = this.process?.getStderrSnapshot(); + if (config.abortController?.signal.aborted) { + this.reset(); + } + throw new Error( + stderr ? `${message}\n\n${stderr}` : message, + error instanceof Error ? { cause: error } : undefined, + ); + } finally { + config.abortController?.signal.removeEventListener('abort', abortHandler); + removeCloseListener(); + removeListener(); + } + } + + reset(): void { + this.currentLaunchKey = null; + this.transport?.dispose(); + this.transport = null; + if (this.process) { + void this.process.shutdown().catch(() => {}); + this.process = null; + } + } + + private async ensureReady(systemPrompt: string): Promise { + const settingsBag = this.plugin.settings as unknown as Record; + const settings = getPiProviderSettings(settingsBag); + const command = this.plugin.getResolvedProviderCliPath('pi') ?? 'pi'; + const cwd = getVaultPath(this.plugin.app) ?? process.cwd(); + const envText = getRuntimeEnvironmentText(settingsBag, 'pi'); + const env = { + ...process.env, + ...parseEnvironmentVariables(envText), + }; + const launchSpec = buildPiLaunchSpec({ + command, + cwd, + env, + envText, + noSession: true, + noTools: this.options.profile === 'passive', + settings: { + ...settings, + toolMode: this.options.profile === 'readonly' ? 'readonly' : settings.toolMode, + }, + systemPrompt, + }); + + if ( + this.process + && this.transport + && this.process.isAlive() + && !this.transport.isClosed + && this.currentLaunchKey === launchSpec.launchKey + ) { + return; + } + + this.reset(); + this.process = new PiSubprocess(launchSpec); + this.process.start(); + this.transport = new PiRpcTransport({ + input: this.process.stdout, + onClose: (listener) => this.process!.onClose(listener), + output: this.process.stdin, + }); + this.transport.start(); + this.currentLaunchKey = launchSpec.launchKey; + } + + private resolveSelectedModel(explicitModel?: string): string | undefined { + if (explicitModel && decodePiModelId(explicitModel)) { + return explicitModel; + } + + const projectedSettings = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + this.plugin.settings, + 'pi', + ); + const selectedModel = typeof projectedSettings.model === 'string' + ? projectedSettings.model + : ''; + return decodePiModelId(selectedModel) ? selectedModel : undefined; + } +} diff --git a/src/providers/pi/runtime/PiChatRuntime.ts b/src/providers/pi/runtime/PiChatRuntime.ts new file mode 100644 index 0000000..0f8c355 --- /dev/null +++ b/src/providers/pi/runtime/PiChatRuntime.ts @@ -0,0 +1,1293 @@ +import * as fsp from 'node:fs/promises'; +import * as path from 'node:path'; + +import { + buildSystemPrompt, + computeSystemPromptKey, + type SystemPromptSettings, +} from '../../../core/prompt/mainAgent'; +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator'; +import type { ProviderCapabilities } from '../../../core/providers/types'; +import type { ChatRuntime } from '../../../core/runtime/ChatRuntime'; +import type { + ApprovalCallback, + AskUserQuestionCallback, + AutoTurnCallback, + ChatRewindMode, + ChatRewindResult, + ChatRuntimeConversationState, + ChatRuntimeEnsureReadyOptions, + ChatRuntimeQueryOptions, + ChatTurnMetadata, + ChatTurnRequest, + ExitPlanModeCallback, + PreparedChatTurn, + SessionUpdateResult, + SubagentRuntimeState, +} from '../../../core/runtime/types'; +import type { + ChatMessage, + Conversation, + SlashCommand, + StreamChunk, + ToolCallInfo, + UsageInfo, +} from '../../../core/types'; +import { parseEnvironmentVariables } from '../../../utils/env'; +import { getVaultPath } from '../../../utils/path'; +import { PI_PROVIDER_CAPABILITIES } from '../capabilities'; +import { + createPiForkSessionFile, + findPiSessionFile, + parsePiSessionEntries, + resolvePiActivePath, +} from '../history/PiHistoryStore'; +import { + clampPiThinkingLevel, + decodePiModelId, + findPiModel, + PI_SYNTHETIC_MODEL_ID, +} from '../models'; +import { + createPiEventNormalizationState, + getPiTerminalErrorMessage, + normalizePiRpcEvent, + type PiEventNormalizationState, +} from '../normalizations/piEventNormalization'; +import { getPiProviderSettings } from '../settings'; +import { + buildPersistedPiState, + getPiState, + type PiForkSource, + type PiProviderState, +} from '../types'; +import { buildPiPromptImages, buildPiPromptText } from './buildPiPrompt'; +import { buildPiUsageInfo } from './buildPiUsageInfo'; +import { PiExtensionUiBridge, type PiExtensionUiRenderer } from './PiExtensionUiBridge'; +import { buildPiLaunchSpec, type PiLaunchSpec } from './PiLaunchSpec'; +import { buildPiSetModelPayload } from './PiRpcPayloads'; +import { type PiRpcRecord, PiRpcTransport } from './PiRpcTransport'; +import { PiSubprocess } from './PiSubprocess'; + +interface ActiveTurn { + cancel: (error: Error) => void; + cancelled: boolean; + queue: StreamChunkQueue; + rejectTerminal: (error: Error) => void; + resolveTerminal: () => void; + terminalPromise: Promise; +} + +class StreamChunkQueue { + private closed = false; + private readonly items: StreamChunk[] = []; + private readonly waiters: Array<(chunk: StreamChunk | null) => void> = []; + + push(chunk: StreamChunk): void { + if (this.closed) { + return; + } + + const waiter = this.waiters.shift(); + if (waiter) { + waiter(chunk); + return; + } + this.items.push(chunk); + } + + close(): void { + if (this.closed) { + return; + } + + this.closed = true; + while (this.waiters.length > 0) { + this.waiters.shift()?.(null); + } + } + + async next(): Promise { + if (this.items.length > 0) { + return this.items.shift() ?? null; + } + + if (this.closed) { + return null; + } + + return new Promise((resolve) => { + this.waiters.push(resolve); + }); + } +} + +export interface PiChatRuntimeOptions { + extensionUiRenderer?: PiExtensionUiRenderer | null; +} + +export class PiChatRuntime implements ChatRuntime { + readonly providerId = 'pi' as const; + + private activeTurn: ActiveTurn | null = null; + private conversationId: string | null = null; + private conversationGeneration = 0; + private currentLaunchKey: string | null = null; + private currentConversationModel: string | null = null; + private currentModel: string | null = null; + private currentSessionTarget: string | null = null; + private currentThinkingLevel: string | null = null; + private currentTurnMetadata: ChatTurnMetadata = {}; + private extensionBridge: PiExtensionUiBridge | null = null; + private leafEntryId: string | null = null; + private parentSession: string | null = null; + private pendingFork: PiForkSource | null = null; + private pendingForkSourceSessionFile: string | null = null; + private process: PiSubprocess | null = null; + private ready = false; + private readinessFlight: { key: string; promise: Promise } | null = null; + private disposed = false; + private lifecycleGeneration = 0; + private readonly readyListeners = new Set<(ready: boolean) => void>(); + private sessionFile: string | null = null; + private sessionId: string | null = null; + private sessionInvalidated = false; + private sessionResetPromise: Promise | null = null; + private supportedCommands: SlashCommand[] = []; + private shutdownPromise: Promise | null = null; + private transport: PiRpcTransport | null = null; + private unregisterTransportClose: (() => void) | null = null; + + constructor( + private readonly plugin: ProviderHost, + private readonly options: PiChatRuntimeOptions = {}, + ) {} + + getCapabilities(): Readonly { + return PI_PROVIDER_CAPABILITIES; + } + + prepareTurn(request: ChatTurnRequest): PreparedChatTurn { + const isCompact = isCompactCommand(request.text); + return { + isCompact, + mcpMentions: request.enabledMcpServers ?? new Set(), + persistedContent: '', + prompt: buildPiPromptText(request), + request, + }; + } + + onReadyStateChange(listener: (ready: boolean) => void): () => void { + this.readyListeners.add(listener); + return () => { + this.readyListeners.delete(listener); + }; + } + + setResumeCheckpoint(_checkpointId: string | undefined): void {} + + syncConversationState(conversation: ChatRuntimeConversationState | null): void { + this.setCurrentConversationModel(conversation?.selectedModel); + const nextConversationId = conversation?.id ?? null; + const state = getPiState(conversation?.providerState); + const isPendingFork = !!conversation + && !!state.forkSource + && !state.sessionId + && !state.sessionFile + && !conversation.sessionId; + const nextSessionId = isPendingFork + ? null + : state.sessionId ?? conversation?.sessionId ?? null; + const nextSessionFile = isPendingFork ? null : state.sessionFile ?? null; + const nextPendingFork = isPendingFork ? state.forkSource ?? null : null; + const nextPendingForkSourceSessionFile = isPendingFork + ? state.forkSourceSessionFile ?? null + : null; + const currentTargetKey = JSON.stringify({ + conversationId: this.conversationId, + pendingFork: this.pendingFork, + pendingForkSourceSessionFile: this.pendingForkSourceSessionFile, + sessionFile: this.sessionFile, + sessionId: this.sessionId, + }); + const nextTargetKey = JSON.stringify({ + conversationId: nextConversationId, + pendingFork: nextPendingFork, + pendingForkSourceSessionFile: nextPendingForkSourceSessionFile, + sessionFile: nextSessionFile, + sessionId: nextSessionId, + }); + + if (!conversation) { + this.currentConversationModel = null; + } + this.conversationId = nextConversationId; + this.sessionId = nextSessionId; + this.sessionFile = nextSessionFile; + this.leafEntryId = isPendingFork ? null : state.leafEntryId ?? null; + this.parentSession = isPendingFork ? null : state.parentSession ?? null; + this.pendingFork = nextPendingFork; + this.pendingForkSourceSessionFile = nextPendingForkSourceSessionFile; + this.sessionInvalidated = false; + if (currentTargetKey !== nextTargetKey) { + this.conversationGeneration += 1; + if (this.readinessFlight) { + void this.shutdownProcess(); + } + } + } + + async reloadMcpServers(): Promise {} + + async ensureReady(options?: ChatRuntimeEnsureReadyOptions): Promise { + if (this.disposed) { + return false; + } + const conversationGeneration = this.conversationGeneration; + const key = JSON.stringify({ conversationGeneration, options: options ?? {} }); + if (this.readinessFlight) { + if (this.readinessFlight.key === key) { + return this.readinessFlight.promise; + } + await this.readinessFlight.promise.catch(() => undefined); + return this.ensureReady(options); + } + + const lifecycleGeneration = this.lifecycleGeneration; + const promise = this.ensureReadyInternal( + options, + lifecycleGeneration, + conversationGeneration, + ); + this.readinessFlight = { key, promise }; + return promise.finally(() => { + if (this.readinessFlight?.promise === promise) { + this.readinessFlight = null; + } + }); + } + + private async ensureReadyInternal( + options: ChatRuntimeEnsureReadyOptions | undefined, + lifecycleGeneration: number, + conversationGeneration: number, + ): Promise { + const settings = getPiProviderSettings(this.plugin.settings); + if (!settings.enabled) { + this.setReady(false); + return false; + } + + await this.sessionResetPromise; + if (!this.isReadinessCurrent(lifecycleGeneration, conversationGeneration)) { + return false; + } + + const allowSessionCreation = options?.allowSessionCreation !== false; + const cwd = getVaultPath(this.plugin.app) ?? process.cwd(); + const resolvedCliPath = this.plugin.getResolvedProviderCliPath('pi') ?? 'pi'; + const runtimeEnvText = getRuntimeEnvironmentText(this.plugin.settings, 'pi'); + if (allowSessionCreation) { + const materialized = await this.materializePendingFork( + cwd, + runtimeEnvText, + conversationGeneration, + ); + if ( + !materialized + || !this.isReadinessCurrent(lifecycleGeneration, conversationGeneration) + ) { + return false; + } + } + + const hasSessionTarget = Boolean(this.sessionId || this.sessionFile); + const promptSettings = this.getSystemPromptSettings(cwd); + const systemPrompt = buildSystemPrompt(promptSettings); + const noSession = !allowSessionCreation && !hasSessionTarget; + const launchSpec = buildPiLaunchSpec({ + command: resolvedCliPath, + cwd, + env: this.buildRuntimeEnv(runtimeEnvText), + envText: runtimeEnvText, + noSession, + providerState: this.getCurrentProviderState(), + settings, + systemPrompt, + }); + const sessionTarget = this.sessionFile ?? this.sessionId ?? null; + const nextLaunchKey = JSON.stringify({ + command: resolvedCliPath, + cwd, + envText: runtimeEnvText, + noSession, + promptKey: computeSystemPromptKey(promptSettings), + systemPrompt, + toolMode: settings.toolMode, + }); + const sessionTargetChanged = sessionTarget !== this.currentSessionTarget; + const canSwitchSessionTarget = sessionTargetChanged && this.isSwitchableSessionFile(this.sessionFile); + const unSwitchableSessionTargetChanged = sessionTargetChanged && !canSwitchSessionTarget; + + const shouldRestart = !this.process + || !this.transport + || !this.process.isAlive() + || this.transport.isClosed + || options?.force === true + || this.currentLaunchKey !== nextLaunchKey + || unSwitchableSessionTargetChanged; + + if (shouldRestart) { + await this.shutdownProcess(); + if (!this.isReadinessCurrent(lifecycleGeneration, conversationGeneration)) { + return false; + } + await this.startProcess(launchSpec); + if (!this.isReadinessCurrent(lifecycleGeneration, conversationGeneration)) { + await this.shutdownProcess(); + return false; + } + this.currentLaunchKey = nextLaunchKey; + this.currentSessionTarget = sessionTarget; + } else if (canSwitchSessionTarget && this.sessionFile) { + const switched = await this.switchSession( + this.sessionFile, + launchSpec, + nextLaunchKey, + lifecycleGeneration, + conversationGeneration, + ); + if ( + !switched + || !this.isReadinessCurrent(lifecycleGeneration, conversationGeneration) + ) { + await this.shutdownProcess(); + return false; + } + } + + if (allowSessionCreation || hasSessionTarget) { + await this.refreshStateAndSessionTarget(conversationGeneration); + if (!this.isReadinessCurrent(lifecycleGeneration, conversationGeneration)) { + await this.shutdownProcess(); + return false; + } + } + this.setReady(true); + return true; + } + + async *query( + turn: PreparedChatTurn, + conversationHistory?: ChatMessage[], + queryOptions?: ChatRuntimeQueryOptions, + ): AsyncGenerator { + if (queryOptions?.model) { + this.setCurrentConversationModel(queryOptions.model); + } + const conversationGeneration = this.conversationGeneration; + this.currentTurnMetadata = {}; + let isReady: boolean; + try { + isReady = await this.ensureReady(); + } catch (error) { + yield { type: 'error', content: this.formatRuntimeError(error) }; + yield { type: 'done' }; + return; + } + + if (!isReady) { + yield { type: 'error', content: 'Failed to start Pi. Check the CLI path and login state.' }; + yield { type: 'done' }; + return; + } + + if (!this.isConversationCurrent(conversationGeneration)) { + yield { type: 'error', content: 'Pi conversation changed before the turn started.' }; + yield { type: 'done' }; + return; + } + + if (!this.transport) { + yield { type: 'error', content: 'Pi runtime is not ready.' }; + yield { type: 'done' }; + return; + } + + const activeTurn = this.createActiveTurn(); + this.activeTurn = activeTurn; + this.normalizationState = createPiEventNormalizationState(); + const shouldBootstrapHistory = (conversationHistory?.length ?? 0) > 0 + && !this.sessionId + && !this.sessionFile; + const promptText = buildPiPromptText( + turn.request, + shouldBootstrapHistory ? conversationHistory : [], + ); + const images = buildPiPromptImages(turn.request.images); + + const runTurn = this.runTurn( + activeTurn, + turn, + promptText, + images, + queryOptions, + conversationGeneration, + ); + + try { + while (true) { + const chunk = await activeTurn.queue.next(); + if (!chunk) { + break; + } + yield chunk; + } + await runTurn; + } finally { + if (this.activeTurn === activeTurn) { + this.transport?.send({ type: 'abort' }); + activeTurn.cancel(new Error('Pi turn cancelled')); + activeTurn.queue.close(); + this.activeTurn = null; + void this.shutdownProcess(); + } + } + } + + async steer(turn: PreparedChatTurn): Promise { + if (!this.transport || this.transport.isClosed) { + return false; + } + + try { + const images = buildPiPromptImages(turn.request.images); + await this.transport.request('steer', { + ...(images.length > 0 ? { images } : {}), + message: buildPiPromptText(turn.request), + }); + this.activeTurn?.queue.push({ + type: 'user_message_start', + content: turn.request.text, + }); + return true; + } catch { + return false; + } + } + + cancel(): void { + const activeTurn = this.activeTurn; + this.transport?.send({ type: 'abort' }); + if (!activeTurn || activeTurn.cancelled) { + return; + } + + activeTurn.cancel(new Error('Pi turn cancelled')); + activeTurn.queue.push({ type: 'done' }); + activeTurn.queue.close(); + void this.shutdownProcess(); + } + + resetSession(): void { + this.conversationGeneration += 1; + const conversationGeneration = this.conversationGeneration; + if (this.readinessFlight) { + void this.shutdownProcess(); + } + this.sessionInvalidated = true; + this.sessionId = null; + this.sessionFile = null; + this.leafEntryId = null; + this.parentSession = null; + this.pendingFork = null; + this.pendingForkSourceSessionFile = null; + this.currentSessionTarget = null; + if (this.transport && !this.transport.isClosed) { + const resetPromise = this.transport.request('new_session') + .then((response) => { + if (!this.isConversationCurrent(conversationGeneration)) { + return; + } + this.applyStateResponse(response); + this.currentSessionTarget = this.sessionFile ?? this.sessionId ?? null; + }) + .catch(() => {}) + .then(() => undefined); + const trackedResetPromise = resetPromise.finally(() => { + if (this.sessionResetPromise === trackedResetPromise) { + this.sessionResetPromise = null; + } + }); + this.sessionResetPromise = trackedResetPromise; + } + } + + getSessionId(): string | null { + return this.sessionId; + } + + consumeSessionInvalidation(): boolean { + const invalidated = this.sessionInvalidated; + this.sessionInvalidated = false; + return invalidated; + } + + isReady(): boolean { + return this.ready; + } + + async getSupportedCommands(): Promise { + if (this.supportedCommands.length > 0) { + return [...this.supportedCommands]; + } + if (!this.transport || this.transport.isClosed) { + return []; + } + + try { + const response = await this.transport.request('get_commands', {}, 10_000); + this.supportedCommands = normalizePiRuntimeCommands(response); + return [...this.supportedCommands]; + } catch { + return []; + } + } + + getAuxiliaryModel(): string | null { + return this.currentConversationModel ?? this.currentModel; + } + + cleanup(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.lifecycleGeneration += 1; + this.activeTurn?.queue.close(); + this.extensionBridge?.cleanup(); + void this.shutdownProcess(); + } + + async rewind( + _userMessageId: string, + _assistantMessageId: string | undefined, + _mode?: ChatRewindMode, + ): Promise { + return { canRewind: false }; + } + + setApprovalCallback(_callback: ApprovalCallback | null): void {} + setApprovalDismisser(_dismisser: (() => void) | null): void {} + setAskUserQuestionCallback(_callback: AskUserQuestionCallback | null): void {} + setExitPlanModeCallback(_callback: ExitPlanModeCallback | null): void {} + setPermissionModeSyncCallback(_callback: ((sdkMode: string) => void) | null): void {} + setSubagentHookProvider(_getState: () => SubagentRuntimeState): void {} + setAutoTurnCallback(_callback: AutoTurnCallback | null): void {} + + consumeTurnMetadata(): ChatTurnMetadata { + const metadata = this.currentTurnMetadata; + this.currentTurnMetadata = {}; + return metadata; + } + + buildSessionUpdates(params: { + conversation: Conversation | null; + sessionInvalidated: boolean; + }): SessionUpdateResult { + const providerState = buildPersistedPiState(this.getCurrentProviderState()); + const updates: Partial = { + providerState: providerState as Record | undefined, + sessionId: this.sessionId, + }; + + if (params.sessionInvalidated && !this.sessionId) { + updates.providerState = undefined; + updates.sessionId = null; + } + + return { updates }; + } + + resolveSessionIdForFork(conversation: Conversation | null): string | null { + const state = getPiState(conversation?.providerState); + return this.sessionFile + ?? this.sessionId + ?? state.sessionFile + ?? state.sessionId + ?? conversation?.sessionId + ?? state.forkSource?.sessionId + ?? null; + } + + async loadSubagentToolCalls(_agentId: string): Promise { + return []; + } + + async loadSubagentFinalResult(_agentId: string): Promise { + return null; + } + + private createActiveTurn(): ActiveTurn { + let terminalSettled = false; + let resolveTerminal!: () => void; + let rejectTerminal!: (error: Error) => void; + const terminalPromise = new Promise((resolve, reject) => { + resolveTerminal = () => { + if (terminalSettled) { + return; + } + terminalSettled = true; + resolve(); + }; + rejectTerminal = (error) => { + if (terminalSettled) { + return; + } + terminalSettled = true; + reject(error); + }; + }); + terminalPromise.catch(() => {}); + + const activeTurn: ActiveTurn = { + cancel: (error) => { + activeTurn.cancelled = true; + rejectTerminal(error); + }, + cancelled: false, + queue: new StreamChunkQueue(), + rejectTerminal, + resolveTerminal, + terminalPromise, + }; + return activeTurn; + } + + private async runTurn( + activeTurn: ActiveTurn, + turn: PreparedChatTurn, + promptText: string, + images: ReturnType, + queryOptions?: ChatRuntimeQueryOptions, + conversationGeneration = this.conversationGeneration, + ): Promise { + try { + const turnStartLeafId = await this.resolveCurrentLeafEntryId(conversationGeneration); + await this.applySelectedModel(queryOptions, conversationGeneration); + await this.applySelectedThinkingLevel(queryOptions, conversationGeneration); + if (!this.isConversationCurrent(conversationGeneration)) { + throw new Error('Pi conversation changed before the turn started.'); + } + if (activeTurn.cancelled) { + throw new Error('Pi turn cancelled'); + } + + if (turn.isCompact) { + await this.transport!.request('compact', { + customInstructions: stripCompactCommand(turn.request.text), + }); + this.currentTurnMetadata.wasSent = true; + activeTurn.queue.push({ type: 'context_compacted' }); + } else { + activeTurn.queue.push({ + type: 'user_message_start', + content: turn.request.text, + }); + await this.transport!.request('prompt', { + ...(images.length > 0 ? { images } : {}), + message: promptText, + }); + this.currentTurnMetadata.wasSent = true; + await activeTurn.terminalPromise; + } + + await this.refreshStateAndSessionTarget(conversationGeneration); + if (!this.isConversationCurrent(conversationGeneration)) { + throw new Error('Pi conversation changed before the turn completed.'); + } + await this.updateTurnMetadataFromSessionFile(turnStartLeafId, conversationGeneration); + const usage = await this.fetchUsage(queryOptions).catch(() => null); + if (usage) { + activeTurn.queue.push({ sessionId: this.sessionId, type: 'usage', usage }); + } + activeTurn.queue.push({ type: 'done' }); + } catch (error) { + activeTurn.queue.push({ + type: 'error', + content: this.formatRuntimeError(error), + }); + activeTurn.queue.push({ type: 'done' }); + } finally { + activeTurn.queue.close(); + if (this.activeTurn === activeTurn) { + this.activeTurn = null; + } + } + } + + private async startProcess(launchSpec: PiLaunchSpec): Promise { + this.process = new PiSubprocess(launchSpec); + this.process.start(); + this.transport = new PiRpcTransport({ + input: this.process.stdout, + onClose: (listener) => this.process!.onClose(listener), + output: this.process.stdin, + }); + this.transport.start(); + this.extensionBridge = new PiExtensionUiBridge( + this.transport, + this.options.extensionUiRenderer ?? null, + (chunk) => this.activeTurn?.queue.push(chunk), + ); + this.transport.onEvent((event) => this.handleRpcEvent(event)); + this.unregisterTransportClose = this.transport.onClose((error) => { + this.setReady(false); + this.extensionBridge?.cleanup(); + this.activeTurn?.rejectTerminal(error ?? new Error('Pi runtime closed')); + }); + this.setReady(true); + } + + private async shutdownProcess(): Promise { + if (this.shutdownPromise) { + return this.shutdownPromise; + } + + this.shutdownPromise = this.doShutdownProcess().finally(() => { + this.shutdownPromise = null; + }); + return this.shutdownPromise; + } + + private async doShutdownProcess(): Promise { + this.setReady(false); + this.activeTurn?.cancel(new Error('Pi runtime stopped')); + this.activeTurn?.queue.close(); + this.activeTurn = null; + this.extensionBridge?.cleanup(); + this.extensionBridge = null; + this.unregisterTransportClose?.(); + this.unregisterTransportClose = null; + this.transport?.dispose(); + this.transport = null; + const process = this.process; + this.process = null; + this.currentModel = null; + this.currentSessionTarget = null; + this.currentThinkingLevel = null; + if (process) { + await process.shutdown().catch(() => {}); + } + this.supportedCommands = []; + } + + private handleRpcEvent(event: PiRpcRecord): void { + if (this.extensionBridge?.handleRequest(event)) { + return; + } + + if (event.type === 'agent_end') { + this.activeTurn?.resolveTerminal(); + return; + } + + if (event.type === 'error') { + this.activeTurn?.queue.push({ + type: 'error', + content: typeof event.error === 'string' ? event.error : 'Pi runtime error.', + }); + this.activeTurn?.resolveTerminal(); + return; + } + + const state = this.getNormalizationState(); + const chunks = normalizePiRpcEvent(event, state); + for (const chunk of chunks) { + this.activeTurn?.queue.push(chunk); + } + if (getPiTerminalErrorMessage(event)) { + this.activeTurn?.resolveTerminal(); + } + } + + private normalizationState: PiEventNormalizationState = createPiEventNormalizationState(); + + private getNormalizationState(): PiEventNormalizationState { + if (!this.activeTurn) { + this.normalizationState = createPiEventNormalizationState(); + return this.normalizationState; + } + return this.normalizationState; + } + + private async applySelectedModel( + queryOptions?: ChatRuntimeQueryOptions, + conversationGeneration = this.conversationGeneration, + ): Promise { + if (!this.transport) { + return; + } + + const selectedModel = this.resolveSelectedModel(this.getProviderSettings(), queryOptions); + const payload = selectedModel ? buildPiSetModelPayload(selectedModel) : null; + if (!payload || this.currentModel === selectedModel) { + return; + } + + await this.transport.request('set_model', payload); + if (!this.isConversationCurrent(conversationGeneration)) { + return; + } + this.currentModel = selectedModel; + this.currentThinkingLevel = null; + } + + private async applySelectedThinkingLevel( + queryOptions?: ChatRuntimeQueryOptions, + conversationGeneration = this.conversationGeneration, + ): Promise { + if (!this.transport) { + return; + } + + const providerSettings = this.getProviderSettings(); + const selectedThinkingLevel = this.resolveSelectedThinkingLevel(providerSettings, queryOptions); + if (!selectedThinkingLevel || this.currentThinkingLevel === selectedThinkingLevel) { + return; + } + + await this.transport.request('set_thinking_level', { + level: selectedThinkingLevel, + }); + if (!this.isConversationCurrent(conversationGeneration)) { + return; + } + this.currentThinkingLevel = selectedThinkingLevel; + } + + private async refreshState( + conversationGeneration = this.conversationGeneration, + ): Promise { + if (!this.transport || this.transport.isClosed) { + return this.isConversationCurrent(conversationGeneration); + } + + const response = await this.transport.request('get_state', {}, 10_000); + if (!this.isConversationCurrent(conversationGeneration)) { + return false; + } + this.applyStateResponse(response); + return true; + } + + private async refreshStateAndSessionTarget( + conversationGeneration = this.conversationGeneration, + ): Promise { + try { + const refreshed = await this.refreshState(conversationGeneration); + if (refreshed === false || !this.isConversationCurrent(conversationGeneration)) { + return false; + } + this.currentSessionTarget = this.sessionFile ?? this.sessionId ?? null; + return true; + } catch { + // State refresh is opportunistic; the next turn can still proceed. + return this.isConversationCurrent(conversationGeneration); + } + } + + private applyStateResponse(response: unknown): void { + const state = extractStateRecord(response); + this.sessionId = getString(state.sessionId) + ?? getString(state.session_id) + ?? getString(getRecord(state.session)?.id) + ?? this.sessionId; + this.sessionFile = getString(state.sessionFile) + ?? getString(state.session_file) + ?? getString(state.sessionPath) + ?? getString(state.session_path) + ?? getString(state.path) + ?? this.sessionFile; + this.leafEntryId = getString(state.leafEntryId) + ?? getString(state.leaf_entry_id) + ?? this.leafEntryId; + this.parentSession = getString(state.parentSession) + ?? getString(state.parent_session) + ?? this.parentSession; + } + + private async fetchUsage(queryOptions?: ChatRuntimeQueryOptions): Promise { + if (!this.transport || this.transport.isClosed) { + return null; + } + + const providerSettings = this.getProviderSettings(); + const selectedModel = this.resolveSelectedModel(providerSettings, queryOptions); + const fallbackContextWindow = selectedModel + ? findPiModel(getPiProviderSettings(providerSettings), selectedModel)?.contextWindow + : undefined; + const response = await this.transport.request('get_session_stats', {}, 10_000); + return buildPiUsageInfo(response, selectedModel, fallbackContextWindow); + } + + private async materializePendingFork( + cwd: string, + runtimeEnvText: string, + conversationGeneration = this.conversationGeneration, + ): Promise { + const pendingFork = this.pendingFork; + if (!pendingFork) { + return true; + } + + const env = parseEnvironmentVariables(runtimeEnvText); + const sourceSessionFile = this.pendingForkSourceSessionFile + ?? findPiSessionFile( + pendingFork.sessionId, + cwd, + typeof env.PI_CODING_AGENT_SESSION_DIR === 'string' ? env.PI_CODING_AGENT_SESSION_DIR : null, + ); + if (!sourceSessionFile) { + throw new Error(`Pi fork source session not found: ${pendingFork.sessionId}`); + } + + const forkedSession = await createPiForkSessionFile( + sourceSessionFile, + pendingFork.resumeAt, + { targetCwd: cwd }, + ); + if (!this.isConversationCurrent(conversationGeneration)) { + return false; + } + this.sessionId = forkedSession.sessionId; + this.sessionFile = forkedSession.sessionFile; + this.leafEntryId = forkedSession.leafEntryId; + this.parentSession = forkedSession.parentSession; + this.pendingFork = null; + this.pendingForkSourceSessionFile = null; + this.sessionInvalidated = false; + this.currentSessionTarget = null; + return true; + } + + private async resolveCurrentLeafEntryId( + conversationGeneration = this.conversationGeneration, + ): Promise { + if (this.leafEntryId) { + return this.leafEntryId; + } + if (!this.sessionFile) { + return null; + } + + try { + const content = await fsp.readFile(this.sessionFile, 'utf-8'); + const entries = parsePiSessionEntries(content).entries; + const activePath = resolvePiActivePath(entries); + const leafEntryId = getLastPiEntryId(activePath); + if (!this.isConversationCurrent(conversationGeneration)) { + return null; + } + this.leafEntryId = leafEntryId; + return leafEntryId; + } catch { + return null; + } + } + + private async updateTurnMetadataFromSessionFile( + previousLeafEntryId: string | null, + conversationGeneration = this.conversationGeneration, + ): Promise { + if (!this.sessionFile) { + return; + } + + try { + const content = await fsp.readFile(this.sessionFile, 'utf-8'); + const entries = parsePiSessionEntries(content).entries; + const activePath = resolvePiActivePath(entries); + if ( + activePath.length === 0 + || !this.isConversationCurrent(conversationGeneration) + ) { + return; + } + + this.leafEntryId = getLastPiEntryId(activePath) ?? this.leafEntryId; + const previousLeafIndex = previousLeafEntryId + ? activePath.findIndex(entry => entry.id === previousLeafEntryId) + : -1; + const newEntries = previousLeafIndex >= 0 + ? activePath.slice(previousLeafIndex + 1) + : activePath; + const userEntry = previousLeafIndex >= 0 + ? newEntries.find(entry => getPiEntryRole(entry) === 'user') + : findLastPiEntryByRole(newEntries, 'user'); + const assistantEntry = findLastPiEntryByRole(newEntries, 'assistant'); + + if (userEntry?.id) { + this.currentTurnMetadata.userMessageId = userEntry.id; + } + if (assistantEntry?.id) { + this.currentTurnMetadata.assistantMessageId = assistantEntry.id; + } + } catch { + // Live checkpoint metadata is best-effort; hydration can still recover IDs later. + } + } + + private getSystemPromptSettings(vaultPath: string): SystemPromptSettings { + return { + customPrompt: this.plugin.settings.systemPrompt, + mediaFolder: this.plugin.settings.mediaFolder, + userName: this.plugin.settings.userName, + vaultPath, + }; + } + + private getProviderSettings(): Record { + const settings = ProviderSettingsCoordinator.getProviderSettingsSnapshot( + this.plugin.settings, + this.providerId, + ); + if (this.currentConversationModel) { + settings.model = this.currentConversationModel; + } + return settings; + } + + private setCurrentConversationModel(model: unknown): void { + const selectedModel = typeof model === 'string' ? model.trim() : ''; + this.currentConversationModel = selectedModel || null; + } + + private resolveSelectedModel( + providerSettings: Record, + queryOptions?: ChatRuntimeQueryOptions, + ): string | null { + const selectedModel = typeof queryOptions?.model === 'string' + ? queryOptions.model + : typeof providerSettings.model === 'string' + ? providerSettings.model + : ''; + + if (!selectedModel || selectedModel === PI_SYNTHETIC_MODEL_ID || !decodePiModelId(selectedModel)) { + return null; + } + + return selectedModel; + } + + private resolveSelectedThinkingLevel( + providerSettings: Record, + queryOptions?: ChatRuntimeQueryOptions, + ): string | null { + const selectedModel = this.resolveSelectedModel(providerSettings, queryOptions); + if (!selectedModel) { + return null; + } + + const piSettings = getPiProviderSettings(providerSettings); + const selectedThinkingLevel = typeof providerSettings.effortLevel === 'string' && providerSettings.effortLevel.trim() + ? providerSettings.effortLevel.trim() + : piSettings.preferredThinkingByModel[selectedModel]; + const selectedPiModel = findPiModel(piSettings, selectedModel); + if (selectedPiModel) { + return clampPiThinkingLevel(selectedThinkingLevel, selectedPiModel.thinkingLevels); + } + + return selectedThinkingLevel ?? null; + } + + private buildRuntimeEnv(runtimeEnvText: string): NodeJS.ProcessEnv { + return { + ...process.env, + ...parseEnvironmentVariables(runtimeEnvText), + }; + } + + private getCurrentProviderState(): PiProviderState { + if (this.pendingFork) { + return { + forkSource: this.pendingFork, + ...(this.pendingForkSourceSessionFile + ? { forkSourceSessionFile: this.pendingForkSourceSessionFile } + : {}), + }; + } + + return { + ...(this.leafEntryId ? { leafEntryId: this.leafEntryId } : {}), + ...(this.parentSession ? { parentSession: this.parentSession } : {}), + ...(this.sessionFile ? { sessionFile: this.sessionFile } : {}), + ...(this.sessionId ? { sessionId: this.sessionId } : {}), + }; + } + + private setReady(ready: boolean): void { + if (this.ready === ready) { + return; + } + + this.ready = ready; + for (const listener of this.readyListeners) { + listener(ready); + } + } + + private isLifecycleCurrent(generation: number): boolean { + return !this.disposed && generation === this.lifecycleGeneration; + } + + private isConversationCurrent(generation: number): boolean { + return generation === this.conversationGeneration; + } + + private isReadinessCurrent( + lifecycleGeneration: number, + conversationGeneration: number, + ): boolean { + return this.isLifecycleCurrent(lifecycleGeneration) + && this.isConversationCurrent(conversationGeneration); + } + + private formatRuntimeError(error: unknown): string { + const message = error instanceof Error ? error.message : 'Pi request failed'; + const stderr = this.process?.getStderrSnapshot(); + return stderr ? `${message}\n\n${stderr}` : message; + } + + private isSwitchableSessionFile(sessionFile: string | null): sessionFile is string { + return typeof sessionFile === 'string' + && sessionFile.trim().length > 0 + && path.isAbsolute(sessionFile); + } + + private async switchSession( + sessionFile: string, + launchSpec: PiLaunchSpec, + nextLaunchKey: string, + lifecycleGeneration = this.lifecycleGeneration, + conversationGeneration = this.conversationGeneration, + ): Promise { + try { + await this.transport!.request('switch_session', { sessionPath: sessionFile }); + if (!this.isReadinessCurrent(lifecycleGeneration, conversationGeneration)) { + return false; + } + this.currentLaunchKey = nextLaunchKey; + this.currentSessionTarget = sessionFile; + this.sessionInvalidated = false; + } catch { + if (!this.isReadinessCurrent(lifecycleGeneration, conversationGeneration)) { + return false; + } + await this.shutdownProcess(); + if (!this.isReadinessCurrent(lifecycleGeneration, conversationGeneration)) { + return false; + } + await this.startProcess(launchSpec); + if (!this.isReadinessCurrent(lifecycleGeneration, conversationGeneration)) { + await this.shutdownProcess(); + return false; + } + this.currentLaunchKey = nextLaunchKey; + this.currentSessionTarget = this.sessionFile ?? this.sessionId ?? null; + } + return true; + } +} + +function stripCompactCommand(text: string): string { + return text.trim().replace(/^\/compact(?:\s|$)/i, '').trim(); +} + +function isCompactCommand(text: string): boolean { + return /^\/compact(\s|$)/i.test(text); +} + +function normalizePiRuntimeCommands(response: unknown): SlashCommand[] { + const records = Array.isArray(response) + ? response + : Array.isArray(getRecord(response).commands) + ? getRecord(response).commands as unknown[] + : []; + const commands: SlashCommand[] = []; + const seen = new Set(); + + for (const record of records) { + const entry = getRecord(record); + const name = getString(entry.name)?.replace(/^\/+/, ''); + if (!name || seen.has(name.toLowerCase())) { + continue; + } + + seen.add(name.toLowerCase()); + const source = getString(entry.source); + commands.push({ + content: '', + description: getString(entry.description) ?? undefined, + id: `pi:${source ?? 'runtime'}:${name}`, + kind: source === 'skill' ? 'skill' : 'command', + name, + source: 'sdk', + }); + } + + return commands; +} + +function extractStateRecord(response: unknown): Record { + const record = getRecord(response); + return getRecord(record.state ?? record.session ?? response); +} + +function getPiEntryRole(entry: { message?: Record; type: string }): string | null { + const message = entry.message ?? {}; + const role = getString(message.role); + if (role) { + return role; + } + + if (entry.type === 'toolResult') { + return 'toolResult'; + } + return null; +} + +function getLastPiEntryId(entries: Array<{ id?: string }>): string | null { + for (let i = entries.length - 1; i >= 0; i--) { + const id = entries[i].id; + if (id) { + return id; + } + } + return null; +} + +function findLastPiEntryByRole; type: string }>( + entries: T[], + role: string, +): T | undefined { + for (let i = entries.length - 1; i >= 0; i--) { + if (getPiEntryRole(entries[i]) === role) { + return entries[i]; + } + } + return undefined; +} + +function getRecord(value: unknown): Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function getString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} diff --git a/src/providers/pi/runtime/PiCliResolver.ts b/src/providers/pi/runtime/PiCliResolver.ts new file mode 100644 index 0000000..50c2960 --- /dev/null +++ b/src/providers/pi/runtime/PiCliResolver.ts @@ -0,0 +1,53 @@ +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import { findCliBinaryPath, resolveConfiguredCliPath } from '../../../utils/cliBinaryLocator'; +import { getHostnameKey, parseEnvironmentVariables } from '../../../utils/env'; +import { getPiProviderSettings } from '../settings'; + +export class PiCliResolver { + private readonly cachedHostname = getHostnameKey(); + private lastCliPath = ''; + private lastEnvText = ''; + private lastHostnamePath = ''; + private resolvedPath: string | null = null; + + resolveFromSettings(settings: Record): string | null { + const piSettings = getPiProviderSettings(settings); + const cliPath = piSettings.cliPath.trim(); + const hostnamePath = (piSettings.cliPathsByHost[this.cachedHostname] ?? '').trim(); + const envText = getRuntimeEnvironmentText(settings, 'pi'); + + if ( + this.resolvedPath !== null + && cliPath === this.lastCliPath + && hostnamePath === this.lastHostnamePath + && envText === this.lastEnvText + ) { + return this.resolvedPath; + } + + this.lastCliPath = cliPath; + this.lastHostnamePath = hostnamePath; + this.lastEnvText = envText; + this.resolvedPath = this.resolve(piSettings.cliPathsByHost, cliPath, envText); + return this.resolvedPath; + } + + resolve( + hostnamePaths: Record | undefined, + legacyPath: string, + envText = '', + ): string | null { + const hostnamePath = (hostnamePaths?.[this.cachedHostname] ?? '').trim(); + const customEnv = parseEnvironmentVariables(envText || ''); + return resolveConfiguredCliPath(hostnamePath) + ?? resolveConfiguredCliPath(legacyPath.trim()) + ?? findCliBinaryPath('pi', customEnv.PATH); + } + + reset(): void { + this.lastCliPath = ''; + this.lastHostnamePath = ''; + this.lastEnvText = ''; + this.resolvedPath = null; + } +} diff --git a/src/providers/pi/runtime/PiExtensionUiBridge.ts b/src/providers/pi/runtime/PiExtensionUiBridge.ts new file mode 100644 index 0000000..1d91da1 --- /dev/null +++ b/src/providers/pi/runtime/PiExtensionUiBridge.ts @@ -0,0 +1,161 @@ +import type { StreamChunk } from '../../../core/types'; +import type { PiRpcRecord, PiRpcTransport } from './PiRpcTransport'; + +export interface PiExtensionUiSelectRequest extends PiRpcRecord { + id: string; +} + +export interface PiExtensionUiConfirmRequest extends PiRpcRecord { + id: string; +} + +export interface PiExtensionUiInputRequest extends PiRpcRecord { + id: string; +} + +export interface PiExtensionUiEditorRequest extends PiRpcRecord { + id: string; +} + +export type PiExtensionUiNotifyRequest = PiRpcRecord; +export type PiExtensionUiSetEditorTextRequest = PiRpcRecord; +export type PiExtensionUiSetStatusRequest = PiRpcRecord; +export type PiExtensionUiSetTitleRequest = PiRpcRecord; +export type PiExtensionUiSetWidgetRequest = PiRpcRecord; + +export interface PiExtensionUiRenderer { + confirm(request: PiExtensionUiConfirmRequest, signal: AbortSignal): Promise<{ cancelled?: boolean; confirmed?: boolean }>; + editor(request: PiExtensionUiEditorRequest, signal: AbortSignal): Promise<{ cancelled?: boolean; value?: string }>; + input(request: PiExtensionUiInputRequest, signal: AbortSignal): Promise<{ cancelled?: boolean; value?: string }>; + notify(request: PiExtensionUiNotifyRequest): void; + select(request: PiExtensionUiSelectRequest, signal: AbortSignal): Promise<{ cancelled?: boolean; value?: string }>; + setEditorText(request: PiExtensionUiSetEditorTextRequest): void; + setStatus(request: PiExtensionUiSetStatusRequest): void; + setTitle(request: PiExtensionUiSetTitleRequest): void; + setWidget(request: PiExtensionUiSetWidgetRequest): void; +} + +export class PiExtensionUiBridge { + private readonly pending = new Map(); + + constructor( + private readonly transport: PiRpcTransport, + private readonly renderer: PiExtensionUiRenderer | null, + private readonly emit?: (chunk: StreamChunk) => void, + ) {} + + handleRequest(request: PiRpcRecord): boolean { + if (request.type !== 'extension_ui_request') { + return false; + } + + const method = getString(request.method) ?? getString(request.action) ?? getString(request.uiType); + switch (method) { + case 'select': + this.handleDialog(request, (renderer, signal) => + renderer.select(requireDialogRequest(request), signal)); + return true; + case 'confirm': + this.handleDialog(request, (renderer, signal) => + renderer.confirm(requireDialogRequest(request), signal)); + return true; + case 'input': + this.handleDialog(request, (renderer, signal) => + renderer.input(requireDialogRequest(request), signal)); + return true; + case 'editor': + this.handleDialog(request, (renderer, signal) => + renderer.editor(requireDialogRequest(request), signal)); + return true; + case 'notify': + this.renderer?.notify(request); + this.emit?.({ + type: 'notice', + content: getString(request.message) ?? getString(request.title) ?? 'Pi extension notification.', + level: 'info', + }); + return true; + case 'setStatus': + case 'set_status': + this.renderer?.setStatus(request); + return true; + case 'setWidget': + case 'set_widget': + this.renderer?.setWidget(request); + return true; + case 'setTitle': + case 'set_title': + this.renderer?.setTitle(request); + return true; + case 'setEditorText': + case 'set_editor_text': + this.renderer?.setEditorText(request); + return true; + default: + this.sendCancellation(request); + return true; + } + } + + cleanup(): void { + for (const [id, controller] of this.pending) { + controller.abort(); + this.sendResponse(id, { cancelled: true }); + } + this.pending.clear(); + } + + private handleDialog( + request: PiRpcRecord, + render: ( + renderer: PiExtensionUiRenderer, + signal: AbortSignal, + ) => Promise>, + ): void { + const id = getString(request.id); + if (!id || !this.renderer) { + this.sendCancellation(request); + return; + } + + const controller = new AbortController(); + this.pending.set(id, controller); + render(this.renderer, controller.signal) + .then((response) => { + if (!controller.signal.aborted) { + this.sendResponse(id, response.cancelled ? { cancelled: true } : response); + } + }) + .catch(() => { + if (!controller.signal.aborted) { + this.sendResponse(id, { cancelled: true }); + } + }) + .finally(() => { + this.pending.delete(id); + }); + } + + private sendCancellation(request: PiRpcRecord): void { + const id = getString(request.id); + if (id) { + this.sendResponse(id, { cancelled: true }); + } + } + + private sendResponse(id: string, response: Record): void { + this.transport.send({ + id, + type: 'extension_ui_response', + ...response, + }); + } +} + +function requireDialogRequest(request: PiRpcRecord): T { + return request as T; +} + +function getString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} diff --git a/src/providers/pi/runtime/PiJsonl.ts b/src/providers/pi/runtime/PiJsonl.ts new file mode 100644 index 0000000..ae7a57f --- /dev/null +++ b/src/providers/pi/runtime/PiJsonl.ts @@ -0,0 +1,71 @@ +import type { Writable } from 'node:stream'; + +export type PiJsonlLineHandler = (line: string) => void; + +interface JsonlReadableStream { + off(eventName: 'data', listener: (chunk: Buffer | string) => void): unknown; + off(eventName: 'end' | 'close', listener: () => void): unknown; + off(eventName: 'error', listener: (error: unknown) => void): unknown; + on(eventName: 'data', listener: (chunk: Buffer | string) => void): unknown; + on(eventName: 'end' | 'close', listener: () => void): unknown; + on(eventName: 'error', listener: (error: unknown) => void): unknown; +} + +export function subscribePiJsonlLines( + input: JsonlReadableStream, + onLine: PiJsonlLineHandler, + onEnd?: () => void, + onError?: (error: Error) => void, +): () => void { + let buffer = ''; + + const handleData = (chunk: Buffer | string): void => { + buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8'); + + while (true) { + const newlineIndex = buffer.indexOf('\n'); + if (newlineIndex < 0) { + break; + } + + const line = stripTrailingCarriageReturn(buffer.slice(0, newlineIndex)); + buffer = buffer.slice(newlineIndex + 1); + onLine(line); + } + }; + + const handleEnd = (): void => { + if (buffer.length > 0) { + onLine(stripTrailingCarriageReturn(buffer)); + buffer = ''; + } + onEnd?.(); + }; + + const handleError = (error: unknown): void => { + onError?.(error instanceof Error ? error : new Error(String(error))); + }; + + input.on('data', handleData); + input.on('end', handleEnd); + input.on('close', handleEnd); + input.on('error', handleError); + + return () => { + input.off('data', handleData); + input.off('end', handleEnd); + input.off('close', handleEnd); + input.off('error', handleError); + }; +} + +export function writePiJsonl( + output: Writable | NodeJS.WritableStream, + record: unknown, +): void { + output.write(`${JSON.stringify(record)}\n`); +} + +function stripTrailingCarriageReturn(value: string): string { + return value.endsWith('\r') ? value.slice(0, -1) : value; +} diff --git a/src/providers/pi/runtime/PiLaunchSpec.ts b/src/providers/pi/runtime/PiLaunchSpec.ts new file mode 100644 index 0000000..039f7e7 --- /dev/null +++ b/src/providers/pi/runtime/PiLaunchSpec.ts @@ -0,0 +1,70 @@ +import { decodePiModelId, normalizePiThinkingLevel } from '../models'; +import type { PiProviderSettings } from '../settings'; +import type { PiProviderState } from '../types'; + +export interface BuildPiLaunchSpecParams { + command: string; + cwd: string; + env?: NodeJS.ProcessEnv; + envText?: string; + model?: string | null; + noSession?: boolean; + noTools?: boolean; + providerState?: PiProviderState | null; + settings: PiProviderSettings; + systemPrompt?: string; + thinkingLevel?: string | null; +} + +export interface PiLaunchSpec { + args: string[]; + command: string; + cwd: string; + env: NodeJS.ProcessEnv; + launchKey: string; +} + +const READONLY_TOOLS = 'read,grep,find,ls'; + +export function buildPiLaunchSpec(params: BuildPiLaunchSpecParams): PiLaunchSpec { + const args = ['--mode', 'rpc']; + const systemPrompt = params.systemPrompt?.trim(); + if (systemPrompt) { + args.push('--system-prompt', systemPrompt); + } + + if (params.noSession) { + args.push('--no-session'); + } else if (params.providerState?.sessionFile || params.providerState?.sessionId) { + args.push('--session', params.providerState.sessionFile ?? params.providerState.sessionId!); + } + + if (params.noTools) { + args.push('--no-tools'); + } else if (params.settings.toolMode === 'readonly') { + args.push('--tools', READONLY_TOOLS); + } + + const decodedModel = typeof params.model === 'string' ? decodePiModelId(params.model) : null; + if (decodedModel) { + args.push('--provider', decodedModel.provider, '--model', decodedModel.modelId); + } + + const thinkingLevel = normalizePiThinkingLevel(params.thinkingLevel); + if (thinkingLevel && thinkingLevel !== 'off') { + args.push('--thinking', thinkingLevel); + } + + return { + args, + command: params.command, + cwd: params.cwd, + env: params.env ?? process.env, + launchKey: JSON.stringify({ + args, + command: params.command, + cwd: params.cwd, + envText: params.envText ?? params.settings.environmentVariables, + }), + }; +} diff --git a/src/providers/pi/runtime/PiModelDiscoveryService.ts b/src/providers/pi/runtime/PiModelDiscoveryService.ts new file mode 100644 index 0000000..5786676 --- /dev/null +++ b/src/providers/pi/runtime/PiModelDiscoveryService.ts @@ -0,0 +1,92 @@ +import { getRuntimeEnvironmentText } from '../../../core/providers/providerEnvironment'; +import type { ProviderHost } from '../../../core/providers/ProviderHost'; +import { parseEnvironmentVariables } from '../../../utils/env'; +import { getVaultPath } from '../../../utils/path'; +import { + normalizePiDiscoveredModels, + type PiDiscoveredModel, +} from '../models'; +import { getPiProviderSettings } from '../settings'; +import { buildPiLaunchSpec } from './PiLaunchSpec'; +import { PiRpcTransport } from './PiRpcTransport'; +import { PiSubprocess } from './PiSubprocess'; + +export interface PiModelDiscoveryResult { + diagnostics?: string; + models: PiDiscoveredModel[]; +} + +export class PiModelDiscoveryService { + constructor(private readonly plugin: ProviderHost) {} + + async discoverModels(): Promise { + const settings = getPiProviderSettings(this.plugin.settings); + const cwd = getVaultPath(this.plugin.app) ?? process.cwd(); + const command = this.plugin.getResolvedProviderCliPath('pi') ?? 'pi'; + const envText = getRuntimeEnvironmentText(this.plugin.settings, 'pi'); + const env = { + ...process.env, + ...parseEnvironmentVariables(envText), + }; + const launchSpec = buildPiLaunchSpec({ + command, + cwd, + env, + envText, + noSession: true, + settings, + }); + const subprocess = new PiSubprocess(launchSpec); + let transport: PiRpcTransport | null = null; + let removeEventListener: (() => void) | null = null; + + try { + subprocess.start(); + transport = new PiRpcTransport({ + input: subprocess.stdout, + onClose: (listener) => subprocess.onClose(listener), + output: subprocess.stdin, + }); + transport.start(); + removeEventListener = transport.onEvent((event) => { + if (event.type !== 'extension_ui_request') { + return; + } + + const id = typeof event.id === 'string' && event.id.trim() ? event.id.trim() : ''; + if (id) { + transport?.send({ + cancelled: true, + id, + type: 'extension_ui_response', + }); + } + }); + const response = await transport.request('get_available_models', {}, 20_000); + const models = normalizePiDiscoveredModels(extractModels(response)); + return { models }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Pi model discovery failed'; + const stderr = subprocess.getStderrSnapshot(); + return { + diagnostics: stderr ? `${message}\n\n${stderr}` : message, + models: [], + }; + } finally { + removeEventListener?.(); + transport?.dispose(); + await subprocess.shutdown().catch(() => {}); + } + } +} + +function extractModels(response: unknown): unknown { + if (Array.isArray(response)) { + return response; + } + if (response && typeof response === 'object' && !Array.isArray(response)) { + const record = response as Record; + return record.models ?? record.availableModels ?? record.available_models ?? []; + } + return []; +} diff --git a/src/providers/pi/runtime/PiRpcPayloads.ts b/src/providers/pi/runtime/PiRpcPayloads.ts new file mode 100644 index 0000000..8f77f65 --- /dev/null +++ b/src/providers/pi/runtime/PiRpcPayloads.ts @@ -0,0 +1,18 @@ +import { decodePiModelId } from '../models'; + +export interface PiSetModelPayload extends Record { + modelId: string; + provider: string; +} + +export function buildPiSetModelPayload(model: string): PiSetModelPayload | null { + const decoded = decodePiModelId(model); + if (!decoded) { + return null; + } + + return { + modelId: decoded.modelId, + provider: decoded.provider, + }; +} diff --git a/src/providers/pi/runtime/PiRpcTransport.ts b/src/providers/pi/runtime/PiRpcTransport.ts new file mode 100644 index 0000000..6420207 --- /dev/null +++ b/src/providers/pi/runtime/PiRpcTransport.ts @@ -0,0 +1,243 @@ +import type { Readable, Writable } from 'node:stream'; + +import { subscribePiJsonlLines, writePiJsonl } from './PiJsonl'; + +const DEFAULT_TIMEOUT_MS = 30_000; + +export type PiRpcRecord = Record; +export type PiRpcEventHandler = (event: PiRpcRecord) => void; + +export interface PiRpcStreams { + input: Readable | NodeJS.ReadableStream; + onClose?: (listener: (error?: Error) => void) => () => void; + output: Writable | NodeJS.WritableStream; +} + +interface PendingRequest { + cleanup: () => void; + reject: (error: Error) => void; + resolve: (response: unknown) => void; + type: string; +} + +export class PiRpcTransportClosedError extends Error { + constructor(message = 'Pi RPC transport closed') { + super(message); + this.name = 'PiRpcTransportClosedError'; + } +} + +export class PiRpcResponseError extends Error { + constructor( + readonly commandType: string, + message: string, + ) { + super(message); + this.name = 'PiRpcResponseError'; + } +} + +export class PiRpcTransport { + private readonly closeListeners = new Set<(error?: Error) => void>(); + private disposed = false; + private readonly eventHandlers = new Set(); + private nextId = 1; + private readonly pending = new Map(); + private unregisterClose?: () => void; + private unsubscribeLines?: () => void; + + constructor( + private readonly streams: PiRpcStreams, + private readonly defaultTimeoutMs = DEFAULT_TIMEOUT_MS, + ) {} + + get isClosed(): boolean { + return this.disposed; + } + + start(): void { + if (this.unsubscribeLines || this.disposed) { + return; + } + + this.unsubscribeLines = subscribePiJsonlLines( + this.streams.input, + (line) => this.handleLine(line), + () => { + if (!this.disposed) { + this.dispose(new PiRpcTransportClosedError('Pi RPC input closed')); + } + }, + (error) => { + if (!this.disposed) { + this.dispose(error); + } + }, + ); + + this.unregisterClose = this.streams.onClose?.((error) => { + if (!this.disposed) { + this.dispose(error ?? new PiRpcTransportClosedError()); + } + }); + } + + onEvent(handler: PiRpcEventHandler): () => void { + this.eventHandlers.add(handler); + return () => { + this.eventHandlers.delete(handler); + }; + } + + onClose(listener: (error?: Error) => void): () => void { + this.closeListeners.add(listener); + return () => { + this.closeListeners.delete(listener); + }; + } + + request( + commandType: string, + payload: Record = {}, + timeoutMs = this.defaultTimeoutMs, + ): Promise { + this.start(); + if (this.disposed) { + return Promise.reject(new PiRpcTransportClosedError()); + } + + const id = `req_${this.nextId++}`; + return new Promise((resolve, reject) => { + let timer: number | undefined; + const cleanup = (): void => { + if (timer !== undefined) { + window.clearTimeout(timer); + } + }; + + if (timeoutMs > 0) { + timer = window.setTimeout(() => { + this.pending.delete(id); + cleanup(); + reject(new Error(`Request timeout: ${commandType} (${timeoutMs}ms)`)); + }, timeoutMs); + } + + this.pending.set(id, { + cleanup, + reject, + resolve: (response: unknown) => resolve(response as T), + type: commandType, + }); + + try { + this.sendRaw({ id, type: commandType, ...payload }); + } catch (error) { + this.pending.delete(id); + cleanup(); + const transportError = error instanceof Error ? error : new Error(String(error)); + this.dispose(transportError); + reject(transportError); + } + }); + } + + send(record: PiRpcRecord): void { + this.start(); + if (this.disposed) { + return; + } + this.sendRaw(record); + } + + dispose(error: Error = new PiRpcTransportClosedError('Pi RPC transport disposed')): void { + if (this.disposed) { + return; + } + + this.disposed = true; + this.unsubscribeLines?.(); + this.unsubscribeLines = undefined; + this.unregisterClose?.(); + this.unregisterClose = undefined; + this.rejectAllPending(error); + for (const listener of this.closeListeners) { + try { + listener(error); + } catch { + // Best-effort close notification. + } + } + this.closeListeners.clear(); + this.eventHandlers.clear(); + } + + private sendRaw(record: PiRpcRecord): void { + writePiJsonl(this.streams.output, record); + } + + private handleLine(line: string): void { + if (!line.trim()) { + return; + } + + let record: PiRpcRecord; + try { + const parsed = JSON.parse(line) as unknown; + if (!isPlainObject(parsed)) { + return; + } + record = parsed; + } catch { + return; + } + + if (record.type === 'response' && typeof record.id === 'string') { + this.handleResponse(record.id, record); + return; + } + + for (const handler of this.eventHandlers) { + handler(record); + } + } + + private handleResponse(id: string, record: PiRpcRecord): void { + const pending = this.pending.get(id); + if (!pending) { + return; + } + + this.pending.delete(id); + pending.cleanup(); + if (record.success === false) { + const errorText = typeof record.error === 'string' + ? record.error + : `Pi RPC command failed: ${pending.type}`; + pending.reject(new PiRpcResponseError(pending.type, errorText)); + return; + } + + if ('result' in record) { + pending.resolve(record.result); + return; + } + if ('data' in record) { + pending.resolve(record.data); + return; + } + pending.resolve(record); + } + + private rejectAllPending(error: Error): void { + for (const pending of this.pending.values()) { + pending.cleanup(); + pending.reject(error); + } + this.pending.clear(); + } +} + +function isPlainObject(value: unknown): value is PiRpcRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/providers/pi/runtime/PiSubprocess.ts b/src/providers/pi/runtime/PiSubprocess.ts new file mode 100644 index 0000000..211dc96 --- /dev/null +++ b/src/providers/pi/runtime/PiSubprocess.ts @@ -0,0 +1,164 @@ +import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'; +import * as path from 'node:path'; +import type { Readable, Writable } from 'node:stream'; + +import { getEnhancedPath } from '../../../utils/env'; +import { + resolveWindowsCmdShimSpawnSpec, + terminateSpawnedProcess, + type WindowsCmdShimSpawnSpec, +} from '../../../utils/windowsCmdShim'; + +const SIGKILL_TIMEOUT_MS = 3_000; +const FINAL_SHUTDOWN_TIMEOUT_MS = 3_000; +const STDERR_BUFFER_LIMIT = 8_000; + +export interface PiSubprocessLaunchSpec { + args: string[]; + command: string; + cwd: string; + env: NodeJS.ProcessEnv; +} + +type CloseListener = (error?: Error) => void; + +export class PiSubprocess { + private closeError: Error | null = null; + private readonly closeListeners = new Set(); + private notifiedClose = false; + private proc: ChildProcessWithoutNullStreams | null = null; + private resolvedSpawnSpec: WindowsCmdShimSpawnSpec | null = null; + private stderrBuffer = ''; + + constructor(private readonly launchSpec: PiSubprocessLaunchSpec) {} + + get stdin(): Writable { + return this.requireProc().stdin; + } + + get stdout(): Readable { + return this.requireProc().stdout; + } + + private requireProc(): ChildProcessWithoutNullStreams { + if (!this.proc) { + throw new Error('Pi subprocess is not started'); + } + return this.proc; + } + + start(): void { + if (this.proc) { + return; + } + + const resolvedSpawnSpec = resolveWindowsCmdShimSpawnSpec(this.launchSpec); + this.resolvedSpawnSpec = resolvedSpawnSpec; + const proc = spawn(resolvedSpawnSpec.command, resolvedSpawnSpec.args, { + cwd: this.launchSpec.cwd, + env: { + ...this.launchSpec.env, + PATH: getEnhancedPath( + this.launchSpec.env.PATH, + path.isAbsolute(this.launchSpec.command) ? this.launchSpec.command : undefined, + ), + }, + stdio: 'pipe', + windowsHide: true, + ...(resolvedSpawnSpec.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + }); + + proc.stderr.on('data', (chunk: Buffer | string) => { + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8'); + this.stderrBuffer = `${this.stderrBuffer}${text}`.slice(-STDERR_BUFFER_LIMIT); + }); + + proc.on('error', (error) => { + this.closeError = error; + this.notifyClose(error); + }); + + proc.on('exit', (code, signal) => { + const exitError = this.closeError ?? ( + code === 0 && signal === null + ? undefined + : new Error(`Pi subprocess exited (${formatExit(code, signal)})`) + ); + this.notifyClose(exitError); + }); + + this.proc = proc; + } + + isAlive(): boolean { + return this.proc !== null && this.proc.exitCode === null && !this.proc.killed; + } + + getStderrSnapshot(): string { + return this.stderrBuffer.trim(); + } + + onClose(listener: CloseListener): () => void { + this.closeListeners.add(listener); + return () => { + this.closeListeners.delete(listener); + }; + } + + async shutdown(): Promise { + if (!this.proc || this.proc.exitCode !== null) { + return; + } + + await new Promise((resolve) => { + const proc = this.proc!; + let killTimer: number | null = null; + let finalTimer: number | null = null; + const onClose = () => { + cleanup(); + resolve(); + }; + killTimer = window.setTimeout(() => { + this.killProc(proc, 'SIGKILL'); + finalTimer = window.setTimeout(onClose, FINAL_SHUTDOWN_TIMEOUT_MS); + }, SIGKILL_TIMEOUT_MS); + const cleanup = () => { + if (killTimer !== null) window.clearTimeout(killTimer); + if (finalTimer !== null) window.clearTimeout(finalTimer); + proc.off('exit', onClose); + }; + + proc.once('exit', onClose); + this.killProc(proc, 'SIGTERM'); + }); + } + + private killProc(proc: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): boolean { + return terminateSpawnedProcess(proc, signal, spawn, this.resolvedSpawnSpec); + } + + private notifyClose(error?: Error): void { + if (this.notifiedClose) { + return; + } + + this.notifiedClose = true; + for (const listener of this.closeListeners) { + try { + listener(error); + } catch { + // Best-effort cleanup notification. + } + } + } +} + +function formatExit(code: number | null, signal: string | null): string { + if (signal) { + return `signal ${signal}`; + } + if (code === null) { + return 'unknown'; + } + return `code ${code}`; +} diff --git a/src/providers/pi/runtime/buildPiPrompt.ts b/src/providers/pi/runtime/buildPiPrompt.ts new file mode 100644 index 0000000..e8f9d3c --- /dev/null +++ b/src/providers/pi/runtime/buildPiPrompt.ts @@ -0,0 +1,62 @@ +import type { ChatTurnRequest } from '../../../core/runtime/types'; +import type { ChatMessage, ImageAttachment } from '../../../core/types'; +import { appendBrowserContext } from '../../../utils/browser'; +import { appendCanvasContext } from '../../../utils/canvas'; +import { appendCurrentNote } from '../../../utils/context'; +import { appendEditorContext } from '../../../utils/editor'; +import { buildContextFromHistory, buildPromptWithHistoryContext } from '../../../utils/session'; + +export interface PiPromptImage { + data: string; + mimeType: string; + type: 'image'; +} + +export function buildPiPromptText( + request: ChatTurnRequest, + conversationHistory: ChatMessage[] = [], +): string { + let prompt = request.text; + + if (request.currentNotePath) { + prompt = appendCurrentNote(prompt, request.currentNotePath); + } + + if (request.editorSelection && request.editorSelection.mode !== 'none') { + prompt = appendEditorContext(prompt, request.editorSelection); + } + + if (request.browserSelection) { + prompt = appendBrowserContext(prompt, request.browserSelection); + } + + if (request.canvasSelection) { + prompt = appendCanvasContext(prompt, request.canvasSelection); + } + + if (conversationHistory.length > 0) { + const historyContext = buildContextFromHistory(conversationHistory); + prompt = buildPromptWithHistoryContext( + historyContext, + prompt, + prompt, + conversationHistory, + ); + } + + return prompt; +} + +export function buildPiPromptImages(images: ImageAttachment[] | undefined): PiPromptImage[] { + return (images ?? []).flatMap((image) => { + if (!image.data) { + return []; + } + + return [{ + data: image.data, + mimeType: image.mediaType, + type: 'image' as const, + }]; + }); +} diff --git a/src/providers/pi/runtime/buildPiUsageInfo.ts b/src/providers/pi/runtime/buildPiUsageInfo.ts new file mode 100644 index 0000000..6c1740b --- /dev/null +++ b/src/providers/pi/runtime/buildPiUsageInfo.ts @@ -0,0 +1,69 @@ +import type { UsageInfo } from '../../../core/types'; + +export function buildPiUsageInfo( + response: unknown, + model: string | null, + fallbackContextWindow = 200_000, +): UsageInfo | null { + const stats = getRecord(response); + const contextUsage = getRecord(stats.contextUsage ?? stats.context_usage ?? stats); + const providerContextWindow = getNumber(contextUsage.contextWindow) + ?? getNumber(contextUsage.context_window) + ?? getNumber(contextUsage.window); + const contextWindow = providerContextWindow ?? fallbackContextWindow; + const contextTokens = getNumber(contextUsage.contextTokens) + ?? getNumber(contextUsage.context_tokens) + ?? getNumber(contextUsage.tokens) + ?? getNumber(contextUsage.used) + ?? 0; + const inputTokens = getNumber(contextUsage.inputTokens) + ?? getNumber(contextUsage.input_tokens) + ?? contextTokens; + + if (contextTokens === 0 && inputTokens === 0) { + return null; + } + + return { + cacheCreationInputTokens: getNumber(contextUsage.cacheCreationInputTokens) + ?? getNumber(contextUsage.cache_creation_input_tokens) + ?? 0, + cacheReadInputTokens: getNumber(contextUsage.cacheReadInputTokens) + ?? getNumber(contextUsage.cache_read_input_tokens) + ?? 0, + contextTokens, + contextWindow, + contextWindowIsAuthoritative: providerContextWindow !== null, + inputTokens, + ...(model ? { model } : {}), + percentage: normalizePiUsagePercentage( + getNumber(contextUsage.percentage), + contextTokens, + contextWindow, + ), + }; +} + +function normalizePiUsagePercentage( + providerPercentage: number | null, + contextTokens: number, + contextWindow: number, +): number { + const rawPercentage = providerPercentage + ?? (contextWindow > 0 ? (contextTokens / contextWindow) * 100 : 0); + const wholePercentage = providerPercentage !== null && rawPercentage >= 0 && rawPercentage <= 1 + ? rawPercentage * 100 + : rawPercentage; + + return Math.min(100, Math.max(0, Math.round(wholePercentage))); +} + +function getRecord(value: unknown): Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; +} + +function getNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} diff --git a/src/providers/pi/settings.ts b/src/providers/pi/settings.ts new file mode 100644 index 0000000..d5bea5e --- /dev/null +++ b/src/providers/pi/settings.ts @@ -0,0 +1,473 @@ +import { getProviderConfig, setProviderConfig } from '../../core/providers/providerConfig'; +import { getProviderEnvironmentVariables } from '../../core/providers/providerEnvironment'; +import type { HostnameCliPaths } from '../../core/types/settings'; +import { + getHostnameKey, + getLegacyHostnameKey, + migrateLegacyHostnameKeyedMap, +} from '../../utils/env'; +import { ensureProviderProjectionMap } from './internal/providerProjection'; +import { + clampPiThinkingLevel, + decodePiModelId, + findPiModel, + isPiModelSelectionId, + normalizePiDiscoveredModels, + normalizePiThinkingLevel, + PI_DEFAULT_THINKING_LEVEL, + type PiDiscoveredModel, + type PiThinkingLevel, +} from './models'; + +export type PiToolMode = 'all' | 'readonly'; + +export interface PersistedPiProviderSettings { + cliPath: string; + cliPathsByHost: HostnameCliPaths; + discoveredModels: PiDiscoveredModel[]; + enabled: boolean; + environmentHash: string; + environmentVariables: string; + modelAliases: Record; + preferredThinkingByModel: Record; + toolMode: PiToolMode; + visibleModels: string[]; +} + +export type PiProviderSettings = PersistedPiProviderSettings; + +export const DEFAULT_PI_PROVIDER_SETTINGS: Readonly = Object.freeze({ + cliPath: '', + cliPathsByHost: {}, + discoveredModels: [], + enabled: false, + environmentHash: '', + environmentVariables: '', + modelAliases: {}, + preferredThinkingByModel: {}, + toolMode: 'all', + visibleModels: [], +}); + +function normalizeHostnameCliPaths(value: unknown): HostnameCliPaths { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const result: HostnameCliPaths = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof entry === 'string' && entry.trim()) { + result[key] = entry.trim(); + } + } + return result; +} + +export function normalizePiVisibleModels( + value: unknown, + discoveredModels: PiDiscoveredModel[] = [], +): string[] { + if (!Array.isArray(value)) { + return []; + } + + const knownIds = new Set(discoveredModels.map(model => model.encodedId)); + const normalized: string[] = []; + const seen = new Set(); + for (const entry of value) { + if (typeof entry !== 'string') { + continue; + } + + const trimmed = entry.trim(); + if (!trimmed || !decodePiModelId(trimmed)) { + continue; + } + if (knownIds.size > 0 && !knownIds.has(trimmed)) { + continue; + } + if (seen.has(trimmed)) { + continue; + } + + seen.add(trimmed); + normalized.push(trimmed); + } + + return normalized; +} + +export function normalizePiModelAliases( + value: unknown, + discoveredModels: PiDiscoveredModel[] = [], +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const normalized: Record = {}; + for (const [encodedId, alias] of Object.entries(value as Record)) { + if (typeof alias !== 'string') { + continue; + } + + const normalizedEncodedId = normalizePiEncodedId(encodedId, discoveredModels); + const normalizedAlias = alias.trim(); + if (!normalizedEncodedId || !normalizedAlias) { + continue; + } + + normalized[normalizedEncodedId] = normalizedAlias; + } + + return normalized; +} + +export function normalizePiPreferredThinkingByModel( + value: unknown, + discoveredModels: PiDiscoveredModel[] = [], +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const normalized: Record = {}; + for (const [encodedId, thinkingLevel] of Object.entries(value as Record)) { + const normalizedEncodedId = normalizePiEncodedId(encodedId, discoveredModels); + const normalizedThinkingLevel = normalizePiThinkingLevel(thinkingLevel); + if (!normalizedEncodedId || !normalizedThinkingLevel) { + continue; + } + + const discoveredModel = discoveredModels.find(model => model.encodedId === normalizedEncodedId); + if (discoveredModel && !discoveredModel.thinkingLevels.includes(normalizedThinkingLevel)) { + continue; + } + + normalized[normalizedEncodedId] = normalizedThinkingLevel; + } + + return normalized; +} + +export function getPiProviderSettings(settings: Record): PiProviderSettings { + const config = getProviderConfig(settings, 'pi'); + const normalizedCliPathsByHost = normalizeHostnameCliPaths(config.cliPathsByHost); + const cliPathsByHost = Object.keys(normalizedCliPathsByHost).length > 0 + ? migrateLegacyHostnameKeyedMap( + normalizedCliPathsByHost, + getHostnameKey(), + getLegacyHostnameKey(), + ) + : normalizedCliPathsByHost; + const discoveredModels = normalizePiDiscoveredModels(config.discoveredModels); + const visibleModels = normalizePiVisibleModels(config.visibleModels, discoveredModels); + const persistableIds = getPersistablePiModelIds(settings, visibleModels); + + return { + cliPath: (config.cliPath as string | undefined) + ?? DEFAULT_PI_PROVIDER_SETTINGS.cliPath, + cliPathsByHost, + discoveredModels, + enabled: (config.enabled as boolean | undefined) + ?? DEFAULT_PI_PROVIDER_SETTINGS.enabled, + environmentHash: (config.environmentHash as string | undefined) + ?? DEFAULT_PI_PROVIDER_SETTINGS.environmentHash, + environmentVariables: (config.environmentVariables as string | undefined) + ?? getProviderEnvironmentVariables(settings, 'pi') + ?? DEFAULT_PI_PROVIDER_SETTINGS.environmentVariables, + modelAliases: normalizePiModelAliasesForPersistableIds( + config.modelAliases, + discoveredModels, + persistableIds, + ), + preferredThinkingByModel: normalizePiPreferredThinkingForPersistableIds( + config.preferredThinkingByModel, + discoveredModels, + persistableIds, + ), + toolMode: normalizePiToolMode(config.toolMode), + visibleModels, + }; +} + +export function updatePiProviderSettings( + settings: Record, + updates: Partial, +): PiProviderSettings { + const current = getPiProviderSettings(settings); + const hostnameKey = getHostnameKey(); + const nextDiscoveredModels = normalizePiDiscoveredModels( + updates.discoveredModels ?? current.discoveredModels, + ); + const nextVisibleModels = normalizePiVisibleModels( + updates.visibleModels ?? current.visibleModels, + nextDiscoveredModels, + ); + const persistableIds = getPersistablePiModelIds(settings, nextVisibleModels); + const nextModelAliases = pruneMapToPersistableIds( + normalizePiModelAliasesForPersistableIds( + updates.modelAliases ?? current.modelAliases, + nextDiscoveredModels, + persistableIds, + ), + persistableIds, + ); + const nextPreferredThinkingByModel = pruneMapToPersistableIds( + normalizePiPreferredThinkingForPersistableIds( + updates.preferredThinkingByModel ?? current.preferredThinkingByModel, + nextDiscoveredModels, + persistableIds, + ), + persistableIds, + ); + const nextCliPathsByHost = 'cliPathsByHost' in updates + ? normalizeHostnameCliPaths(updates.cliPathsByHost) + : { ...current.cliPathsByHost }; + let nextCliPath = 'cliPathsByHost' in updates + ? ( + typeof updates.cliPath === 'string' + ? updates.cliPath.trim() + : DEFAULT_PI_PROVIDER_SETTINGS.cliPath + ) + : current.cliPath.trim(); + + if ('cliPath' in updates && !('cliPathsByHost' in updates)) { + const trimmedCliPath = typeof updates.cliPath === 'string' ? updates.cliPath.trim() : ''; + if (trimmedCliPath) { + nextCliPathsByHost[hostnameKey] = trimmedCliPath; + } else { + delete nextCliPathsByHost[hostnameKey]; + } + nextCliPath = DEFAULT_PI_PROVIDER_SETTINGS.cliPath; + } + + const next: PiProviderSettings = { + ...current, + ...updates, + cliPath: nextCliPath, + cliPathsByHost: nextCliPathsByHost, + discoveredModels: nextDiscoveredModels, + modelAliases: nextModelAliases, + preferredThinkingByModel: nextPreferredThinkingByModel, + toolMode: normalizePiToolMode(updates.toolMode ?? current.toolMode), + visibleModels: nextVisibleModels, + }; + + if (updates.visibleModels !== undefined) { + retargetRemovedPiSelections(settings, next); + const retargetedPersistableIds = getPersistablePiModelIds(settings, next.visibleModels); + next.modelAliases = pruneMapToPersistableIds(next.modelAliases, retargetedPersistableIds); + next.preferredThinkingByModel = pruneMapToPersistableIds( + next.preferredThinkingByModel, + retargetedPersistableIds, + ); + } + + setProviderConfig(settings, 'pi', { + cliPath: next.cliPath, + cliPathsByHost: next.cliPathsByHost, + discoveredModels: next.discoveredModels, + enabled: next.enabled, + environmentHash: next.environmentHash, + environmentVariables: next.environmentVariables, + modelAliases: next.modelAliases, + preferredThinkingByModel: next.preferredThinkingByModel, + toolMode: next.toolMode, + visibleModels: next.visibleModels, + }); + + return next; +} + +function normalizePiModelAliasesForPersistableIds( + value: unknown, + discoveredModels: PiDiscoveredModel[], + persistableIds: Set, +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const normalized: Record = {}; + for (const [encodedId, alias] of Object.entries(value as Record)) { + if (typeof alias !== 'string') { + continue; + } + + const normalizedEncodedId = normalizePiPersistableEncodedId( + encodedId, + discoveredModels, + persistableIds, + ); + const normalizedAlias = alias.trim(); + if (!normalizedEncodedId || !normalizedAlias) { + continue; + } + + normalized[normalizedEncodedId] = normalizedAlias; + } + + return normalized; +} + +function normalizePiPreferredThinkingForPersistableIds( + value: unknown, + discoveredModels: PiDiscoveredModel[], + persistableIds: Set, +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const normalized: Record = {}; + for (const [encodedId, thinkingLevel] of Object.entries(value as Record)) { + const normalizedEncodedId = normalizePiPersistableEncodedId( + encodedId, + discoveredModels, + persistableIds, + ); + const normalizedThinkingLevel = normalizePiThinkingLevel(thinkingLevel); + if (!normalizedEncodedId || !normalizedThinkingLevel) { + continue; + } + + const discoveredModel = discoveredModels.find(model => model.encodedId === normalizedEncodedId); + if (discoveredModel && !discoveredModel.thinkingLevels.includes(normalizedThinkingLevel)) { + continue; + } + + normalized[normalizedEncodedId] = normalizedThinkingLevel; + } + + return normalized; +} + +export function resolvePiModelAlias( + settings: PiProviderSettings, + encodedId: string, +): string | null { + return settings.modelAliases[encodedId] ?? null; +} + +function normalizePiToolMode(value: unknown): PiToolMode { + return value === 'readonly' ? 'readonly' : 'all'; +} + +function normalizePiEncodedId( + value: string, + discoveredModels: PiDiscoveredModel[], +): string { + const trimmed = value.trim(); + const decoded = decodePiModelId(trimmed); + if (!decoded) { + return ''; + } + + if (discoveredModels.length === 0) { + return trimmed; + } + + const discoveredModel = findPiModel({ discoveredModels }, trimmed); + return discoveredModel ? discoveredModel.encodedId : ''; +} + +function normalizePiPersistableEncodedId( + value: string, + discoveredModels: PiDiscoveredModel[], + persistableIds: Set, +): string { + const trimmed = value.trim(); + const decoded = decodePiModelId(trimmed); + if (!decoded) { + return ''; + } + + const discoveredModel = findPiModel({ discoveredModels }, trimmed); + if (discoveredModel) { + return discoveredModel.encodedId; + } + + return persistableIds.has(trimmed) ? trimmed : ''; +} + +function getPersistablePiModelIds( + settings: Record, + visibleModels: string[], +): Set { + const persistableIds = new Set(visibleModels); + addPersistableSelection(persistableIds, settings.model); + addPersistableSelection(persistableIds, settings.titleGenerationModel); + + const savedProviderModel = settings.savedProviderModel; + if (savedProviderModel && typeof savedProviderModel === 'object' && !Array.isArray(savedProviderModel)) { + addPersistableSelection(persistableIds, (savedProviderModel as Record).pi); + } + + return persistableIds; +} + +function addPersistableSelection(target: Set, value: unknown): void { + if (typeof value === 'string' && decodePiModelId(value)) { + target.add(value); + } +} + +function pruneMapToPersistableIds( + value: Record, + persistableIds: Set, +): Record { + const pruned: Record = {}; + for (const [encodedId, entry] of Object.entries(value)) { + if (persistableIds.has(encodedId)) { + pruned[encodedId] = entry; + } + } + return pruned; +} + +function retargetRemovedPiSelections( + settings: Record, + next: PiProviderSettings, +): void { + if (next.visibleModels.length === 0) { + if (typeof settings.titleGenerationModel === 'string' && isPiModelSelectionId(settings.titleGenerationModel)) { + settings.titleGenerationModel = ''; + } + return; + } + + const visibleSet = new Set(next.visibleModels); + const fallbackModelId = next.visibleModels[0]; + const fallbackModel = findPiModel(next, fallbackModelId); + const fallbackEffort = next.preferredThinkingByModel[fallbackModelId] + ?? (fallbackModel + ? clampPiThinkingLevel(PI_DEFAULT_THINKING_LEVEL, fallbackModel.thinkingLevels) + : PI_DEFAULT_THINKING_LEVEL); + + const maybeRetargetModel = (value: unknown): string | null => { + if (typeof value !== 'string' || !isPiModelSelectionId(value) || value === 'pi') { + return null; + } + + return visibleSet.has(value) ? null : fallbackModelId; + }; + + const savedProviderModel = ensureProviderProjectionMap(settings, 'savedProviderModel'); + const nextSavedModel = maybeRetargetModel(savedProviderModel.pi); + if (nextSavedModel) { + savedProviderModel.pi = nextSavedModel; + ensureProviderProjectionMap(settings, 'savedProviderEffort').pi = fallbackEffort; + } + + const nextTopLevelModel = maybeRetargetModel(settings.model); + if (nextTopLevelModel) { + settings.model = nextTopLevelModel; + settings.effortLevel = fallbackEffort; + } + + const nextTitleGenerationModel = maybeRetargetModel(settings.titleGenerationModel); + if (nextTitleGenerationModel) { + settings.titleGenerationModel = nextTitleGenerationModel; + } +} diff --git a/src/providers/pi/types.ts b/src/providers/pi/types.ts new file mode 100644 index 0000000..0bef003 --- /dev/null +++ b/src/providers/pi/types.ts @@ -0,0 +1,64 @@ +export interface PiForkSource { + resumeAt: string; + sessionId: string; +} + +export interface PiProviderState { + forkSource?: PiForkSource; + forkSourceSessionFile?: string; + leafEntryId?: string; + parentSession?: string; + sessionFile?: string; + sessionId?: string; +} + +export function getPiState(value: unknown): PiProviderState { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const record = value as Record; + const forkSource = getPiForkSource(record.forkSource); + return { + ...(forkSource ? { forkSource } : {}), + ...(typeof record.forkSourceSessionFile === 'string' && record.forkSourceSessionFile.trim() + ? { forkSourceSessionFile: record.forkSourceSessionFile.trim() } + : {}), + ...(typeof record.leafEntryId === 'string' && record.leafEntryId.trim() + ? { leafEntryId: record.leafEntryId.trim() } + : {}), + ...(typeof record.parentSession === 'string' && record.parentSession.trim() + ? { parentSession: record.parentSession.trim() } + : {}), + ...(typeof record.sessionFile === 'string' && record.sessionFile.trim() + ? { sessionFile: record.sessionFile.trim() } + : {}), + ...(typeof record.sessionId === 'string' && record.sessionId.trim() + ? { sessionId: record.sessionId.trim() } + : {}), + }; +} + +export function buildPersistedPiState(state: PiProviderState): PiProviderState | undefined { + const persisted: PiProviderState = { + ...(state.forkSource ? { forkSource: state.forkSource } : {}), + ...(state.forkSourceSessionFile ? { forkSourceSessionFile: state.forkSourceSessionFile } : {}), + ...(state.leafEntryId ? { leafEntryId: state.leafEntryId } : {}), + ...(state.parentSession ? { parentSession: state.parentSession } : {}), + ...(state.sessionFile ? { sessionFile: state.sessionFile } : {}), + ...(state.sessionId ? { sessionId: state.sessionId } : {}), + }; + + return Object.keys(persisted).length > 0 ? persisted : undefined; +} + +function getPiForkSource(value: unknown): PiForkSource | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const record = value as Record; + const sessionId = typeof record.sessionId === 'string' ? record.sessionId.trim() : ''; + const resumeAt = typeof record.resumeAt === 'string' ? record.resumeAt.trim() : ''; + return sessionId && resumeAt ? { resumeAt, sessionId } : undefined; +} diff --git a/src/providers/pi/ui/ObsidianPiExtensionUiRenderer.ts b/src/providers/pi/ui/ObsidianPiExtensionUiRenderer.ts new file mode 100644 index 0000000..27d2579 --- /dev/null +++ b/src/providers/pi/ui/ObsidianPiExtensionUiRenderer.ts @@ -0,0 +1,251 @@ +import { type App, Modal, Notice } from 'obsidian'; + +import type { + PiExtensionUiConfirmRequest, + PiExtensionUiEditorRequest, + PiExtensionUiInputRequest, + PiExtensionUiNotifyRequest, + PiExtensionUiRenderer, + PiExtensionUiSelectRequest, + PiExtensionUiSetEditorTextRequest, + PiExtensionUiSetStatusRequest, + PiExtensionUiSetTitleRequest, + PiExtensionUiSetWidgetRequest, +} from './PiExtensionUiRenderer'; + +export class ObsidianPiExtensionUiRenderer implements PiExtensionUiRenderer { + constructor(private readonly app: App) {} + + async select( + request: PiExtensionUiSelectRequest, + signal: AbortSignal, + ): Promise<{ cancelled?: boolean; value?: string }> { + return new PiSelectModal(this.app, request, signal).openAndWait(); + } + + async confirm( + request: PiExtensionUiConfirmRequest, + signal: AbortSignal, + ): Promise<{ cancelled?: boolean; confirmed?: boolean }> { + return new PiConfirmModal(this.app, request, signal).openAndWait(); + } + + async input( + request: PiExtensionUiInputRequest, + signal: AbortSignal, + ): Promise<{ cancelled?: boolean; value?: string }> { + return new PiTextModal(this.app, request, signal, false).openAndWait(); + } + + async editor( + request: PiExtensionUiEditorRequest, + signal: AbortSignal, + ): Promise<{ cancelled?: boolean; value?: string }> { + return new PiTextModal(this.app, request, signal, true).openAndWait(); + } + + notify(request: PiExtensionUiNotifyRequest): void { + new Notice(getDisplayText(request)); + } + + setStatus(_request: PiExtensionUiSetStatusRequest): void {} + setWidget(_request: PiExtensionUiSetWidgetRequest): void {} + setTitle(_request: PiExtensionUiSetTitleRequest): void {} + setEditorText(_request: PiExtensionUiSetEditorTextRequest): void {} +} + +function getDisplayText(request: Record): string { + const message = typeof request.message === 'string' ? request.message.trim() : ''; + const title = typeof request.title === 'string' ? request.title.trim() : ''; + return message || title || 'Pi extension notification.'; +} + +function getTitle(request: Record, fallback: string): string { + return typeof request.title === 'string' && request.title.trim() + ? request.title.trim() + : fallback; +} + +function getMessage(request: Record): string { + return typeof request.message === 'string' ? request.message.trim() : ''; +} + +interface PiSelectOption { + label: string; + value: string; +} + +function getSelectOptions(request: Record): PiSelectOption[] { + const rawOptions = Array.isArray(request.options) ? request.options : []; + return rawOptions.flatMap((option): PiSelectOption[] => { + if (typeof option === 'string' && option.trim()) { + return [{ label: option.trim(), value: option.trim() }]; + } + if (!option || typeof option !== 'object' || Array.isArray(option)) { + return []; + } + + const record = option as Record; + const value = typeof record.value === 'string' ? record.value.trim() : ''; + const label = typeof record.label === 'string' && record.label.trim() + ? record.label.trim() + : value; + return value ? [{ label, value }] : []; + }); +} + +abstract class PiExtensionModal> extends Modal { + private done = false; + private resolve!: (result: TResult) => void; + private readonly resultPromise = new Promise((resolve) => { + this.resolve = resolve; + }); + + constructor( + app: App, + protected readonly request: Record, + private readonly signal: AbortSignal, + ) { + super(app); + } + + openAndWait(): Promise { + if (this.signal.aborted) { + return Promise.resolve(this.cancelledResult()); + } + + const abortHandler = (): void => { + this.finish(this.cancelledResult()); + this.close(); + }; + this.signal.addEventListener('abort', abortHandler, { once: true }); + void this.resultPromise.finally(() => { + this.signal.removeEventListener('abort', abortHandler); + }); + this.open(); + return this.resultPromise; + } + + override onOpen(): void { + this.contentEl.empty(); + this.contentEl.addClass('claudian-pi-extension-modal'); + this.render(); + } + + override onClose(): void { + this.finish(this.cancelledResult()); + } + + protected abstract cancelledResult(): TResult; + protected abstract render(): void; + + protected finish(result: TResult): void { + if (this.done) { + return; + } + + this.done = true; + this.resolve(result); + } + + protected renderHeader(fallbackTitle: string): void { + this.contentEl.createEl('h2', { text: getTitle(this.request, fallbackTitle) }); + const message = getMessage(this.request); + if (message) { + this.contentEl.createEl('p', { text: message }); + } + } +} + +class PiSelectModal extends PiExtensionModal<{ cancelled?: boolean; value?: string }> { + protected cancelledResult(): { cancelled: true } { + return { cancelled: true }; + } + + protected render(): void { + this.renderHeader('Pi extension'); + const options = getSelectOptions(this.request); + const listEl = this.contentEl.createDiv({ cls: 'claudian-pi-extension-options' }); + for (const option of options) { + const button = listEl.createEl('button', { text: option.label }); + button.addEventListener('click', () => { + this.finish({ value: option.value }); + this.close(); + }); + } + this.renderCancelButton(); + } + + private renderCancelButton(): void { + const cancelButton = this.contentEl.createEl('button', { text: 'Cancel' }); + cancelButton.addEventListener('click', () => { + this.finish({ cancelled: true }); + this.close(); + }); + } +} + +class PiConfirmModal extends PiExtensionModal<{ cancelled?: boolean; confirmed?: boolean }> { + protected cancelledResult(): { cancelled: true } { + return { cancelled: true }; + } + + protected render(): void { + this.renderHeader('Pi extension'); + const actionsEl = this.contentEl.createDiv({ cls: 'claudian-pi-extension-actions' }); + const confirmButton = actionsEl.createEl('button', { text: 'Confirm' }); + confirmButton.addEventListener('click', () => { + this.finish({ confirmed: true }); + this.close(); + }); + const cancelButton = actionsEl.createEl('button', { text: 'Cancel' }); + cancelButton.addEventListener('click', () => { + this.finish({ confirmed: false }); + this.close(); + }); + } +} + +class PiTextModal extends PiExtensionModal<{ cancelled?: boolean; value?: string }> { + constructor( + app: App, + request: Record, + signal: AbortSignal, + private readonly multiline: boolean, + ) { + super(app, request, signal); + } + + protected cancelledResult(): { cancelled: true } { + return { cancelled: true }; + } + + protected render(): void { + this.renderHeader('Pi extension'); + const initialValue = typeof this.request.value === 'string' + ? this.request.value + : typeof this.request.defaultValue === 'string' + ? this.request.defaultValue + : ''; + const input = this.multiline + ? this.contentEl.createEl('textarea') + : this.contentEl.createEl('input', { type: 'text' }); + input.value = initialValue; + if (this.multiline) { + (input as HTMLTextAreaElement).rows = 8; + } + + const actionsEl = this.contentEl.createDiv({ cls: 'claudian-pi-extension-actions' }); + const submitButton = actionsEl.createEl('button', { text: 'Submit' }); + submitButton.addEventListener('click', () => { + this.finish({ value: input.value }); + this.close(); + }); + const cancelButton = actionsEl.createEl('button', { text: 'Cancel' }); + cancelButton.addEventListener('click', () => { + this.finish({ cancelled: true }); + this.close(); + }); + window.setTimeout(() => input.focus(), 0); + } +} diff --git a/src/providers/pi/ui/PiChatUIConfig.ts b/src/providers/pi/ui/PiChatUIConfig.ts new file mode 100644 index 0000000..1d2961c --- /dev/null +++ b/src/providers/pi/ui/PiChatUIConfig.ts @@ -0,0 +1,265 @@ +import type { + ProviderChatUIConfig, + ProviderPermissionModeToggleConfig, + ProviderReasoningOption, + ProviderUIOption, +} from '../../../core/providers/types'; +import { PI_PROVIDER_ICON } from '../../../shared/icons'; +import { + clampPiThinkingLevel, + decodePiModelId, + getPiSupportedThinkingLevels, + isPiModelSelectionId, + PI_DEFAULT_THINKING_LEVEL, + PI_SYNTHETIC_MODEL_ID, + type PiDiscoveredModel, + type PiThinkingLevel, +} from '../models'; +import { + getPiProviderSettings, + updatePiProviderSettings, +} from '../settings'; + +const PI_MODELS: ProviderUIOption[] = [ + { value: PI_SYNTHETIC_MODEL_ID, label: 'Pi', description: 'Configure models in settings' }, +]; +const DEFAULT_PI_REASONING_LEVELS = getPiSupportedThinkingLevels({ reasoning: true }); +const DEFAULT_CONTEXT_WINDOW = 200_000; +const PI_PERMISSION_MODE_TOGGLE: ProviderPermissionModeToggleConfig = { + inactiveValue: 'normal', + inactiveLabel: 'Read-only', + activeValue: 'yolo', + activeLabel: 'All tools', +}; + +export const piChatUIConfig: ProviderChatUIConfig = { + getModelOptions(settings): ProviderUIOption[] { + const piSettings = getPiProviderSettings(settings); + const discoveredModels = new Map(piSettings.discoveredModels.map((model) => [ + model.encodedId, + buildModelOption(model, piSettings.modelAliases[model.encodedId]), + ])); + const savedProviderModel = ( + settings.savedProviderModel + && typeof settings.savedProviderModel === 'object' + && !Array.isArray(settings.savedProviderModel) + ) + ? settings.savedProviderModel as Record + : null; + + const options: ProviderUIOption[] = []; + const seen = new Set(); + for (const encodedId of piSettings.visibleModels) { + pushOption( + options, + seen, + encodedId, + discoveredModels.get(encodedId) + ?? { + description: 'Configured model', + label: piSettings.modelAliases[encodedId] ?? formatFallbackLabel(encodedId), + value: encodedId, + }, + ); + } + + const selectedModelValues = [ + typeof settings.model === 'string' ? settings.model : '', + typeof savedProviderModel?.pi === 'string' ? savedProviderModel.pi : '', + ]; + + for (const model of selectedModelValues) { + if (!model || model === PI_SYNTHETIC_MODEL_ID || !decodePiModelId(model)) { + continue; + } + + pushOption( + options, + seen, + model, + discoveredModels.get(model) + ?? { + description: 'Selected in an existing session', + label: piSettings.modelAliases[model] ?? formatFallbackLabel(model), + value: model, + }, + ); + } + + return options.length > 0 ? options : [...PI_MODELS]; + }, + + ownsModel(model: string): boolean { + return model === PI_SYNTHETIC_MODEL_ID || decodePiModelId(model) !== null; + }, + + isAdaptiveReasoningModel(model: string, settings: Record): boolean { + const piModel = getCachedModel(model, settings); + if (piModel) { + return piModel.thinkingLevels.some(level => level !== 'off'); + } + + return !!decodePiModelId(model); + }, + + getReasoningOptions(model: string, settings: Record): ProviderReasoningOption[] { + const piModel = getCachedModel(model, settings); + const levels = piModel?.thinkingLevels + ?? (decodePiModelId(model) ? DEFAULT_PI_REASONING_LEVELS : ['off']); + return levels.map((level) => ({ + label: formatThinkingLevelLabel(level), + value: level, + })); + }, + + getDefaultReasoningValue: getPiDefaultReasoningValue, + + getContextWindowSize( + model: string, + customLimits?: Record, + settings?: Record, + ): number { + const metadataContextWindow = settings + ? getCachedModel(model, settings)?.contextWindow + : undefined; + return metadataContextWindow ?? customLimits?.[model] ?? DEFAULT_CONTEXT_WINDOW; + }, + + isDefaultModel(model: string): boolean { + return isPiModelSelectionId(model); + }, + + applyModelDefaults(model: string, settings: unknown): void { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + return; + } + + const settingsBag = settings as Record; + if (!decodePiModelId(model)) { + settingsBag.effortLevel = 'off'; + return; + } + + settingsBag.model = model; + settingsBag.effortLevel = getPiDefaultReasoningValue(model, settingsBag); + }, + + applyReasoningSelection(model: string, value: string, settings: unknown): void { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + return; + } + + const settingsBag = settings as Record; + const piModel = getCachedModel(model, settingsBag); + const encodedId = piModel?.encodedId ?? (decodePiModelId(model) ? model : ''); + if (!encodedId) { + return; + } + const supportedLevels = piModel?.thinkingLevels ?? DEFAULT_PI_REASONING_LEVELS; + + const nextPreferredThinkingByModel = { + ...getPiProviderSettings(settingsBag).preferredThinkingByModel, + }; + const normalizedValue = value as PiThinkingLevel; + if (!supportedLevels.includes(normalizedValue)) { + delete nextPreferredThinkingByModel[encodedId]; + } else { + nextPreferredThinkingByModel[encodedId] = normalizedValue; + } + + updatePiProviderSettings(settingsBag, { + preferredThinkingByModel: nextPreferredThinkingByModel, + }); + }, + + normalizeModelVariant(model: string): string { + return decodePiModelId(model) ? model : model; + }, + + getCustomModelIds(): Set { + return new Set(); + }, + + getModeSelector(): null { + return null; + }, + + getPermissionModeToggle(): ProviderPermissionModeToggleConfig { + return PI_PERMISSION_MODE_TOGGLE; + }, + + resolvePermissionMode(settings: Record): string | null { + return getPiProviderSettings(settings).toolMode === 'readonly' ? 'normal' : 'yolo'; + }, + + applyPermissionMode(value: string, settings: unknown): void { + if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { + return; + } + + const settingsBag = settings as Record; + settingsBag.permissionMode = value; + updatePiProviderSettings(settingsBag, { + toolMode: value === 'normal' ? 'readonly' : 'all', + }); + }, + + getProviderIcon() { + return PI_PROVIDER_ICON; + }, +}; + +function getCachedModel(model: string, settings: Record): PiDiscoveredModel | null { + if (!decodePiModelId(model)) { + return null; + } + + return getPiProviderSettings(settings).discoveredModels.find(entry => entry.encodedId === model) ?? null; +} + +function getPiDefaultReasoningValue(model: string, settings: Record): string { + const piModel = getCachedModel(model, settings); + if (!piModel) { + return decodePiModelId(model) ? PI_DEFAULT_THINKING_LEVEL : 'off'; + } + + const piSettings = getPiProviderSettings(settings); + return clampPiThinkingLevel( + piSettings.preferredThinkingByModel[piModel.encodedId], + piModel.thinkingLevels, + ); +} + +function buildModelOption(model: PiDiscoveredModel, alias: string | undefined): ProviderUIOption { + return { + description: `${model.provider} runtime`, + group: model.provider, + label: alias ?? model.label, + value: model.encodedId, + }; +} + +function formatFallbackLabel(encodedId: string): string { + const decoded = decodePiModelId(encodedId); + return decoded ? `${decoded.provider}/${decoded.modelId}` : 'Pi'; +} + +function formatThinkingLevelLabel(value: PiThinkingLevel): string { + return value === 'xhigh' + ? 'XHigh' + : value.charAt(0).toUpperCase() + value.slice(1); +} + +function pushOption( + target: ProviderUIOption[], + seenValues: Set, + value: string, + option: ProviderUIOption, +): void { + if (seenValues.has(value)) { + return; + } + + seenValues.add(value); + target.push(option); +} diff --git a/src/providers/pi/ui/PiExtensionUiRenderer.ts b/src/providers/pi/ui/PiExtensionUiRenderer.ts new file mode 100644 index 0000000..ebc58e5 --- /dev/null +++ b/src/providers/pi/ui/PiExtensionUiRenderer.ts @@ -0,0 +1,12 @@ +export type { + PiExtensionUiConfirmRequest, + PiExtensionUiEditorRequest, + PiExtensionUiInputRequest, + PiExtensionUiNotifyRequest, + PiExtensionUiRenderer, + PiExtensionUiSelectRequest, + PiExtensionUiSetEditorTextRequest, + PiExtensionUiSetStatusRequest, + PiExtensionUiSetTitleRequest, + PiExtensionUiSetWidgetRequest, +} from '../runtime/PiExtensionUiBridge'; diff --git a/src/providers/pi/ui/PiSettingsTab.ts b/src/providers/pi/ui/PiSettingsTab.ts new file mode 100644 index 0000000..f8bbb68 --- /dev/null +++ b/src/providers/pi/ui/PiSettingsTab.ts @@ -0,0 +1,296 @@ +import * as fs from 'node:fs'; + +import { Notice, Setting } from 'obsidian'; + +import type { + ProviderSettingsTabRenderer, + ProviderSettingsTabRendererContext, +} from '../../../core/providers/types'; +import { renderEnvironmentSettingsSection } from '../../../shared/settings/EnvironmentSettingsSection'; +import { + type ProviderModelPickerModel, + type ProviderModelPickerState, + renderProviderModelPicker, +} from '../../../shared/settings/ProviderModelPicker'; +import { getHostnameKey } from '../../../utils/env'; +import { expandHomePath } from '../../../utils/path'; +import { maybeGetPiWorkspaceServices } from '../app/PiWorkspaceServices'; +import { sameStringList } from '../internal/compareCollections'; +import { decodePiModelId, type PiDiscoveredModel } from '../models'; +import { PiModelDiscoveryService } from '../runtime/PiModelDiscoveryService'; +import { + getPiProviderSettings, + normalizePiVisibleModels, + updatePiProviderSettings, +} from '../settings'; + +export const piSettingsTabRenderer: ProviderSettingsTabRenderer = { + render(container, context) { + const settingsBag = context.plugin.settings as unknown as Record; + const piSettings = getPiProviderSettings(settingsBag); + const hostnameKey = getHostnameKey(); + const workspace = maybeGetPiWorkspaceServices(); + + new Setting(container).setName('Setup').setHeading(); + + new Setting(container) + .setName('Enable Pi') + .setDesc('Launch `pi --mode rpc` as a provider.') + .addToggle((toggle) => + toggle + .setValue(piSettings.enabled) + .onChange(async (value) => { + await context.plugin.mutateSettings((settings) => { + updatePiProviderSettings(settings, { enabled: value }); + }); + context.refreshModelSelectors(); + }) + ); + + const validationEl = container.createDiv({ + cls: 'claudian-cli-path-validation claudian-setting-validation claudian-setting-validation-error claudian-hidden', + }); + const cliPathsByHost = { ...piSettings.cliPathsByHost }; + let cliPathInputEl: HTMLInputElement | null = null; + + const updateCliPathValidation = (value: string, inputEl?: HTMLInputElement): boolean => { + const error = validateCliPath(value); + if (error) { + validationEl.setText(error); + validationEl.toggleClass('claudian-hidden', false); + inputEl?.toggleClass('claudian-input-error', true); + return false; + } + + validationEl.toggleClass('claudian-hidden', true); + inputEl?.toggleClass('claudian-input-error', false); + return true; + }; + + const persistCliPath = async (value: string): Promise => { + if (!updateCliPathValidation(value, cliPathInputEl ?? undefined)) { + return; + } + + const trimmed = value.trim(); + if (trimmed) { + cliPathsByHost[hostnameKey] = trimmed; + } else { + delete cliPathsByHost[hostnameKey]; + } + + await context.plugin.mutateSettings((settings) => { + updatePiProviderSettings(settings, { + cliPathsByHost: { ...cliPathsByHost }, + discoveredModels: [], + }); + workspace?.cliResolver?.reset(); + }); + context.refreshModelSelectors(); + }; + + new Setting(container) + .setName('CLI path') + .setDesc('Optional absolute path to the Pi CLI for this computer. Leave empty to use `pi` from PATH.') + .addText((text) => { + const currentValue = piSettings.cliPathsByHost[hostnameKey] || ''; + text + .setPlaceholder(process.platform === 'win32' + ? 'C:\\Users\\you\\AppData\\Roaming\\npm\\pi.cmd' + : '/usr/local/bin/pi') + .setValue(currentValue) + .onChange((value) => { + void persistCliPath(value); + }); + cliPathInputEl = text.inputEl; + updateCliPathValidation(currentValue, text.inputEl); + }); + + new Setting(container).setName('Models').setHeading(); + renderPiModelPicker(container, context, settingsBag); + + renderEnvironmentSettingsSection({ + container, + desc: 'Environment variables passed only to Pi.', + heading: 'Environment', + name: 'Pi environment variables', + placeholder: 'PI_CODING_AGENT_SESSION_DIR=/path/to/sessions', + plugin: context.plugin, + scope: 'provider:pi', + }); + }, +}; + +function renderPiModelPicker( + container: HTMLElement, + context: ProviderSettingsTabRendererContext, + settingsBag: Record, +): void { + const getState = (): ProviderModelPickerState => { + const current = getPiProviderSettings(settingsBag); + return { + aliases: current.modelAliases, + discoveredCount: current.discoveredModels.length, + models: buildPiPickerModels(current.discoveredModels, current.visibleModels), + selectedIds: current.visibleModels, + }; + }; + + renderProviderModelPicker({ + container, + emptyCatalogText: 'No Pi models discovered yet. Click Discover to load models from Pi.', + failedCatalogText: 'Could not load the Pi model catalog. Check the CLI path and login state, then try again.', + getState, + async loadCatalog() { + const result = await new PiModelDiscoveryService(context.plugin).discoverModels(); + if (result.diagnostics) { + new Notice(`Pi discovery failed: ${result.diagnostics}`); + return 'failed'; + } + + const current = getPiProviderSettings(settingsBag); + const normalizedVisibleModels = normalizePiVisibleModels(current.visibleModels, result.models); + const shouldPersist = result.models.length > 0 + || current.discoveredModels.length > 0 + || !sameStringList(current.visibleModels, normalizedVisibleModels); + if (shouldPersist) { + await context.plugin.mutateSettings((settings) => { + updatePiProviderSettings(settings, { + discoveredModels: result.models, + visibleModels: normalizedVisibleModels, + }); + }); + context.refreshModelSelectors(); + } + return result.models.length > 0 ? 'loaded' : 'empty'; + }, + loadingCatalogText: 'Loading Pi model catalog...', + modifier: 'pi', + async onAliasesChange(modelAliases) { + await context.plugin.mutateSettings((settings) => { + updatePiProviderSettings(settings, { modelAliases }); + }); + context.refreshModelSelectors(); + }, + async onSelectedIdsChange(visibleModels) { + const current = getPiProviderSettings(settingsBag); + const normalized = normalizePiVisibleModels(visibleModels, current.discoveredModels); + if (sameStringList(current.visibleModels, normalized)) { + return; + } + + await context.plugin.mutateSettings((settings) => { + updatePiProviderSettings(settings, { visibleModels: normalized }); + }); + context.refreshModelSelectors(); + }, + providerName: 'Pi', + settingDescription: 'Choose which Pi models appear in the chat selector. Filter by provider or type to search. The current session model stays pinned even if it is not selected here.', + }); +} + +function validateCliPath(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const expandedPath = expandHomePath(trimmed); + if (!fs.existsSync(expandedPath)) { + return 'Path does not exist'; + } + + if (!fs.statSync(expandedPath).isFile()) { + return 'Path must point to a file'; + } + + return null; +} + +function buildPiPickerModels( + discoveredModels: PiDiscoveredModel[], + visibleModels: string[], +): ProviderModelPickerModel[] { + const models: ProviderModelPickerModel[] = []; + const discoveredIds = new Set(); + + for (const model of discoveredModels) { + discoveredIds.add(model.encodedId); + models.push({ + description: buildPiModelDescription(model), + id: model.encodedId, + isAvailable: true, + name: model.label || model.id, + providerKey: model.provider.toLowerCase(), + providerLabel: formatProviderLabel(model.provider), + }); + } + + for (const encodedId of visibleModels) { + if (discoveredIds.has(encodedId)) { + continue; + } + + const decoded = decodePiModelId(encodedId); + const provider = decoded?.provider ?? 'pi'; + models.push({ + description: 'Configured model', + id: encodedId, + isAvailable: false, + name: decoded?.modelId ?? encodedId, + providerKey: provider.toLowerCase(), + providerLabel: formatProviderLabel(provider), + unavailableMessage: 'Not currently reported by Pi', + }); + } + + return models.sort((left, right) => { + const providerCmp = (left.providerLabel ?? '').localeCompare(right.providerLabel ?? ''); + if (providerCmp !== 0) { + return providerCmp; + } + return left.name.localeCompare(right.name); + }); +} + +function buildPiModelDescription(model: PiDiscoveredModel): string { + const details: string[] = []; + if (model.api) { + details.push(`API: ${model.api}`); + } + if (model.contextWindow) { + details.push(`${model.contextWindow.toLocaleString()} context`); + } + if (model.maxTokens) { + details.push(`${model.maxTokens.toLocaleString()} output`); + } + if (model.input.includes('image')) { + details.push('image input'); + } + details.push(model.reasoning + ? `thinking: ${model.thinkingLevels.join(', ')}` + : 'thinking: off'); + + return details.join(' | '); +} + +function formatProviderLabel(provider: string): string { + const normalized = provider.trim(); + const knownProviders: Record = { + anthropic: 'Anthropic', + deepseek: 'DeepSeek', + google: 'Google', + openai: 'OpenAI', + xai: 'xAI', + }; + const known = knownProviders[normalized.toLowerCase()]; + if (known) { + return known; + } + + return normalized + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' ') || 'Pi'; +} diff --git a/src/shared/components/ResumeSessionDropdown.ts b/src/shared/components/ResumeSessionDropdown.ts new file mode 100644 index 0000000..7f6ad2e --- /dev/null +++ b/src/shared/components/ResumeSessionDropdown.ts @@ -0,0 +1,185 @@ +/** + * Claudian - Resume session dropdown + * + * Dropup UI for selecting a previous conversation to resume. + * Shown when the /resume built-in command is executed. + */ + +import { setIcon } from 'obsidian'; + +import type { ConversationMeta } from '../../core/types'; + +export interface ResumeSessionDropdownCallbacks { + onSelect: (conversationId: string) => void; + onDismiss: () => void; +} + +export class ResumeSessionDropdown { + private containerEl: HTMLElement; + private inputEl: HTMLTextAreaElement; + private dropdownEl: HTMLElement; + private callbacks: ResumeSessionDropdownCallbacks; + private conversations: ConversationMeta[]; + private currentConversationId: string | null; + private selectedIndex = 0; + private onInput: () => void; + + constructor( + containerEl: HTMLElement, + inputEl: HTMLTextAreaElement, + conversations: ConversationMeta[], + currentConversationId: string | null, + callbacks: ResumeSessionDropdownCallbacks + ) { + this.containerEl = containerEl; + this.inputEl = inputEl; + this.conversations = this.sortConversations(conversations); + this.currentConversationId = currentConversationId; + this.callbacks = callbacks; + + this.dropdownEl = this.containerEl.createDiv({ cls: 'claudian-resume-dropdown' }); + this.render(); + this.dropdownEl.addClass('visible'); + + // Auto-dismiss when user starts typing + this.onInput = () => this.dismiss(); + this.inputEl.addEventListener('input', this.onInput); + } + + handleKeydown(e: KeyboardEvent): boolean { + if (!this.isVisible()) return false; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + this.navigate(1); + return true; + case 'ArrowUp': + e.preventDefault(); + this.navigate(-1); + return true; + case 'Enter': + case 'Tab': + if (this.conversations.length > 0) { + e.preventDefault(); + this.selectItem(); + return true; + } + return false; + case 'Escape': + e.preventDefault(); + this.dismiss(); + return true; + } + return false; + } + + isVisible(): boolean { + return this.dropdownEl?.hasClass('visible') ?? false; + } + + destroy(): void { + this.inputEl.removeEventListener('input', this.onInput); + this.dropdownEl?.remove(); + } + + private dismiss(): void { + this.dropdownEl.removeClass('visible'); + this.callbacks.onDismiss(); + } + + private selectItem(): void { + if (this.conversations.length === 0) return; + const selected = this.conversations[this.selectedIndex]; + if (!selected) return; + + // Dismiss without switching if selecting the current conversation + if (selected.id === this.currentConversationId) { + this.dismiss(); + return; + } + + this.callbacks.onSelect(selected.id); + } + + private navigate(direction: number): void { + const maxIndex = this.conversations.length - 1; + this.selectedIndex = Math.max(0, Math.min(maxIndex, this.selectedIndex + direction)); + this.updateSelection(); + } + + private updateSelection(): void { + const items = this.dropdownEl.querySelectorAll('.claudian-resume-item'); + items?.forEach((item, index) => { + if (index === this.selectedIndex) { + item.addClass('selected'); + (item as HTMLElement).scrollIntoView({ block: 'nearest' }); + } else { + item.removeClass('selected'); + } + }); + } + + private sortConversations(conversations: ConversationMeta[]): ConversationMeta[] { + return [...conversations].sort((a, b) => { + return (b.lastResponseAt ?? b.createdAt) - (a.lastResponseAt ?? a.createdAt); + }); + } + + private render(): void { + this.dropdownEl.empty(); + + const header = this.dropdownEl.createDiv({ cls: 'claudian-resume-header' }); + header.createSpan({ text: 'Resume conversation' }); + + if (this.conversations.length === 0) { + this.dropdownEl.createDiv({ cls: 'claudian-resume-empty', text: 'No conversations' }); + return; + } + + const list = this.dropdownEl.createDiv({ cls: 'claudian-resume-list' }); + + for (let i = 0; i < this.conversations.length; i++) { + const conv = this.conversations[i]; + const isCurrent = conv.id === this.currentConversationId; + + const item = list.createDiv({ cls: 'claudian-resume-item' }); + if (isCurrent) item.addClass('current'); + if (i === this.selectedIndex) item.addClass('selected'); + + const iconEl = item.createDiv({ cls: 'claudian-resume-item-icon' }); + setIcon(iconEl, isCurrent ? 'message-square-dot' : 'message-square'); + + const content = item.createDiv({ cls: 'claudian-resume-item-content' }); + const titleEl = content.createDiv({ cls: 'claudian-resume-item-title', text: conv.title }); + titleEl.setAttribute('title', conv.title); + content.createDiv({ + cls: 'claudian-resume-item-date', + text: isCurrent ? 'Current session' : this.formatDate(conv.lastResponseAt ?? conv.createdAt), + }); + + item.addEventListener('click', () => { + if (isCurrent) { + this.dismiss(); + return; + } + this.callbacks.onSelect(conv.id); + }); + + item.addEventListener('mouseenter', () => { + this.selectedIndex = i; + this.updateSelection(); + }); + } + } + + private formatDate(timestamp: number): string { + const date = new Date(timestamp); + const now = new Date(); + + if (date.toDateString() === now.toDateString()) { + return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }); + } + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } +} diff --git a/src/shared/components/SelectableDropdown.ts b/src/shared/components/SelectableDropdown.ts new file mode 100644 index 0000000..572f62b --- /dev/null +++ b/src/shared/components/SelectableDropdown.ts @@ -0,0 +1,140 @@ +export interface SelectableDropdownOptions { + listClassName: string; + itemClassName: string; + emptyClassName: string; + fixed?: boolean; + fixedClassName?: string; +} + +export interface SelectableDropdownRenderOptions { + items: T[]; + selectedIndex: number; + emptyText: string; + renderItem: (item: T, itemEl: HTMLElement) => void; + getItemClass?: (item: T) => string | string[] | undefined; + onItemClick?: (item: T, index: number, e: MouseEvent) => void; + onItemHover?: (item: T, index: number) => void; +} + +export class SelectableDropdown { + private containerEl: HTMLElement; + private dropdownEl: HTMLElement | null = null; + private options: SelectableDropdownOptions; + private items: T[] = []; + private itemEls: HTMLElement[] = []; + private selectedIndex = 0; + + constructor(containerEl: HTMLElement, options: SelectableDropdownOptions) { + this.containerEl = containerEl; + this.options = options; + } + + isVisible(): boolean { + return this.dropdownEl?.hasClass('visible') ?? false; + } + + getElement(): HTMLElement | null { + return this.dropdownEl; + } + + getSelectedIndex(): number { + return this.selectedIndex; + } + + getSelectedItem(): T | null { + return this.items[this.selectedIndex] ?? null; + } + + getItems(): T[] { + return this.items; + } + + hide(): void { + if (this.dropdownEl) { + this.dropdownEl.removeClass('visible'); + } + } + + destroy(): void { + if (this.dropdownEl) { + this.dropdownEl.remove(); + this.dropdownEl = null; + } + } + + render(options: SelectableDropdownRenderOptions): void { + this.items = options.items; + this.selectedIndex = options.selectedIndex; + + if (!this.dropdownEl) { + this.dropdownEl = this.createDropdownElement(); + } + + this.dropdownEl.empty(); + this.itemEls = []; + + if (options.items.length === 0) { + const emptyEl = this.dropdownEl.createDiv({ cls: this.options.emptyClassName }); + emptyEl.setText(options.emptyText); + } else { + for (let i = 0; i < options.items.length; i++) { + const item = options.items[i]; + const itemEl = this.dropdownEl.createDiv({ cls: this.options.itemClassName }); + + const extraClass = options.getItemClass?.(item); + if (Array.isArray(extraClass)) { + extraClass.forEach(cls => itemEl.addClass(cls)); + } else if (extraClass) { + itemEl.addClass(extraClass); + } + + if (i === this.selectedIndex) { + itemEl.addClass('selected'); + } + + options.renderItem(item, itemEl); + + itemEl.addEventListener('click', (e) => { + this.selectedIndex = i; + this.updateSelection(); + options.onItemClick?.(item, i, e); + }); + + itemEl.addEventListener('mouseenter', () => { + this.selectedIndex = i; + this.updateSelection(); + options.onItemHover?.(item, i); + }); + + this.itemEls.push(itemEl); + } + } + + this.dropdownEl.addClass('visible'); + } + + updateSelection(): void { + this.itemEls.forEach((itemEl, index) => { + if (index === this.selectedIndex) { + itemEl.addClass('selected'); + itemEl.scrollIntoView({ block: 'nearest' }); + } else { + itemEl.removeClass('selected'); + } + }); + } + + moveSelection(delta: number): void { + const maxIndex = this.items.length - 1; + this.selectedIndex = Math.max(0, Math.min(maxIndex, this.selectedIndex + delta)); + this.updateSelection(); + } + + private createDropdownElement(): HTMLElement { + const className = this.options.fixed && this.options.fixedClassName + ? `${this.options.listClassName} ${this.options.fixedClassName}` + : this.options.listClassName; + + return this.containerEl.createDiv({ cls: className }); + } +} diff --git a/src/shared/components/SelectionHighlight.ts b/src/shared/components/SelectionHighlight.ts new file mode 100644 index 0000000..2e15a88 --- /dev/null +++ b/src/shared/components/SelectionHighlight.ts @@ -0,0 +1,77 @@ +/** + * SelectionHighlight - Shared CM6 selection highlight for chat and inline edit + * + * Provides a reusable mechanism to highlight selected text in the editor + * when focus moves elsewhere (e.g., to an input field). + */ + +import { RangeSetBuilder, StateEffect, StateField } from '@codemirror/state'; +import type { DecorationSet } from '@codemirror/view'; +import { Decoration, EditorView } from '@codemirror/view'; + +export interface SelectionHighlighter { + show: (editorView: EditorView, from: number, to: number) => void; + hide: (editorView: EditorView) => void; +} + +function createSelectionHighlighter(): SelectionHighlighter { + const showHighlight = StateEffect.define<{ from: number; to: number }>(); + const hideHighlight = StateEffect.define(); + + const selectionHighlightField = StateField.define({ + create: () => Decoration.none, + update: (deco, tr) => { + for (const e of tr.effects) { + if (e.is(showHighlight)) { + const builder = new RangeSetBuilder(); + builder.add(e.value.from, e.value.to, Decoration.mark({ + class: 'claudian-selection-highlight', + })); + return builder.finish(); + } else if (e.is(hideHighlight)) { + return Decoration.none; + } + } + return deco.map(tr.changes); + }, + provide: (f) => EditorView.decorations.from(f), + }); + + const installedEditors = new WeakSet(); + + function ensureHighlightField(editorView: EditorView): void { + if (!installedEditors.has(editorView)) { + editorView.dispatch({ + effects: StateEffect.appendConfig.of(selectionHighlightField), + }); + installedEditors.add(editorView); + } + } + + function show(editorView: EditorView, from: number, to: number): void { + ensureHighlightField(editorView); + editorView.dispatch({ + effects: showHighlight.of({ from, to }), + }); + } + + function hide(editorView: EditorView): void { + if (installedEditors.has(editorView)) { + editorView.dispatch({ + effects: hideHighlight.of(null), + }); + } + } + + return { show, hide }; +} + +const defaultHighlighter = createSelectionHighlighter(); + +export function showSelectionHighlight(editorView: EditorView, from: number, to: number): void { + defaultHighlighter.show(editorView, from, to); +} + +export function hideSelectionHighlight(editorView: EditorView): void { + defaultHighlighter.hide(editorView); +} diff --git a/src/shared/components/SlashCommandDropdown.ts b/src/shared/components/SlashCommandDropdown.ts new file mode 100644 index 0000000..b6a54e1 --- /dev/null +++ b/src/shared/components/SlashCommandDropdown.ts @@ -0,0 +1,421 @@ +import { getBuiltInCommandsForDropdown } from '../../core/commands/builtInCommands'; +import type { ProviderCommandDropdownConfig } from '../../core/providers/commands/ProviderCommandCatalog'; +import type { ProviderCommandEntry } from '../../core/providers/commands/ProviderCommandEntry'; +import type { SlashCommand } from '../../core/types'; +import { normalizeArgumentHint } from '../../utils/slashCommand'; + +interface DropdownItem { + name: string; + description?: string; + argumentHint?: string; + content: string; + displayPrefix: string; + insertPrefix: string; + isBuiltIn: boolean; + slashCommand?: SlashCommand; + providerEntry?: ProviderCommandEntry; +} + +export interface SlashCommandDropdownCallbacks { + onSelect: (command: SlashCommand) => void; + onHide: () => void; +} + +export interface SlashCommandDropdownOptions { + fixed?: boolean; + hiddenCommands?: Set; + providerConfig?: ProviderCommandDropdownConfig; + getProviderEntries?: () => Promise; +} + +export class SlashCommandDropdown { + private containerEl: HTMLElement; + private dropdownEl: HTMLElement | null = null; + private inputEl: HTMLTextAreaElement | HTMLInputElement; + private callbacks: SlashCommandDropdownCallbacks; + private enabled = true; + private onInput: () => void; + private triggerStartIndex = -1; + private activeTriggerChar = '/'; + private selectedIndex = 0; + private filteredItems: DropdownItem[] = []; + private isFixed: boolean; + private hiddenCommands: Set; + + private providerConfig: ProviderCommandDropdownConfig | null; + private getProviderEntries: (() => Promise) | null; + private cachedProviderEntries: ProviderCommandEntry[] = []; + private providerEntriesFetched = false; + + private requestId = 0; + + constructor( + containerEl: HTMLElement, + inputEl: HTMLTextAreaElement | HTMLInputElement, + callbacks: SlashCommandDropdownCallbacks, + options: SlashCommandDropdownOptions = {} + ) { + this.containerEl = containerEl; + this.inputEl = inputEl; + this.callbacks = callbacks; + this.isFixed = options.fixed ?? false; + this.hiddenCommands = options.hiddenCommands ?? new Set(); + this.providerConfig = options.providerConfig ?? null; + this.getProviderEntries = options.getProviderEntries ?? null; + + this.onInput = () => this.handleInputChange(); + this.inputEl.addEventListener('input', this.onInput); + } + + setEnabled(enabled: boolean): void { + this.enabled = enabled; + if (!enabled) { + this.hide(); + } + } + + setHiddenCommands(commands: Set): void { + this.hiddenCommands = commands; + } + + setProviderCatalog( + config: ProviderCommandDropdownConfig, + getEntries: () => Promise, + ): void { + this.providerConfig = config; + this.getProviderEntries = getEntries; + this.cachedProviderEntries = []; + this.providerEntriesFetched = false; + this.requestId = 0; + } + + handleInputChange(): void { + if (!this.enabled) return; + + const text = this.getInputValue(); + const cursorPos = this.getCursorPosition(); + const textBeforeCursor = text.substring(0, cursorPos); + const triggerChars = this.providerConfig?.triggerChars ?? ['/']; + + // Scan backward from cursor for the nearest valid trigger char. + // Valid trigger: at position 0, or preceded by whitespace. + let triggerIndex = -1; + let triggerChar = ''; + + for (let i = cursorPos - 1; i >= 0; i--) { + const ch = textBeforeCursor.charAt(i); + if (/\s/.test(ch)) break; + if (triggerChars.includes(ch)) { + if (i === 0 || /\s/.test(textBeforeCursor.charAt(i - 1))) { + triggerIndex = i; + triggerChar = ch; + } + break; + } + } + + if (triggerIndex === -1) { + this.hide(); + return; + } + + const searchText = textBeforeCursor.substring(triggerIndex + 1); + + if (/\s/.test(searchText)) { + this.hide(); + return; + } + + this.triggerStartIndex = triggerIndex; + this.activeTriggerChar = triggerChar; + const isAtPosition0 = triggerIndex === 0; + void this.showDropdown(searchText, isAtPosition0); + } + + handleKeydown(e: KeyboardEvent): boolean { + if (!this.enabled || !this.isVisible()) return false; + + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + this.navigate(1); + return true; + case 'ArrowUp': + e.preventDefault(); + this.navigate(-1); + return true; + case 'Enter': + case 'Tab': + if (this.filteredItems.length > 0) { + e.preventDefault(); + this.selectItem(); + return true; + } + return false; + case 'Escape': + e.preventDefault(); + this.hide(); + return true; + } + return false; + } + + isVisible(): boolean { + return this.dropdownEl?.hasClass('visible') ?? false; + } + + hide(): void { + if (this.dropdownEl) { + this.dropdownEl.removeClass('visible'); + } + this.triggerStartIndex = -1; + this.callbacks.onHide(); + } + + destroy(): void { + this.inputEl.removeEventListener('input', this.onInput); + if (this.dropdownEl) { + this.dropdownEl.remove(); + this.dropdownEl = null; + } + } + + resetSdkSkillsCache(): void { + this.cachedProviderEntries = []; + this.providerEntriesFetched = false; + this.requestId = 0; + } + + private getInputValue(): string { + return this.inputEl.value; + } + + private getCursorPosition(): number { + return this.inputEl.selectionStart || 0; + } + + private setInputValue(value: string): void { + this.inputEl.value = value; + } + + private setCursorPosition(pos: number): void { + this.inputEl.selectionStart = pos; + this.inputEl.selectionEnd = pos; + } + + private async showDropdown(searchText: string, isAtPosition0 = true): Promise { + const currentRequest = ++this.requestId; + const searchLower = searchText.toLowerCase(); + + await this.fetchProviderEntries(currentRequest); + + if (currentRequest !== this.requestId) return; + + const includeBuiltIns = isAtPosition0 && this.activeTriggerChar === '/'; + const allItems = this.buildItemList(includeBuiltIns); + + this.filteredItems = allItems + .filter(item => + item.name.toLowerCase().includes(searchLower) || + item.description?.toLowerCase().includes(searchLower) + ) + .sort((a, b) => a.name.localeCompare(b.name)); + + if (currentRequest !== this.requestId) return; + + if (searchText.length > 0 && this.filteredItems.length === 0) { + this.hide(); + return; + } + + this.selectedIndex = 0; + this.render(); + } + + private async fetchProviderEntries(currentRequest: number): Promise { + if (this.providerEntriesFetched || !this.getProviderEntries) return; + + try { + const entries = await this.getProviderEntries(); + if (currentRequest !== this.requestId) return; + if (entries.length > 0) { + this.cachedProviderEntries = entries; + this.providerEntriesFetched = true; + } + } catch { + if (currentRequest !== this.requestId) return; + } + } + + private buildItemList(includeBuiltIns: boolean): DropdownItem[] { + const seenNames = new Set(); + const items: DropdownItem[] = []; + + if (includeBuiltIns) { + const builtIns = getBuiltInCommandsForDropdown(this.providerConfig?.providerId); + for (const cmd of builtIns) { + const nameLower = cmd.name.toLowerCase(); + if (!seenNames.has(nameLower)) { + seenNames.add(nameLower); + items.push({ + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + content: cmd.content, + displayPrefix: '/', + insertPrefix: '/', + isBuiltIn: true, + slashCommand: cmd, + }); + } + } + } + + for (const entry of this.cachedProviderEntries) { + const nameLower = entry.name.toLowerCase(); + if (seenNames.has(nameLower) || this.hiddenCommands.has(nameLower)) { + continue; + } + seenNames.add(nameLower); + items.push({ + name: entry.name, + description: entry.description, + argumentHint: entry.argumentHint, + content: entry.content, + displayPrefix: entry.displayPrefix, + insertPrefix: entry.insertPrefix, + isBuiltIn: false, + providerEntry: entry, + slashCommand: { + id: entry.id, + name: entry.name, + description: entry.description, + content: entry.content, + argumentHint: entry.argumentHint, + allowedTools: entry.allowedTools, + model: entry.model, + source: entry.source, + kind: entry.kind, + disableModelInvocation: entry.disableModelInvocation, + userInvocable: entry.userInvocable, + context: entry.context, + agent: entry.agent, + hooks: entry.hooks, + }, + }); + } + + return items; + } + + private render(): void { + if (!this.dropdownEl) { + this.dropdownEl = this.createDropdownElement(); + } + + this.dropdownEl.empty(); + + if (this.filteredItems.length === 0) { + const emptyEl = this.dropdownEl.createDiv({ cls: 'claudian-slash-empty' }); + emptyEl.setText('No matching commands'); + } else { + for (let i = 0; i < this.filteredItems.length; i++) { + const item = this.filteredItems[i]; + const itemEl = this.dropdownEl.createDiv({ cls: 'claudian-slash-item' }); + + if (i === this.selectedIndex) { + itemEl.addClass('selected'); + } + + const nameEl = itemEl.createSpan({ cls: 'claudian-slash-name' }); + nameEl.setText(`${item.displayPrefix}${item.name}`); + + if (item.argumentHint) { + const hintEl = itemEl.createSpan({ cls: 'claudian-slash-hint' }); + hintEl.setText(normalizeArgumentHint(item.argumentHint)); + } + + if (item.description) { + const descEl = itemEl.createDiv({ cls: 'claudian-slash-desc' }); + descEl.setText(item.description); + } + + itemEl.addEventListener('click', () => { + this.selectedIndex = i; + this.selectItem(); + }); + + itemEl.addEventListener('mouseenter', () => { + this.selectedIndex = i; + this.updateSelection(); + }); + } + } + + this.dropdownEl.addClass('visible'); + + if (this.isFixed) { + this.positionFixed(); + } + } + + private createDropdownElement(): HTMLElement { + if (this.isFixed) { + return this.containerEl.createDiv({ + cls: 'claudian-slash-dropdown claudian-slash-dropdown-fixed', + }); + } else { + return this.containerEl.createDiv({ cls: 'claudian-slash-dropdown' }); + } + } + + private positionFixed(): void { + if (!this.dropdownEl || !this.isFixed) return; + + const inputRect = this.inputEl.getBoundingClientRect(); + this.dropdownEl.setCssProps({ + '--claudian-fixed-dropdown-bottom': `${window.innerHeight - inputRect.top + 4}px`, + '--claudian-fixed-dropdown-left': `${inputRect.left}px`, + '--claudian-fixed-dropdown-width': `${Math.max(inputRect.width, 280)}px`, + }); + } + + private navigate(direction: number): void { + const maxIndex = this.filteredItems.length - 1; + this.selectedIndex = Math.max(0, Math.min(maxIndex, this.selectedIndex + direction)); + this.updateSelection(); + } + + private updateSelection(): void { + const items = this.dropdownEl?.querySelectorAll('.claudian-slash-item'); + items?.forEach((item, index) => { + if (index === this.selectedIndex) { + item.addClass('selected'); + (item as HTMLElement).scrollIntoView({ block: 'nearest' }); + } else { + item.removeClass('selected'); + } + }); + } + + private selectItem(): void { + if (this.filteredItems.length === 0) return; + + const selected = this.filteredItems[this.selectedIndex]; + if (!selected) return; + + const text = this.getInputValue(); + const beforeTrigger = text.substring(0, this.triggerStartIndex); + const afterCursor = text.substring(this.getCursorPosition()); + const replacement = `${selected.insertPrefix}${selected.name} `; + + this.setInputValue(beforeTrigger + replacement + afterCursor); + this.setCursorPosition(beforeTrigger.length + replacement.length); + + this.hide(); + if (selected.slashCommand) { + this.callbacks.onSelect(selected.slashCommand); + } + this.inputEl.focus(); + } +} diff --git a/src/shared/icons.ts b/src/shared/icons.ts new file mode 100644 index 0000000..f41d7f4 --- /dev/null +++ b/src/shared/icons.ts @@ -0,0 +1,180 @@ +import type { ProviderIconSvg, ProviderSvgChild } from '../core/providers/types'; + +export const MCP_ICON_SVG = `MCP`; + +export const CHECK_ICON_SVG = ``; + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const MCP_ICON_PATHS = [ + 'M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z', + 'M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z', +]; + +function createSvgElement(ownerDocument: Document, tagName: string): SVGElement { + return ownerDocument.createElementNS(SVG_NS, tagName); +} + +export function appendMcpIcon(container: HTMLElement): void { + container.empty(); + + const svg = createSvgElement(container.ownerDocument, 'svg'); + svg.setAttribute('fill', 'currentColor'); + svg.setAttribute('fill-rule', 'evenodd'); + svg.setAttribute('height', '1em'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('width', '1em'); + + const title = createSvgElement(container.ownerDocument, 'title'); + title.textContent = 'MCP'; + svg.appendChild(title); + + for (const pathData of MCP_ICON_PATHS) { + const path = createSvgElement(container.ownerDocument, 'path'); + path.setAttribute('d', pathData); + svg.appendChild(path); + } + + container.appendChild(svg); +} + +export function appendCheckIcon(container: HTMLElement): void { + container.empty(); + + const svg = createSvgElement(container.ownerDocument, 'svg'); + svg.setAttribute('width', '12'); + svg.setAttribute('height', '12'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '3'); + svg.setAttribute('stroke-linecap', 'round'); + svg.setAttribute('stroke-linejoin', 'round'); + + const polyline = createSvgElement(container.ownerDocument, 'polyline'); + polyline.setAttribute('points', '20 6 9 17 4 12'); + svg.appendChild(polyline); + + container.appendChild(svg); +} + +export const OPENAI_PROVIDER_ICON: ProviderIconSvg = { + viewBox: '-1 -.1 949.1 959.8', + path: 'm925.8 456.3c10.4 23.2 17 48 19.7 73.3 2.6 25.3 1.3 50.9-4.1 75.8-5.3 24.9-14.5 48.8-27.3 70.8-8.4 14.7-18.3 28.5-29.7 41.2-11.3 12.6-23.9 24-37.6 34-13.8 10-28.5 18.4-44.1 25.3-15.5 6.8-31.7 12-48.3 15.4-7.8 24.2-19.4 47.1-34.4 67.7-14.9 20.6-33 38.7-53.6 53.6-20.6 15-43.4 26.6-67.6 34.4-24.2 7.9-49.5 11.8-75 11.8-16.9.1-33.9-1.7-50.5-5.1-16.5-3.5-32.7-8.8-48.2-15.7s-30.2-15.5-43.9-25.5c-13.6-10-26.2-21.5-37.4-34.2-25 5.4-50.6 6.7-75.9 4.1-25.3-2.7-50.1-9.3-73.4-19.7-23.2-10.3-44.7-24.3-63.6-41.4s-35-37.1-47.7-59.1c-8.5-14.7-15.5-30.2-20.8-46.3s-8.8-32.7-10.6-49.6c-1.8-16.8-1.7-33.8.1-50.7 1.8-16.8 5.5-33.4 10.8-49.5-17-18.9-31-40.4-41.4-63.6-10.3-23.3-17-48-19.6-73.3-2.7-25.3-1.3-50.9 4-75.8s14.5-48.8 27.3-70.8c8.4-14.7 18.3-28.6 29.6-41.2s24-24 37.7-34 28.5-18.5 44-25.3c15.6-6.9 31.8-12 48.4-15.4 7.8-24.3 19.4-47.1 34.3-67.7 15-20.6 33.1-38.7 53.7-53.7 20.6-14.9 43.4-26.5 67.6-34.4 24.2-7.8 49.5-11.8 75-11.7 16.9-.1 33.9 1.6 50.5 5.1s32.8 8.7 48.3 15.6c15.5 7 30.2 15.5 43.9 25.5 13.7 10.1 26.3 21.5 37.5 34.2 24.9-5.3 50.5-6.6 75.8-4s50 9.3 73.3 19.6c23.2 10.4 44.7 24.3 63.6 41.4 18.9 17 35 36.9 47.7 59 8.5 14.6 15.5 30.1 20.8 46.3 5.3 16.1 8.9 32.7 10.6 49.6 1.8 16.9 1.8 33.9-.1 50.8-1.8 16.9-5.5 33.5-10.8 49.6 17.1 18.9 31 40.3 41.4 63.6zm-333.2 426.9c21.8-9 41.6-22.3 58.3-39s30-36.5 39-58.4c9-21.8 13.7-45.2 13.7-68.8v-223q-.1-.3-.2-.7-.1-.3-.3-.6-.2-.3-.5-.5-.3-.3-.6-.4l-80.7-46.6v269.4c0 2.7-.4 5.5-1.1 8.1-.7 2.7-1.7 5.2-3.1 7.6s-3 4.6-5 6.5a32.1 32.1 0 0 1-6.5 5l-191.1 110.3c-1.6 1-4.3 2.4-5.7 3.2 7.9 6.7 16.5 12.6 25.5 17.8 9.1 5.2 18.5 9.6 28.3 13.2 9.8 3.5 19.9 6.2 30.1 8 10.3 1.8 20.7 2.7 31.1 2.7 23.6 0 47-4.7 68.8-13.8zm-455.1-151.4c11.9 20.5 27.6 38.3 46.3 52.7 18.8 14.4 40.1 24.9 62.9 31s46.6 7.7 70 4.6 45.9-10.7 66.4-22.5l193.2-111.5.5-.5q.2-.2.3-.6.2-.3.3-.6v-94l-233.2 134.9c-2.4 1.4-4.9 2.4-7.5 3.2-2.7.7-5.4 1-8.2 1-2.7 0-5.4-.3-8.1-1-2.6-.8-5.2-1.8-7.6-3.2l-191.1-110.4c-1.7-1-4.2-2.5-5.6-3.4-1.8 10.3-2.7 20.7-2.7 31.1s1 20.8 2.8 31.1c1.8 10.2 4.6 20.3 8.1 30.1 3.6 9.8 8 19.2 13.2 28.2zm-50.2-417c-11.8 20.5-19.4 43.1-22.5 66.5s-1.5 47.1 4.6 70c6.1 22.8 16.6 44.1 31 62.9 14.4 18.7 32.3 34.4 52.7 46.2l193.1 111.6q.3.1.7.2h.7q.4 0 .7-.2.3-.1.6-.3l81-46.8-233.2-134.6c-2.3-1.4-4.5-3.1-6.5-5a32.1 32.1 0 0 1-5-6.5c-1.3-2.4-2.4-4.9-3.1-7.6-.7-2.6-1.1-5.3-1-8.1v-227.1c-9.8 3.6-19.3 8-28.3 13.2-9 5.3-17.5 11.3-25.5 18-7.9 6.7-15.3 14.1-22 22.1-6.7 7.9-12.6 16.5-17.8 25.5zm663.3 154.4c2.4 1.4 4.6 3 6.6 5 1.9 1.9 3.6 4.1 5 6.5 1.3 2.4 2.4 5 3.1 7.6.6 2.7 1 5.4.9 8.2v227.1c32.1-11.8 60.1-32.5 80.8-59.7 20.8-27.2 33.3-59.7 36.2-93.7s-3.9-68.2-19.7-98.5-39.9-55.5-69.5-72.5l-193.1-111.6q-.3-.1-.7-.2h-.7q-.3.1-.7.2-.3.1-.6.3l-80.6 46.6 233.2 134.7zm80.5-121h-.1v.1zm-.1-.1c5.8-33.6 1.9-68.2-11.3-99.7-13.1-31.5-35-58.6-63-78.2-28-19.5-61-30.7-95.1-32.2-34.2-1.4-68 6.9-97.6 23.9l-193.1 111.5q-.3.2-.5.5l-.4.6q-.1.3-.2.7-.1.3-.1.7v93.2l233.2-134.7c2.4-1.4 5-2.4 7.6-3.2 2.7-.7 5.4-1 8.1-1 2.8 0 5.5.3 8.2 1 2.6.8 5.1 1.8 7.5 3.2l191.1 110.4c1.7 1 4.2 2.4 5.6 3.3zm-505.3-103.2c0-2.7.4-5.4 1.1-8.1.7-2.6 1.7-5.2 3.1-7.6 1.4-2.3 3-4.5 5-6.5 1.9-1.9 4.1-3.6 6.5-4.9l191.1-110.3c1.8-1.1 4.3-2.5 5.7-3.2-26.2-21.9-58.2-35.9-92.1-40.2-33.9-4.4-68.3 1-99.2 15.5-31 14.5-57.2 37.6-75.5 66.4-18.3 28.9-28 62.3-28 96.5v223q.1.4.2.7.1.3.3.6.2.3.5.6.2.2.6.4l80.7 46.6zm43.8 294.7 103.9 60 103.9-60v-119.9l-103.8-60-103.9 60z', +}; + +export const CLAUDE_PROVIDER_ICON: ProviderIconSvg = { + viewBox: '0 -.01 39.5 39.53', + path: 'm7.75 26.27 7.77-4.36.13-.38-.13-.21h-.38l-1.3-.08-4.44-.12-3.85-.16-3.73-.2-.94-.2-.88-1.16.09-.58.79-.53 1.13.1 2.5.17 3.75.26 2.72.16 4.03.42h.64l.09-.26-.22-.16-.17-.16-3.88-2.63-4.2-2.78-2.2-1.6-1.19-.81-.6-.76-.26-1.66 1.08-1.19 1.45.1.37.1 1.47 1.13 3.14 2.43 4.1 3.02.6.5.24-.17.03-.12-.27-.45-2.23-4.03-2.38-4.1-1.06-1.7-.28-1.02c-.1-.42-.17-.77-.17-1.2l1.23-1.67.68-.22 1.64.22.69.6 1.02 2.33 1.65 3.67 2.56 4.99.75 1.48.4 1.37.15.42h.26v-.24l.21-2.81.39-3.45.38-4.44.13-1.25.62-1.5 1.23-.81.96.46.79 1.13-.11.73-.47 3.05-.92 4.78-.6 3.2h.35l.4-.4 1.62-2.15 2.72-3.4 1.2-1.35 1.4-1.49.9-.71h1.7l1.25 1.86-.56 1.92-1.75 2.22-1.45 1.88-2.08 2.8-1.3 2.24.12.18.31-.03 4.7-1 2.54-.46 3.03-.52 1.37.64.15.65-.54 1.33-3.24.8-3.8.76-5.66 1.34-.07.05.08.1 2.55.24 1.09.06h2.67l4.97.37 1.3.86.78 1.05-.13.8-2 1.02-2.7-.64-6.3-1.5-2.16-.54h-.3v.18l1.8 1.76 3.3 2.98 4.13 3.84.21.95-.53.75-.56-.08-3.63-2.73-1.4-1.23-3.17-2.67h-.21v.28l.73 1.07 3.86 5.8.2 1.78-.28.58-1 .35-1.1-.2-2.26-3.17-2.33-3.57-1.88-3.2-.23.13-1.11 11.95-.52.61-1.2.46-1-.76-.53-1.23.53-2.43.64-3.17.52-2.52.47-3.13.28-1.04-.02-.07-.23.03-2.36 3.24-3.59 4.85-2.84 3.04-.68.27-1.18-.61.11-1.09.66-.97 3.93-5 2.37-3.1 1.53-1.79-.01-.26h-.09l-10.44 6.78-1.86.24-.8-.75.1-1.23.38-.4 3.14-2.16z', +}; + +export const OPENCODE_PROVIDER_ICON: ProviderIconSvg = { + kind: 'composite', + viewBox: '0 0 300 300', + children: [ + { + tag: 'g', + attributes: { + class: 'claudian-provider-icon-variant claudian-provider-icon-variant--light', + transform: 'translate(30 0)', + }, + children: [ + { tag: 'path', attributes: { d: 'M180 240H60V120H180V240Z', fill: '#CFCECD' } }, + { tag: 'path', attributes: { d: 'M180 60H60V240H180V60ZM240 300H0V0H240V300Z', fill: '#211E1E' } }, + ], + }, + { + tag: 'g', + attributes: { + class: 'claudian-provider-icon-variant claudian-provider-icon-variant--dark', + transform: 'translate(30 0)', + }, + children: [ + { tag: 'path', attributes: { d: 'M180 240H60V120H180V240Z', fill: '#4B4646' } }, + { tag: 'path', attributes: { d: 'M180 60H60V240H180V60ZM240 300H0V0H240V300Z', fill: '#F1ECEC' } }, + ], + }, + ], +}; + +export const PI_PROVIDER_ICON: ProviderIconSvg = { + kind: 'composite', + viewBox: '0 0 800 800', + children: [ + { + tag: 'path', + attributes: { + d: 'M165.29 165.29H517.36V400H400V517.36H282.65V634.72H165.29ZM282.65 282.65V400H400V282.65Z', + fill: 'currentColor', + 'fill-rule': 'evenodd', + }, + }, + { + tag: 'path', + attributes: { + d: 'M517.36 400H634.72V634.72H517.36Z', + fill: 'currentColor', + }, + }, + ], +}; + +export interface CreateProviderIconSvgOptions { + className?: string; + dataProvider?: string; + height?: number | string; + ownerDocument?: Document; + width?: number | string; +} + +export function createProviderIconSvg( + icon: ProviderIconSvg, + options: CreateProviderIconSvgOptions = {}, +): SVGElement { + const ownerDocument = options.ownerDocument ?? window.document; + const svg = ownerDocument.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', icon.viewBox); + svg.setAttribute('fill', 'none'); + svg.setAttribute('aria-hidden', 'true'); + svg.classList.add('claudian-provider-icon'); + + if (options.width !== undefined) { + svg.setAttribute('width', String(options.width)); + } + if (options.height !== undefined) { + svg.setAttribute('height', String(options.height)); + } + if (options.className) { + svg.classList.add(...options.className.split(/\s+/).filter(Boolean)); + } + if (options.dataProvider) { + svg.setAttribute('data-provider', options.dataProvider); + } + + if (icon.kind === 'composite') { + for (const child of icon.children) { + svg.appendChild(createProviderSvgChild(child, ownerDocument)); + } + return svg; + } + + const path = ownerDocument.createElementNS(SVG_NS, 'path'); + path.setAttribute('d', icon.path); + path.setAttribute('fill', 'currentColor'); + svg.appendChild(path); + return svg; +} + +function createProviderSvgChild(child: ProviderSvgChild, ownerDocument: Document): SVGElement { + const element = ownerDocument.createElementNS(SVG_NS, child.tag); + for (const [name, value] of Object.entries(child.attributes)) { + element.setAttribute(name, value); + } + + if (child.tag === 'g') { + for (const nestedChild of child.children) { + element.appendChild(createProviderSvgChild(nestedChild, ownerDocument)); + } + } + + return element; +} diff --git a/src/shared/mention/MentionDropdownController.ts b/src/shared/mention/MentionDropdownController.ts new file mode 100644 index 0000000..794f629 --- /dev/null +++ b/src/shared/mention/MentionDropdownController.ts @@ -0,0 +1,627 @@ +import type { TFile } from 'obsidian'; +import { setIcon } from 'obsidian'; + +import { buildExternalContextDisplayEntries } from '../../utils/externalContext'; +import { type ExternalContextFile, externalContextScanner } from '../../utils/externalContextScanner'; +import { extractMcpMentions } from '../../utils/mcp'; +import { SelectableDropdown } from '../components/SelectableDropdown'; +import { appendMcpIcon } from '../icons'; +import { + type AgentMentionProvider, + type FolderMentionItem, + type MentionItem, +} from './types'; + +export type { AgentMentionProvider }; + +export interface MentionDropdownOptions { + fixed?: boolean; +} + +export interface MentionDropdownCallbacks { + onAttachFile: (path: string) => void; + onMcpMentionChange?: (servers: Set) => void; + onAgentMentionSelect?: (agentId: string) => void; + getMentionedMcpServers: () => Set; + setMentionedMcpServers: (mentions: Set) => boolean; + addMentionedMcpServer: (name: string) => void; + getExternalContexts: () => string[]; + getCachedVaultFolders: () => Array>; + getCachedVaultFiles: () => TFile[]; + normalizePathForVault: (path: string | undefined | null) => string | null; +} + +export interface McpMentionProvider { + getContextSavingServers: () => Array<{ name: string }>; +} + +export class MentionDropdownController { + private containerEl: HTMLElement; + private inputEl: HTMLTextAreaElement | HTMLInputElement; + private callbacks: MentionDropdownCallbacks; + private dropdown: SelectableDropdown; + private mentionStartIndex = -1; + private selectedMentionIndex = 0; + private filteredMentionItems: MentionItem[] = []; + private filteredContextFiles: ExternalContextFile[] = []; + private activeContextFilter: { folderName: string; contextRoot: string } | null = null; + private activeAgentFilter = false; + private mcpManager: McpMentionProvider | null = null; + private agentService: AgentMentionProvider | null = null; + private fixed: boolean; + private debounceTimer: number | null = null; + + constructor( + containerEl: HTMLElement, + inputEl: HTMLTextAreaElement | HTMLInputElement, + callbacks: MentionDropdownCallbacks, + options: MentionDropdownOptions = {} + ) { + this.containerEl = containerEl; + this.inputEl = inputEl; + this.callbacks = callbacks; + this.fixed = options.fixed ?? false; + + this.dropdown = new SelectableDropdown(this.containerEl, { + listClassName: 'claudian-mention-dropdown', + itemClassName: 'claudian-mention-item', + emptyClassName: 'claudian-mention-empty', + fixed: this.fixed, + fixedClassName: 'claudian-mention-dropdown-fixed', + }); + } + + setMcpManager(manager: McpMentionProvider | null): void { + this.mcpManager = manager; + } + + setAgentService(service: AgentMentionProvider | null): void { + if (this.agentService !== service && this.dropdown.isVisible()) { + this.hide(); + } + this.agentService = service; + } + + preScanExternalContexts(): void { + const externalContexts = this.callbacks.getExternalContexts() || []; + if (externalContexts.length === 0) return; + + window.setTimeout(() => { + try { + externalContextScanner.scanPaths(externalContexts); + } catch { + // Pre-scan is best-effort, ignore failures + } + }, 0); + } + + isVisible(): boolean { + return this.dropdown.isVisible(); + } + + hide(): void { + this.dropdown.hide(); + this.mentionStartIndex = -1; + } + + containsElement(el: Node): boolean { + return this.dropdown.getElement()?.contains(el) ?? false; + } + + destroy(): void { + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + } + this.dropdown.destroy(); + } + + updateMcpMentionsFromText(text: string): void { + if (!this.mcpManager) return; + + const validNames = new Set( + this.mcpManager.getContextSavingServers().map(s => s.name) + ); + + const newMentions = extractMcpMentions(text, validNames); + const changed = this.callbacks.setMentionedMcpServers(newMentions); + + if (changed) { + this.callbacks.onMcpMentionChange?.(newMentions); + } + } + + handleInputChange(): void { + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + } + + this.debounceTimer = window.setTimeout(() => { + const text = this.inputEl.value; + this.updateMcpMentionsFromText(text); + + const cursorPos = this.inputEl.selectionStart || 0; + const textBeforeCursor = text.substring(0, cursorPos); + const lastAtIndex = textBeforeCursor.lastIndexOf('@'); + + if (lastAtIndex === -1) { + this.hide(); + return; + } + + const charBeforeAt = lastAtIndex > 0 ? textBeforeCursor[lastAtIndex - 1] : ' '; + if (!/\s/.test(charBeforeAt) && lastAtIndex !== 0) { + this.hide(); + return; + } + + const searchText = textBeforeCursor.substring(lastAtIndex + 1); + + this.mentionStartIndex = lastAtIndex; + this.showMentionDropdown(searchText); + }, 200); + } + + handleKeydown(e: KeyboardEvent): boolean { + if (!this.dropdown.isVisible()) return false; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + this.dropdown.moveSelection(1); + this.selectedMentionIndex = this.dropdown.getSelectedIndex(); + return true; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + this.dropdown.moveSelection(-1); + this.selectedMentionIndex = this.dropdown.getSelectedIndex(); + return true; + } + // Check !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) + if ((e.key === 'Enter' || e.key === 'Tab') && !e.isComposing) { + e.preventDefault(); + this.selectMentionItem(); + return true; + } + if (e.key === 'Escape' && !e.isComposing) { + e.preventDefault(); + // If in secondary menu, return to first level instead of closing + if (this.activeContextFilter || this.activeAgentFilter) { + this.returnToFirstLevel(); + return true; + } + this.hide(); + return true; + } + + return false; + } + + private showMentionDropdown(searchText: string): void { + const searchLower = searchText.toLowerCase(); + this.filteredMentionItems = []; + this.filteredContextFiles = []; + + const externalContexts = this.callbacks.getExternalContexts() || []; + const contextEntries = buildExternalContextDisplayEntries(externalContexts); + + const isFilterSearch = searchText.includes('/'); + let fileSearchText = searchLower; + + if (isFilterSearch && searchLower.startsWith('agents/')) { + this.activeAgentFilter = true; + this.activeContextFilter = null; + const agentSearchText = searchText.substring('agents/'.length).toLowerCase(); + + if (this.agentService) { + const matchingAgents = this.agentService.searchAgents(agentSearchText); + for (const agent of matchingAgents) { + this.filteredMentionItems.push({ + type: 'agent', + id: agent.id, + name: agent.name, + description: agent.description, + source: agent.source, + }); + } + } + + this.selectedMentionIndex = 0; + this.renderMentionDropdown(); + return; + } + + if (isFilterSearch) { + const matchingContext = contextEntries + .filter(entry => searchLower.startsWith(`${entry.displayNameLower}/`)) + .sort((a, b) => b.displayNameLower.length - a.displayNameLower.length)[0]; + + if (matchingContext) { + const prefixLength = matchingContext.displayName.length + 1; + fileSearchText = searchText.substring(prefixLength).toLowerCase(); + this.activeContextFilter = { + folderName: matchingContext.displayName, + contextRoot: matchingContext.contextRoot, + }; + } else { + this.activeContextFilter = null; + } + } + + if (this.activeContextFilter && isFilterSearch) { + const contextFiles = externalContextScanner.scanPaths([this.activeContextFilter.contextRoot]); + this.filteredContextFiles = contextFiles + .filter(file => { + const relativePath = file.relativePath.replace(/\\/g, '/'); + const pathLower = relativePath.toLowerCase(); + const nameLower = file.name.toLowerCase(); + return pathLower.includes(fileSearchText) || nameLower.includes(fileSearchText); + }) + .sort((a, b) => { + const aNameMatch = a.name.toLowerCase().startsWith(fileSearchText); + const bNameMatch = b.name.toLowerCase().startsWith(fileSearchText); + if (aNameMatch && !bNameMatch) return -1; + if (!aNameMatch && bNameMatch) return 1; + return b.mtime - a.mtime; + }); + + for (const file of this.filteredContextFiles) { + const relativePath = file.relativePath.replace(/\\/g, '/'); + this.filteredMentionItems.push({ + type: 'context-file', + name: relativePath, + absolutePath: file.path, + contextRoot: file.contextRoot, + folderName: this.activeContextFilter.folderName, + }); + } + + const firstVaultItemIndex = this.filteredMentionItems.length; + const vaultItemCount = this.appendVaultItems(searchLower); + + if (this.hideIfNoResults()) { + return; + } + + if (this.filteredContextFiles.length === 0 && vaultItemCount > 0) { + this.selectedMentionIndex = firstVaultItemIndex; + } else { + this.selectedMentionIndex = 0; + } + + this.renderMentionDropdown(); + return; + } + + this.activeContextFilter = null; + this.activeAgentFilter = false; + + if (this.mcpManager) { + const mcpServers = this.mcpManager.getContextSavingServers(); + + for (const server of mcpServers) { + if (server.name.toLowerCase().includes(searchLower)) { + this.filteredMentionItems.push({ + type: 'mcp-server', + name: server.name, + }); + } + } + } + + if (this.agentService) { + const hasAgents = this.agentService.searchAgents('').length > 0; + if (hasAgents && 'agents'.includes(searchLower)) { + this.filteredMentionItems.push({ + type: 'agent-folder', + name: 'Agents', + }); + } + } + + if (contextEntries.length > 0) { + const matchingFolders = new Set(); + for (const entry of contextEntries) { + if (entry.displayNameLower.includes(searchLower) && !matchingFolders.has(entry.displayName)) { + matchingFolders.add(entry.displayName); + this.filteredMentionItems.push({ + type: 'context-folder', + name: entry.displayName, + contextRoot: entry.contextRoot, + folderName: entry.displayName, + }); + } + } + } + + const firstVaultItemIndex = this.filteredMentionItems.length; + const vaultItemCount = this.appendVaultItems(searchLower); + + if (this.hideIfNoResults()) { + return; + } + + this.selectedMentionIndex = vaultItemCount > 0 ? firstVaultItemIndex : 0; + + this.renderMentionDropdown(); + } + + private appendVaultItems(searchLower: string): number { + type ScoredItem = + | { type: 'folder'; name: string; path: string; startsWithQuery: boolean; mtime: number } + | { type: 'file'; name: string; path: string; file: TFile; startsWithQuery: boolean; mtime: number }; + + const compare = (a: ScoredItem, b: ScoredItem): number => { + if (a.startsWithQuery !== b.startsWithQuery) return a.startsWithQuery ? -1 : 1; + if (a.mtime !== b.mtime) return b.mtime - a.mtime; + if (a.type !== b.type) return a.type === 'file' ? -1 : 1; + return a.path.localeCompare(b.path); + }; + + const allFiles = this.callbacks.getCachedVaultFiles(); + + // Derive folder mtime from the most recently modified file within each folder + const folderMtimeMap = new Map(); + for (const f of allFiles) { + const parts = f.path.split('/'); + for (let i = 1; i < parts.length; i++) { + const folderPath = parts.slice(0, i).join('/'); + const existing = folderMtimeMap.get(folderPath) ?? 0; + if (f.stat.mtime > existing) { + folderMtimeMap.set(folderPath, f.stat.mtime); + } + } + } + + const scoredFolders: ScoredItem[] = this.callbacks.getCachedVaultFolders() + .map(f => ({ + name: f.name, + path: f.path.replace(/\\/g, '/').replace(/\/+$/, ''), + })) + .filter(f => + f.path.length > 0 && + (f.path.toLowerCase().includes(searchLower) || f.name.toLowerCase().includes(searchLower)) + ) + .map(f => ({ + type: 'folder' as const, + name: f.name, + path: f.path, + startsWithQuery: f.name.toLowerCase().startsWith(searchLower), + mtime: folderMtimeMap.get(f.path) ?? 0, + })) + .sort(compare) + .slice(0, 50); + + const scoredFiles: ScoredItem[] = allFiles + .filter(f => + f.path.toLowerCase().includes(searchLower) || f.name.toLowerCase().includes(searchLower) + ) + .map(f => ({ + type: 'file' as const, + name: f.name, + path: f.path, + file: f, + startsWithQuery: f.name.toLowerCase().startsWith(searchLower), + mtime: f.stat.mtime, + })) + .sort(compare) + .slice(0, 100); + + const merged = [...scoredFolders, ...scoredFiles].sort(compare); + + for (const item of merged) { + if (item.type === 'folder') { + this.filteredMentionItems.push({ type: 'folder', name: item.name, path: item.path }); + } else { + this.filteredMentionItems.push({ type: 'file', name: item.name, path: item.path, file: item.file }); + } + } + + return merged.length; + } + + private hideIfNoResults(): boolean { + if (this.filteredMentionItems.length > 0) return false; + + this.hide(); + return true; + } + + private renderMentionDropdown(): void { + this.dropdown.render({ + items: this.filteredMentionItems, + selectedIndex: this.selectedMentionIndex, + emptyText: 'No matches', + getItemClass: (item) => { + switch (item.type) { + case 'mcp-server': return 'mcp-server'; + case 'folder': return 'vault-folder'; + case 'agent': return 'agent'; + case 'agent-folder': return 'agent-folder'; + case 'context-file': return 'context-file'; + case 'context-folder': return 'context-folder'; + default: return undefined; + } + }, + renderItem: (item, itemEl) => { + const iconEl = itemEl.createSpan({ cls: 'claudian-mention-icon' }); + switch (item.type) { + case 'mcp-server': + appendMcpIcon(iconEl); + break; + case 'agent': + case 'agent-folder': + setIcon(iconEl, 'bot'); + break; + case 'context-file': + setIcon(iconEl, 'folder-open'); + break; + case 'folder': + case 'context-folder': + setIcon(iconEl, 'folder'); + break; + default: + setIcon(iconEl, 'file-text'); + } + + const textEl = itemEl.createSpan({ cls: 'claudian-mention-text' }); + + switch (item.type) { + case 'mcp-server': + textEl.createSpan({ cls: 'claudian-mention-name' }).setText(`@${item.name}`); + break; + case 'agent-folder': + textEl.createSpan({ + cls: 'claudian-mention-name claudian-mention-name-agent-folder', + }).setText(`@${item.name}/`); + break; + case 'agent': { + // Show ID (which is namespaced for plugin agents) for consistency with inserted text + textEl.createSpan({ + cls: 'claudian-mention-name claudian-mention-name-agent', + }).setText(`@${item.id}`); + if (item.description) { + textEl.createSpan({ cls: 'claudian-mention-agent-desc' }).setText(item.description); + } + break; + } + case 'context-folder': + textEl.createSpan({ + cls: 'claudian-mention-name claudian-mention-name-folder', + }).setText(`@${item.name}/`); + break; + case 'context-file': + textEl.createSpan({ + cls: 'claudian-mention-name claudian-mention-name-context', + }).setText(item.name); + break; + case 'folder': + textEl.createSpan({ + cls: 'claudian-mention-name claudian-mention-name-folder', + }).setText(`@${item.path}/`); + break; + default: + textEl.createSpan({ cls: 'claudian-mention-path' }).setText(item.path || item.name); + } + }, + onItemClick: (item, index, e) => { + // Stop propagation for folder items to prevent document click handler + // from hiding dropdown (since dropdown is re-rendered with new DOM) + if (item.type === 'context-folder' || item.type === 'agent-folder') { + e.stopPropagation(); + } + this.selectedMentionIndex = index; + this.selectMentionItem(); + }, + onItemHover: (_item, index) => { + this.selectedMentionIndex = index; + }, + }); + + if (this.fixed) { + this.positionFixed(); + } + } + + private positionFixed(): void { + const dropdownEl = this.dropdown.getElement(); + if (!dropdownEl) return; + + const inputRect = this.inputEl.getBoundingClientRect(); + dropdownEl.setCssProps({ + '--claudian-fixed-dropdown-bottom': `${window.innerHeight - inputRect.top + 4}px`, + '--claudian-fixed-dropdown-left': `${inputRect.left}px`, + '--claudian-fixed-dropdown-width': `${Math.max(inputRect.width, 280)}px`, + }); + } + + private insertReplacement(beforeAt: string, replacement: string, afterCursor: string): void { + this.inputEl.value = beforeAt + replacement + afterCursor; + this.inputEl.selectionStart = this.inputEl.selectionEnd = beforeAt.length + replacement.length; + } + + private returnToFirstLevel(): void { + const text = this.inputEl.value; + const beforeAt = text.substring(0, this.mentionStartIndex); + const cursorPos = this.inputEl.selectionStart || 0; + const afterCursor = text.substring(cursorPos); + + this.inputEl.value = beforeAt + '@' + afterCursor; + this.inputEl.selectionStart = this.inputEl.selectionEnd = beforeAt.length + 1; + + this.activeContextFilter = null; + this.activeAgentFilter = false; + + this.showMentionDropdown(''); + } + + private selectMentionItem(): void { + if (this.filteredMentionItems.length === 0) return; + + const selectedIndex = this.dropdown.getSelectedIndex(); + this.selectedMentionIndex = selectedIndex; + const selectedItem = this.filteredMentionItems[selectedIndex]; + if (!selectedItem) return; + + const text = this.inputEl.value; + const beforeAt = text.substring(0, this.mentionStartIndex); + const cursorPos = this.inputEl.selectionStart || 0; + const afterCursor = text.substring(cursorPos); + + switch (selectedItem.type) { + case 'mcp-server': { + const replacement = `@${selectedItem.name} `; + this.insertReplacement(beforeAt, replacement, afterCursor); + this.callbacks.addMentionedMcpServer(selectedItem.name); + this.callbacks.onMcpMentionChange?.(this.callbacks.getMentionedMcpServers()); + break; + } + case 'agent-folder': + // Don't modify input text - just show agents submenu + this.activeAgentFilter = true; + this.inputEl.focus(); + this.showMentionDropdown('Agents/'); + return; + case 'agent': { + const replacement = `@${selectedItem.id} (agent) `; + this.insertReplacement(beforeAt, replacement, afterCursor); + this.callbacks.onAgentMentionSelect?.(selectedItem.id); + break; + } + case 'context-folder': { + const replacement = `@${selectedItem.name}/`; + this.insertReplacement(beforeAt, replacement, afterCursor); + this.inputEl.focus(); + this.handleInputChange(); + return; + } + case 'context-file': { + // Display friendly name in input; absolute path resolution happens at send time. + const displayName = selectedItem.folderName + ? `@${selectedItem.folderName}/${selectedItem.name}` + : `@${selectedItem.name}`; + if (selectedItem.absolutePath) { + this.callbacks.onAttachFile(selectedItem.absolutePath); + } + this.insertReplacement(beforeAt, `${displayName} `, afterCursor); + break; + } + case 'folder': { + const normalizedPath = this.callbacks.normalizePathForVault(selectedItem.path); + this.insertReplacement(beforeAt, `@${normalizedPath ?? selectedItem.path}/ `, afterCursor); + break; + } + default: { + const rawPath = selectedItem.file?.path ?? selectedItem.path; + const normalizedPath = this.callbacks.normalizePathForVault(rawPath); + if (normalizedPath) { + this.callbacks.onAttachFile(normalizedPath); + } + this.insertReplacement(beforeAt, `@${normalizedPath ?? selectedItem.name} `, afterCursor); + break; + } + } + + this.hide(); + this.inputEl.focus(); + } +} diff --git a/src/shared/mention/VaultMentionCache.ts b/src/shared/mention/VaultMentionCache.ts new file mode 100644 index 0000000..a9fca5b --- /dev/null +++ b/src/shared/mention/VaultMentionCache.ts @@ -0,0 +1,106 @@ +import type { App, TFile } from 'obsidian'; +import { TFolder } from 'obsidian'; + +export interface VaultFileCacheOptions { + onLoadError?: (error: unknown) => void; +} + +export class VaultFileCache { + private cachedFiles: TFile[] = []; + private dirty = true; + private isInitialized = false; + + constructor( + private app: App, + private options: VaultFileCacheOptions = {} + ) {} + + initializeInBackground(): void { + if (this.isInitialized) return; + + window.setTimeout(() => { + this.tryRefreshFiles(); + }, 0); + } + + markDirty(): void { + this.dirty = true; + } + + getFiles(): TFile[] { + if (this.dirty || !this.isInitialized) { + this.tryRefreshFiles(); + } + return this.cachedFiles; + } + + private tryRefreshFiles(): void { + try { + this.cachedFiles = this.app.vault.getFiles(); + this.dirty = false; + } catch (error) { + this.options.onLoadError?.(error); + // Keep stale cache on failure. If data exists, avoid retrying each call. + if (this.cachedFiles.length > 0) { + this.dirty = false; + } + } finally { + this.isInitialized = true; + } + } +} + +function isVisibleFolder(folder: TFolder): boolean { + const normalizedPath = folder.path + .replace(/\\/g, '/') + .replace(/\/+$/, ''); + if (!normalizedPath) return false; + return !normalizedPath.split('/').some(segment => segment.startsWith('.')); +} + +export class VaultFolderCache { + private cachedFolders: TFolder[] = []; + private dirty = true; + private isInitialized = false; + + constructor(private app: App) {} + + initializeInBackground(): void { + if (this.isInitialized) return; + + window.setTimeout(() => { + this.tryRefreshFolders(); + }, 0); + } + + markDirty(): void { + this.dirty = true; + } + + getFolders(): TFolder[] { + if (this.dirty || !this.isInitialized) { + this.tryRefreshFolders(); + } + return this.cachedFolders; + } + + private tryRefreshFolders(): void { + try { + this.cachedFolders = this.loadFolders(); + this.dirty = false; + } catch { + // Keep stale cache on failure. If data exists, avoid retrying each call. + if (this.cachedFolders.length > 0) { + this.dirty = false; + } + } finally { + this.isInitialized = true; + } + } + + private loadFolders(): TFolder[] { + return this.app.vault + .getAllLoadedFiles() + .filter((file): file is TFolder => file instanceof TFolder && isVisibleFolder(file)); + } +} diff --git a/src/shared/mention/VaultMentionDataProvider.ts b/src/shared/mention/VaultMentionDataProvider.ts new file mode 100644 index 0000000..7dbaf87 --- /dev/null +++ b/src/shared/mention/VaultMentionDataProvider.ts @@ -0,0 +1,51 @@ +import type { App, TFile } from 'obsidian'; + +import { VaultFileCache, VaultFolderCache } from './VaultMentionCache'; + +export interface VaultMentionDataProviderOptions { + onFileLoadError?: () => void; +} + +export class VaultMentionDataProvider { + private fileCache: VaultFileCache; + private folderCache: VaultFolderCache; + private hasReportedFileLoadError = false; + + constructor( + app: App, + options: VaultMentionDataProviderOptions = {} + ) { + this.fileCache = new VaultFileCache(app, { + onLoadError: () => { + if (this.hasReportedFileLoadError) return; + this.hasReportedFileLoadError = true; + options.onFileLoadError?.(); + }, + }); + this.folderCache = new VaultFolderCache(app); + } + + initializeInBackground(): void { + this.fileCache.initializeInBackground(); + this.folderCache.initializeInBackground(); + } + + markFilesDirty(): void { + this.fileCache.markDirty(); + } + + markFoldersDirty(): void { + this.folderCache.markDirty(); + } + + getCachedVaultFiles(): TFile[] { + return this.fileCache.getFiles(); + } + + getCachedVaultFolders(): Array<{ name: string; path: string }> { + return this.folderCache.getFolders().map(folder => ({ + name: folder.name, + path: folder.path, + })); + } +} diff --git a/src/shared/mention/types.ts b/src/shared/mention/types.ts new file mode 100644 index 0000000..f20d666 --- /dev/null +++ b/src/shared/mention/types.ts @@ -0,0 +1,67 @@ +import type { TFile } from 'obsidian'; + +import type { + AgentMentionProvider, + AgentMentionSource, +} from '../../core/providers/types'; + +export interface FileMentionItem { + type: 'file'; + name: string; + path: string; + file: TFile; +} + +export interface FolderMentionItem { + type: 'folder'; + name: string; + path: string; +} + +export interface McpServerMentionItem { + type: 'mcp-server'; + name: string; +} + +export interface ContextFileMentionItem { + type: 'context-file'; + name: string; + absolutePath: string; + contextRoot: string; + folderName: string; +} + +export interface ContextFolderMentionItem { + type: 'context-folder'; + name: string; + contextRoot: string; + folderName: string; +} + +export interface AgentMentionItem { + type: 'agent'; + /** Display name */ + name: string; + /** Full ID (namespaced for plugins) */ + id: string; + /** Brief description */ + description?: string; + /** Source of the agent */ + source: AgentMentionSource; +} + +export interface AgentFolderMentionItem { + type: 'agent-folder'; + name: string; +} + +export type { AgentMentionProvider }; + +export type MentionItem = + | FileMentionItem + | FolderMentionItem + | McpServerMentionItem + | ContextFileMentionItem + | ContextFolderMentionItem + | AgentMentionItem + | AgentFolderMentionItem; diff --git a/src/shared/modals/ConfirmModal.ts b/src/shared/modals/ConfirmModal.ts new file mode 100644 index 0000000..9035c15 --- /dev/null +++ b/src/shared/modals/ConfirmModal.ts @@ -0,0 +1,60 @@ +import { type App,Modal, Setting } from 'obsidian'; + +import { t } from '../../i18n/i18n'; + +export function confirmDelete(app: App, message: string): Promise { + return new Promise(resolve => { + new ConfirmModal(app, message, resolve).open(); + }); +} + +export function confirm(app: App, message: string, confirmText: string): Promise { + return new Promise(resolve => { + new ConfirmModal(app, message, resolve, confirmText).open(); + }); +} + +class ConfirmModal extends Modal { + private message: string; + private resolve: (confirmed: boolean) => void; + private resolved = false; + private confirmText: string; + + constructor(app: App, message: string, resolve: (confirmed: boolean) => void, confirmText?: string) { + super(app); + this.message = message; + this.resolve = resolve; + this.confirmText = confirmText ?? t('common.delete'); + } + + onOpen() { + this.setTitle(t('common.confirm')); + this.modalEl.addClass('claudian-confirm-modal'); + + this.contentEl.createEl('p', { text: this.message }); + + new Setting(this.contentEl) + .addButton(btn => + btn + .setButtonText(t('common.cancel')) + .onClick(() => this.close()) + ) + .addButton(btn => + btn + .setButtonText(this.confirmText) + .setWarning() + .onClick(() => { + this.resolved = true; + this.resolve(true); + this.close(); + }) + ); + } + + onClose() { + if (!this.resolved) { + this.resolve(false); + } + this.contentEl.empty(); + } +} diff --git a/src/shared/modals/ForkTargetModal.ts b/src/shared/modals/ForkTargetModal.ts new file mode 100644 index 0000000..519f3a8 --- /dev/null +++ b/src/shared/modals/ForkTargetModal.ts @@ -0,0 +1,47 @@ +import { type App, Modal } from 'obsidian'; + +import { t } from '../../i18n/i18n'; + +export type ForkTarget = 'new-tab' | 'current-tab'; + +export function chooseForkTarget(app: App): Promise { + return new Promise(resolve => { + new ForkTargetModal(app, resolve).open(); + }); +} + +class ForkTargetModal extends Modal { + private resolve: (target: ForkTarget | null) => void; + private resolved = false; + + constructor(app: App, resolve: (target: ForkTarget | null) => void) { + super(app); + this.resolve = resolve; + } + + onOpen() { + this.setTitle(t('chat.fork.chooseTarget')); + this.modalEl.addClass('claudian-fork-target-modal'); + + const list = this.contentEl.createDiv({ cls: 'claudian-fork-target-list' }); + + this.createOption(list, 'current-tab', t('chat.fork.targetCurrentTab')); + this.createOption(list, 'new-tab', t('chat.fork.targetNewTab')); + } + + private createOption(container: HTMLElement, target: ForkTarget, label: string): void { + const item = container.createDiv({ cls: 'claudian-fork-target-option', text: label }); + item.addEventListener('click', () => { + this.resolved = true; + this.resolve(target); + this.close(); + }); + } + + onClose() { + if (!this.resolved) { + this.resolve(null); + } + this.contentEl.empty(); + } +} diff --git a/src/shared/modals/InstructionConfirmModal.ts b/src/shared/modals/InstructionConfirmModal.ts new file mode 100644 index 0000000..03a0958 --- /dev/null +++ b/src/shared/modals/InstructionConfirmModal.ts @@ -0,0 +1,281 @@ +/** + * Claudian - Instruction modal + * + * Unified modal that handles all instruction mode states: + * - Loading (initial processing) + * - Clarification (agent asks question) + * - Confirmation (final instruction review) + */ + +import type { App } from 'obsidian'; +import { Modal, TextAreaComponent } from 'obsidian'; + +export type InstructionDecision = 'accept' | 'reject'; + +type ModalState = 'loading' | 'clarification' | 'confirmation'; + +export interface InstructionModalCallbacks { + onAccept: (finalInstruction: string) => void; + onReject: () => void; + onClarificationSubmit: (response: string) => Promise; +} + +export class InstructionModal extends Modal { + private rawInstruction: string; + private callbacks: InstructionModalCallbacks; + private state: ModalState = 'loading'; + private resolved = false; + + // UI elements + private contentSectionEl: HTMLElement | null = null; + private loadingEl: HTMLElement | null = null; + private clarificationEl: HTMLElement | null = null; + private confirmationEl: HTMLElement | null = null; + private buttonsEl: HTMLElement | null = null; + + // Clarification state + private clarificationTextEl: HTMLElement | null = null; + private responseTextarea: TextAreaComponent | null = null; + private isSubmitting = false; + + // Confirmation state + private refinedInstruction: string = ''; + private editTextarea: TextAreaComponent | null = null; + private isEditing = false; + private refinedDisplayEl: HTMLElement | null = null; + private editContainerEl: HTMLElement | null = null; + private editBtnEl: HTMLButtonElement | null = null; + + constructor( + app: App, + rawInstruction: string, + callbacks: InstructionModalCallbacks + ) { + super(app); + this.rawInstruction = rawInstruction; + this.callbacks = callbacks; + } + + onOpen() { + const { contentEl } = this; + contentEl.addClass('claudian-instruction-modal'); + this.setTitle('Add custom instruction'); + + // User input section (always visible) + const inputSection = contentEl.createDiv({ cls: 'claudian-instruction-section' }); + const inputLabel = inputSection.createDiv({ cls: 'claudian-instruction-label' }); + inputLabel.setText('Your input:'); + const inputText = inputSection.createDiv({ cls: 'claudian-instruction-original' }); + inputText.setText(this.rawInstruction); + + // Main content section (changes based on state) + this.contentSectionEl = contentEl.createDiv({ cls: 'claudian-instruction-content-section' }); + + // Loading state + this.loadingEl = this.contentSectionEl.createDiv({ cls: 'claudian-instruction-loading' }); + this.loadingEl.createDiv({ cls: 'claudian-instruction-spinner' }); + this.loadingEl.createSpan({ text: 'Processing your instruction...' }); + + // Clarification state (hidden initially) + this.clarificationEl = this.contentSectionEl.createDiv({ cls: 'claudian-instruction-clarification-section' }); + this.clarificationEl.addClass('claudian-hidden'); + this.clarificationTextEl = this.clarificationEl.createDiv({ cls: 'claudian-instruction-clarification' }); + + const responseSection = this.clarificationEl.createDiv({ cls: 'claudian-instruction-section' }); + const responseLabel = responseSection.createDiv({ cls: 'claudian-instruction-label' }); + responseLabel.setText('Your response:'); + + this.responseTextarea = new TextAreaComponent(responseSection); + this.responseTextarea.inputEl.addClass('claudian-instruction-response-textarea'); + this.responseTextarea.inputEl.rows = 3; + this.responseTextarea.inputEl.placeholder = 'Provide more details...'; + + this.responseTextarea.inputEl.addEventListener('keydown', (e) => { + // Check !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && !this.isSubmitting) { + e.preventDefault(); + void this.submitClarification(); + } + }); + + // Confirmation state (hidden initially) + this.confirmationEl = this.contentSectionEl.createDiv({ cls: 'claudian-instruction-confirmation-section' }); + this.confirmationEl.addClass('claudian-hidden'); + + // Refined instruction display/edit + const refinedSection = this.confirmationEl.createDiv({ cls: 'claudian-instruction-section' }); + const refinedLabel = refinedSection.createDiv({ cls: 'claudian-instruction-label' }); + refinedLabel.setText('Refined snippet:'); + + this.refinedDisplayEl = refinedSection.createDiv({ cls: 'claudian-instruction-refined' }); + this.editContainerEl = refinedSection.createDiv({ cls: 'claudian-instruction-edit-container' }); + this.editContainerEl.addClass('claudian-hidden'); + + this.editTextarea = new TextAreaComponent(this.editContainerEl); + this.editTextarea.inputEl.addClass('claudian-instruction-edit-textarea'); + this.editTextarea.inputEl.rows = 4; + + // Buttons (changes based on state) + this.buttonsEl = contentEl.createDiv({ cls: 'claudian-instruction-buttons' }); + this.updateButtons(); + + this.showState('loading'); + } + + showClarification(clarification: string) { + if (this.clarificationTextEl) { + this.clarificationTextEl.setText(clarification); + } + if (this.responseTextarea) { + this.responseTextarea.setValue(''); + } + this.isSubmitting = false; + this.showState('clarification'); + this.responseTextarea?.inputEl.focus(); + } + + showConfirmation(refinedInstruction: string) { + this.refinedInstruction = refinedInstruction; + + if (this.refinedDisplayEl) { + this.refinedDisplayEl.setText(refinedInstruction); + } + if (this.editTextarea) { + this.editTextarea.setValue(refinedInstruction); + } + + this.showState('confirmation'); + } + + showError(error: string) { + // Just close - the error notice will be shown by caller + this.resolved = true; + this.close(); + } + + showClarificationLoading() { + this.isSubmitting = true; + if (this.loadingEl) { + this.loadingEl.querySelector('.claudian-instruction-spinner'); + const text = this.loadingEl.querySelector('span'); + if (text) text.textContent = 'Processing...'; + } + this.showState('loading'); + } + + private showState(state: ModalState) { + this.state = state; + + if (this.loadingEl) { + this.loadingEl.toggleClass('claudian-hidden', state !== 'loading'); + } + if (this.clarificationEl) { + this.clarificationEl.toggleClass('claudian-hidden', state !== 'clarification'); + } + if (this.confirmationEl) { + this.confirmationEl.toggleClass('claudian-hidden', state !== 'confirmation'); + } + + this.updateButtons(); + } + + private updateButtons() { + if (!this.buttonsEl) return; + this.buttonsEl.empty(); + + const cancelBtn = this.buttonsEl.createEl('button', { + text: 'Cancel', + cls: 'claudian-instruction-btn claudian-instruction-reject-btn', + attr: { 'aria-label': 'Cancel' } + }); + cancelBtn.addEventListener('click', () => this.handleReject()); + + if (this.state === 'clarification') { + const submitBtn = this.buttonsEl.createEl('button', { + text: 'Submit', + cls: 'claudian-instruction-btn claudian-instruction-accept-btn', + attr: { 'aria-label': 'Submit response' } + }); + submitBtn.addEventListener('click', () => { + void this.submitClarification(); + }); + } else if (this.state === 'confirmation') { + this.editBtnEl = this.buttonsEl.createEl('button', { + text: 'Edit', + cls: 'claudian-instruction-btn claudian-instruction-edit-btn', + attr: { 'aria-label': 'Edit instruction' } + }); + this.editBtnEl.addEventListener('click', () => this.toggleEdit()); + + const acceptBtn = this.buttonsEl.createEl('button', { + text: 'Accept', + cls: 'claudian-instruction-btn claudian-instruction-accept-btn', + attr: { 'aria-label': 'Accept instruction' } + }); + acceptBtn.addEventListener('click', () => this.handleAccept()); + acceptBtn.focus(); + } + } + + private async submitClarification() { + const response = this.responseTextarea?.getValue().trim(); + if (!response || this.isSubmitting) return; + + this.showClarificationLoading(); + + try { + await this.callbacks.onClarificationSubmit(response); + } catch { + // On error, go back to clarification state + this.isSubmitting = false; + this.showState('clarification'); + } + } + + private toggleEdit() { + this.isEditing = !this.isEditing; + + if (this.isEditing) { + this.refinedDisplayEl?.addClass('claudian-hidden'); + this.editContainerEl?.removeClass('claudian-hidden'); + if (this.editBtnEl) this.editBtnEl.setText('Preview'); + this.editTextarea?.inputEl.focus(); + } else { + const edited = this.editTextarea?.getValue() || this.refinedInstruction; + this.refinedInstruction = edited; + if (this.refinedDisplayEl) { + this.refinedDisplayEl.setText(edited); + this.refinedDisplayEl.removeClass('claudian-hidden'); + } + this.editContainerEl?.addClass('claudian-hidden'); + if (this.editBtnEl) this.editBtnEl.setText('Edit'); + } + } + + private handleAccept() { + if (this.resolved) return; + this.resolved = true; + + const finalInstruction = this.isEditing + ? (this.editTextarea?.getValue() || this.refinedInstruction) + : this.refinedInstruction; + + this.callbacks.onAccept(finalInstruction); + this.close(); + } + + private handleReject() { + if (this.resolved) return; + this.resolved = true; + this.callbacks.onReject(); + this.close(); + } + + onClose() { + if (!this.resolved) { + this.resolved = true; + this.callbacks.onReject(); + } + this.contentEl.empty(); + } +} diff --git a/src/shared/settings/EnvSnippetManager.ts b/src/shared/settings/EnvSnippetManager.ts new file mode 100644 index 0000000..0f3fe90 --- /dev/null +++ b/src/shared/settings/EnvSnippetManager.ts @@ -0,0 +1,438 @@ +import type { App } from 'obsidian'; +import { Modal, Notice, setIcon, Setting } from 'obsidian'; + +import { + getEnvironmentScopeUpdates, + resolveEnvironmentSnippetScope, +} from '../../core/providers/providerEnvironment'; +import type { ProviderHost } from '../../core/providers/ProviderHost'; +import { ProviderRegistry } from '../../core/providers/ProviderRegistry'; +import type { EnvironmentScope, EnvSnippet } from '../../core/types'; +import { t } from '../../i18n/i18n'; +import { formatContextLimit, parseContextLimit, parseEnvironmentVariables } from '../../utils/env'; +import { confirmDelete } from '../modals/ConfirmModal'; + +export class EnvSnippetModal extends Modal { + plugin: ProviderHost; + snippet: EnvSnippet | null; + snippetScope: EnvironmentScope; + onSave: (snippet: EnvSnippet) => void; + + constructor( + app: App, + plugin: ProviderHost, + snippet: EnvSnippet | null, + scope: EnvironmentScope, + onSave: (snippet: EnvSnippet) => void, + ) { + super(app); + this.plugin = plugin; + this.snippet = snippet; + this.snippetScope = scope; + this.onSave = onSave; + } + + onOpen() { + const { contentEl } = this; + this.setTitle(this.snippet ? t('settings.envSnippets.modal.titleEdit') : t('settings.envSnippets.modal.titleSave')); + + this.modalEl.addClass('claudian-env-snippet-modal'); + + let nameEl: HTMLInputElement; + let descEl: HTMLInputElement; + let envVarsEl: HTMLTextAreaElement; + const contextLimitInputs: Map = new Map(); + const modelAliasInputs: Map = new Map(); + let contextLimitsContainer: HTMLElement | null = null; + + // !e.isComposing for IME support (Chinese, Japanese, Korean, etc.) + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.isComposing) { + e.preventDefault(); + saveSnippet(); + } else if (e.key === 'Escape' && !e.isComposing) { + e.preventDefault(); + this.close(); + } + }; + + const saveSnippet = () => { + const name = nameEl.value.trim(); + if (!name) { + new Notice(t('settings.envSnippets.nameRequired')); + return; + } + + const contextLimits: Record = {}; + for (const [modelId, input] of contextLimitInputs) { + const value = input.value.trim(); + if (value) { + const parsed = parseContextLimit(value); + if (parsed !== null) { + contextLimits[modelId] = parsed; + } + } + } + + const modelAliases: Record = {}; + for (const [modelId, input] of modelAliasInputs) { + const value = input.value.trim(); + if (value) { + modelAliases[modelId] = value; + } + } + + const snippet: EnvSnippet = { + id: this.snippet?.id || `snippet-${Date.now()}`, + name, + description: descEl.value.trim(), + envVars: envVarsEl.value, + scope: resolveEnvironmentSnippetScope( + envVarsEl.value, + this.snippet?.scope ?? this.snippetScope, + ), + contextLimits: Object.keys(contextLimits).length > 0 ? contextLimits : undefined, + modelAliases: modelAliasInputs.size > 0 ? modelAliases : undefined, + }; + + this.onSave(snippet); + this.close(); + }; + + const renderContextLimitFields = () => { + if (!contextLimitsContainer) return; + contextLimitsContainer.empty(); + contextLimitInputs.clear(); + modelAliasInputs.clear(); + + const envVars = parseEnvironmentVariables(envVarsEl.value); + const uniqueModelIds = ProviderRegistry.getCustomModelIds(envVars); + + if (uniqueModelIds.size === 0) { + contextLimitsContainer.addClass('claudian-hidden'); + return; + } + + contextLimitsContainer.removeClass('claudian-hidden'); + + const existingLimits = this.snippet?.contextLimits ?? this.plugin.settings.customContextLimits ?? {}; + const existingAliases = this.snippet?.modelAliases ?? this.plugin.settings.customModelAliases ?? {}; + + contextLimitsContainer.createEl('div', { + text: t('settings.customModelOverrides.name'), + cls: 'setting-item-name', + }); + contextLimitsContainer.createEl('div', { + text: t('settings.customModelOverrides.desc'), + cls: 'setting-item-description', + }); + + for (const modelId of uniqueModelIds) { + const row = contextLimitsContainer.createDiv({ cls: 'claudian-snippet-limit-row' }); + row.createSpan({ text: modelId, cls: 'claudian-snippet-limit-model' }); + row.createSpan({ cls: 'claudian-snippet-limit-spacer' }); + + const aliasInput = row.createEl('input', { + type: 'text', + placeholder: t('settings.customModelAliases.placeholder'), + cls: 'claudian-snippet-alias-input', + }); + aliasInput.value = existingAliases[modelId] ?? ''; + aliasInput.setAttribute('aria-label', `Alias for ${modelId}`); + aliasInput.title = 'Custom label shown in the model selector. Leave empty to use the default.'; + modelAliasInputs.set(modelId, aliasInput); + + const input = row.createEl('input', { + type: 'text', + placeholder: '200k', + cls: 'claudian-snippet-limit-input', + }); + input.value = existingLimits[modelId] ? formatContextLimit(existingLimits[modelId]) : ''; + input.setAttribute('aria-label', `Context window for ${modelId}`); + contextLimitInputs.set(modelId, input); + } + }; + + new Setting(contentEl) + .setName(t('settings.envSnippets.modal.name')) + .setDesc(t('settings.envSnippets.modal.namePlaceholder')) + .addText((text) => { + nameEl = text.inputEl; + text.setValue(this.snippet?.name || ''); + text.inputEl.addEventListener('keydown', handleKeyDown); + }); + + new Setting(contentEl) + .setName(t('settings.envSnippets.modal.description')) + .setDesc(t('settings.envSnippets.modal.descPlaceholder')) + .addText((text) => { + descEl = text.inputEl; + text.setValue(this.snippet?.description || ''); + text.inputEl.addEventListener('keydown', handleKeyDown); + }); + + const envVarsSetting = new Setting(contentEl) + .setName(t('settings.envSnippets.modal.envVars')) + .setDesc(t('settings.envSnippets.modal.envVarsPlaceholder')) + .addTextArea((text) => { + envVarsEl = text.inputEl; + const envVarsToShow = this.snippet?.envVars ?? this.plugin.getEnvironmentVariablesForScope(this.snippetScope); + text.setValue(envVarsToShow); + text.inputEl.rows = 8; + text.inputEl.addEventListener('blur', () => renderContextLimitFields()); + }); + envVarsSetting.settingEl.addClass('claudian-env-snippet-setting'); + envVarsSetting.controlEl.addClass('claudian-env-snippet-control'); + + contextLimitsContainer = contentEl.createDiv({ cls: 'claudian-snippet-context-limits' }); + renderContextLimitFields(); + + const buttonContainer = contentEl.createDiv({ cls: 'claudian-snippet-buttons' }); + + const cancelBtn = buttonContainer.createEl('button', { + text: t('settings.envSnippets.modal.cancel'), + cls: 'claudian-cancel-btn' + }); + cancelBtn.addEventListener('click', () => this.close()); + + const saveBtn = buttonContainer.createEl('button', { + text: this.snippet ? t('settings.envSnippets.modal.update') : t('settings.envSnippets.modal.save'), + cls: 'claudian-save-btn' + }); + saveBtn.addEventListener('click', () => saveSnippet()); + + // Focus name input after modal is rendered (timeout for Windows compatibility) + window.setTimeout(() => nameEl?.focus(), 50); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} + +export class EnvSnippetManager { + private containerEl: HTMLElement; + private plugin: ProviderHost; + private scope: EnvironmentScope; + private onContextLimitsChange?: () => void; + + constructor( + containerEl: HTMLElement, + plugin: ProviderHost, + scope: EnvironmentScope, + onContextLimitsChange?: () => void, + ) { + this.containerEl = containerEl; + this.plugin = plugin; + this.scope = scope; + this.onContextLimitsChange = onContextLimitsChange; + this.render(); + } + + private render() { + this.containerEl.empty(); + + const headerEl = this.containerEl.createDiv({ cls: 'claudian-snippet-header' }); + headerEl.createSpan({ text: t('settings.envSnippets.name'), cls: 'claudian-snippet-label' }); + + const saveBtn = headerEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': t('settings.envSnippets.addBtn') }, + }); + setIcon(saveBtn, 'plus'); + saveBtn.addEventListener('click', () => { + void this.saveCurrentEnv(); + }); + + const snippets = this.plugin.settings.envSnippets.filter((snippet) => this.shouldDisplaySnippet(snippet)); + + if (snippets.length === 0) { + const emptyEl = this.containerEl.createDiv({ cls: 'claudian-snippet-empty' }); + emptyEl.setText(t('settings.envSnippets.noSnippets')); + return; + } + + const listEl = this.containerEl.createDiv({ cls: 'claudian-snippet-list' }); + + for (const snippet of snippets) { + const itemEl = listEl.createDiv({ cls: 'claudian-snippet-item' }); + + const infoEl = itemEl.createDiv({ cls: 'claudian-snippet-info' }); + + const nameEl = infoEl.createDiv({ cls: 'claudian-snippet-name' }); + nameEl.setText(snippet.name); + + if (snippet.description) { + const descEl = infoEl.createDiv({ cls: 'claudian-snippet-description' }); + descEl.setText(snippet.description); + } + + const actionsEl = itemEl.createDiv({ cls: 'claudian-snippet-actions' }); + + const restoreBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': 'Insert' }, + }); + setIcon(restoreBtn, 'clipboard-paste'); + restoreBtn.addEventListener('click', () => { + void (async (): Promise => { + try { + await this.insertSnippet(snippet); + } catch { + new Notice('Failed to insert snippet'); + } + })(); + }); + + const editBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': 'Edit' }, + }); + setIcon(editBtn, 'pencil'); + editBtn.addEventListener('click', () => { + this.editSnippet(snippet); + }); + + const deleteBtn = actionsEl.createEl('button', { + cls: 'claudian-settings-action-btn claudian-settings-delete-btn', + attr: { 'aria-label': 'Delete' }, + }); + setIcon(deleteBtn, 'trash-2'); + deleteBtn.addEventListener('click', () => { + void (async (): Promise => { + try { + if (await confirmDelete(this.plugin.app, `Delete environment snippet "${snippet.name}"?`)) { + await this.deleteSnippet(snippet); + } + } catch { + new Notice('Failed to delete snippet'); + } + })(); + }); + } + } + + private async saveCurrentEnv() { + const modal = new EnvSnippetModal( + this.plugin.app, + this.plugin, + null, + this.scope, + (snippet) => { + void (async (): Promise => { + await this.plugin.mutateSettings((settings) => { + settings.envSnippets.push(snippet); + }); + this.render(); + new Notice(`Environment snippet "${snippet.name}" saved`); + })(); + } + ); + modal.open(); + } + + private async insertSnippet(snippet: EnvSnippet) { + const snippetContent = snippet.envVars.trim(); + const updates = getEnvironmentScopeUpdates( + snippetContent, + snippet.scope ?? this.scope, + ); + + if (updates.length === 1) { + const [update] = updates; + this.syncTextareaValue(update.scope, update.envText); + await this.plugin.applyEnvironmentVariables(update.scope, update.envText); + } else if (updates.length > 1) { + for (const update of updates) { + this.syncTextareaValue(update.scope, update.envText); + } + await this.plugin.applyEnvironmentVariablesBatch(updates); + } + + // Legacy snippets without contextLimits don't modify limits + await this.plugin.mutateSettings((settings) => { + if (snippet.contextLimits) { + settings.customContextLimits = { + ...settings.customContextLimits, + ...snippet.contextLimits, + }; + } + + // Legacy snippets without modelAliases don't modify aliases. Snippets saved + // with alias fields clear aliases for their own model IDs when left empty. + if (snippet.modelAliases) { + const modelIds = ProviderRegistry.getCustomModelIds(parseEnvironmentVariables(snippet.envVars)); + const nextAliases = { ...(settings.customModelAliases ?? {}) }; + for (const modelId of modelIds) { + const alias = snippet.modelAliases[modelId]?.trim(); + if (alias) { + nextAliases[modelId] = alias; + } else { + delete nextAliases[modelId]; + } + } + settings.customModelAliases = nextAliases; + } + }); + + this.onContextLimitsChange?.(); + const view = this.plugin.app.workspace.getLeavesOfType('claudian-view')[0]?.view as { + refreshModelSelector?(): void; + } | undefined; + view?.refreshModelSelector?.(); + } + + private editSnippet(snippet: EnvSnippet) { + const modal = new EnvSnippetModal( + this.plugin.app, + this.plugin, + snippet, + this.scope, + (updatedSnippet) => { + void (async (): Promise => { + const exists = this.plugin.settings.envSnippets.some(s => s.id === snippet.id); + if (exists) { + await this.plugin.mutateSettings((settings) => { + const index = settings.envSnippets.findIndex(s => s.id === snippet.id); + if (index !== -1) { + settings.envSnippets[index] = updatedSnippet; + } + }); + this.render(); + new Notice(`Environment snippet "${updatedSnippet.name}" updated`); + } + })(); + } + ); + modal.open(); + } + + private async deleteSnippet(snippet: EnvSnippet) { + await this.plugin.mutateSettings((settings) => { + settings.envSnippets = settings.envSnippets.filter(s => s.id !== snippet.id); + }); + this.render(); + new Notice(`Environment snippet "${snippet.name}" deleted`); + } + + public refresh() { + this.render(); + } + + private shouldDisplaySnippet(snippet: EnvSnippet): boolean { + if (this.scope === 'shared') { + return !snippet.scope || snippet.scope === 'shared'; + } + + return snippet.scope === this.scope; + } + + private syncTextareaValue(scope: EnvironmentScope, value: string): void { + const selector = `.claudian-settings-env-textarea[data-env-scope="${scope}"]`; + const envTextarea = (this.containerEl.ownerDocument ?? window.document).querySelector(selector); + if (envTextarea) { + envTextarea.value = value; + } + } +} diff --git a/src/shared/settings/EnvironmentSettingsSection.ts b/src/shared/settings/EnvironmentSettingsSection.ts new file mode 100644 index 0000000..e6fde85 --- /dev/null +++ b/src/shared/settings/EnvironmentSettingsSection.ts @@ -0,0 +1,85 @@ +import { Setting } from 'obsidian'; + +import { getEnvironmentReviewKeysForScope } from '../../core/providers/providerEnvironment'; +import type { ProviderHost } from '../../core/providers/ProviderHost'; +import type { EnvironmentScope } from '../../core/types/settings'; +import { EnvSnippetManager } from './EnvSnippetManager'; + +interface EnvironmentSettingsSectionOptions { + container: HTMLElement; + plugin: ProviderHost; + scope: EnvironmentScope; + heading?: string; + name: string; + desc: string; + placeholder: string; + renderCustomContextLimits?: (container: HTMLElement) => void; +} + +export function renderEnvironmentSettingsSection( + options: EnvironmentSettingsSectionOptions, +): void { + const { + container, + plugin, + scope, + heading, + name, + desc, + placeholder, + renderCustomContextLimits, + } = options; + + if (heading) { + new Setting(container).setName(heading).setHeading(); + } + + let envTextarea: HTMLTextAreaElement | null = null; + const reviewEl = container.createDiv({ + cls: 'claudian-env-review-warning claudian-setting-validation claudian-setting-validation-warning claudian-hidden', + }); + + const updateReviewWarning = () => { + const reviewKeys = getEnvironmentReviewKeysForScope(envTextarea?.value ?? '', scope); + if (reviewKeys.length === 0) { + reviewEl.toggleClass('claudian-hidden', true); + reviewEl.empty(); + return; + } + + reviewEl.setText(`Review environment ownership for: ${reviewKeys.join(', ')}`); + reviewEl.toggleClass('claudian-hidden', false); + }; + + new Setting(container) + .setName(name) + .setDesc(desc) + .addTextArea((text) => { + text + .setPlaceholder(placeholder) + .setValue(plugin.getEnvironmentVariablesForScope(scope)); + text.inputEl.rows = 6; + text.inputEl.cols = 50; + text.inputEl.addClass('claudian-settings-env-textarea'); + text.inputEl.dataset.envScope = scope; + text.inputEl.addEventListener('input', () => updateReviewWarning()); + text.inputEl.addEventListener('blur', () => { + void (async (): Promise => { + await plugin.applyEnvironmentVariables(scope, text.inputEl.value); + renderCustomContextLimits?.(contextLimitsContainer); + updateReviewWarning(); + })(); + }); + envTextarea = text.inputEl; + }); + + updateReviewWarning(); + + const contextLimitsContainer = container.createDiv({ cls: 'claudian-context-limits-container' }); + renderCustomContextLimits?.(contextLimitsContainer); + + const envSnippetsContainer = container.createDiv({ cls: 'claudian-env-snippets-container' }); + new EnvSnippetManager(envSnippetsContainer, plugin, scope, () => { + renderCustomContextLimits?.(contextLimitsContainer); + }); +} diff --git a/src/shared/settings/McpServerModal.ts b/src/shared/settings/McpServerModal.ts new file mode 100644 index 0000000..4c70fe0 --- /dev/null +++ b/src/shared/settings/McpServerModal.ts @@ -0,0 +1,335 @@ +import type { App } from 'obsidian'; +import { Modal, Notice, Setting } from 'obsidian'; + +import type { + ManagedMcpServer, + McpHttpServerConfig, + McpServerConfig, + McpServerType, + McpSSEServerConfig, + McpStdioServerConfig, +} from '../../core/types'; +import { DEFAULT_MCP_SERVER, getMcpServerType } from '../../core/types'; +import { parseCommand } from '../../utils/mcp'; + +export class McpServerModal extends Modal { + private existingServer: ManagedMcpServer | null; + private onSave: (server: ManagedMcpServer) => void; + + private serverName = ''; + private serverType: McpServerType = 'stdio'; + private enabled = DEFAULT_MCP_SERVER.enabled; + private contextSaving = DEFAULT_MCP_SERVER.contextSaving; + private command = ''; + private env = ''; + private url = ''; + private headers = ''; + private typeFieldsEl: HTMLElement | null = null; + private nameInputEl: HTMLInputElement | null = null; + + constructor( + app: App, + existingServer: ManagedMcpServer | null, + onSave: (server: ManagedMcpServer) => void, + initialType?: McpServerType, + prefillConfig?: { name: string; config: McpServerConfig } + ) { + super(app); + this.existingServer = existingServer; + this.onSave = onSave; + + if (existingServer) { + this.serverName = existingServer.name; + this.serverType = getMcpServerType(existingServer.config); + this.enabled = existingServer.enabled; + this.contextSaving = existingServer.contextSaving; + this.initFromConfig(existingServer.config); + } else if (prefillConfig) { + this.serverName = prefillConfig.name; + this.serverType = getMcpServerType(prefillConfig.config); + this.initFromConfig(prefillConfig.config); + } else if (initialType) { + this.serverType = initialType; + } + } + + private initFromConfig(config: McpServerConfig) { + const type = getMcpServerType(config); + if (type === 'stdio') { + const stdioConfig = config as McpStdioServerConfig; + if (stdioConfig.args && stdioConfig.args.length > 0) { + this.command = stdioConfig.command + ' ' + stdioConfig.args.join(' '); + } else { + this.command = stdioConfig.command; + } + this.env = this.envRecordToString(stdioConfig.env); + } else { + const urlConfig = config as McpSSEServerConfig | McpHttpServerConfig; + this.url = urlConfig.url; + this.headers = this.envRecordToString(urlConfig.headers); + } + } + + onOpen() { + this.setTitle(this.existingServer ? 'Edit MCP Server' : 'Add MCP Server'); + this.modalEl.addClass('claudian-mcp-modal'); + + const { contentEl } = this; + + new Setting(contentEl) + .setName('Server name') + .setDesc('Unique identifier for this server') + .addText((text) => { + this.nameInputEl = text.inputEl; + text.setValue(this.serverName); + text.setPlaceholder('My-mcp-server'); + text.onChange((value) => { + this.serverName = value; + }); + text.inputEl.addEventListener('keydown', (e) => this.handleKeyDown(e)); + }); + + new Setting(contentEl) + .setName('Type') + .setDesc('Server connection type') + .addDropdown((dropdown) => { + dropdown.addOption('stdio', 'Stdio (local command)'); + dropdown.addOption('sse', 'Sse (server-sent events)'); + dropdown.addOption('http', 'HTTP (HTTP endpoint)'); + dropdown.setValue(this.serverType); + dropdown.onChange((value) => { + this.serverType = value as McpServerType; + this.renderTypeFields(); + }); + }); + + this.typeFieldsEl = contentEl.createDiv({ cls: 'claudian-mcp-type-fields' }); + this.renderTypeFields(); + + new Setting(contentEl) + .setName('Enabled') + .setDesc('Whether this server is active') + .addToggle((toggle) => { + toggle.setValue(this.enabled); + toggle.onChange((value) => { + this.enabled = value; + }); + }); + + new Setting(contentEl) + .setName('Context-saving mode') + .setDesc('Hide tools from agent unless @-mentioned (saves context window)') + .addToggle((toggle) => { + toggle.setValue(this.contextSaving); + toggle.onChange((value) => { + this.contextSaving = value; + }); + }); + + const buttonContainer = contentEl.createDiv({ cls: 'claudian-mcp-buttons' }); + + const cancelBtn = buttonContainer.createEl('button', { + text: 'Cancel', + cls: 'claudian-cancel-btn', + }); + cancelBtn.addEventListener('click', () => this.close()); + + const saveBtn = buttonContainer.createEl('button', { + text: this.existingServer ? 'Update' : 'Add', + cls: 'claudian-save-btn mod-cta', + }); + saveBtn.addEventListener('click', () => this.save()); + } + + private renderTypeFields() { + if (!this.typeFieldsEl) return; + this.typeFieldsEl.empty(); + + if (this.serverType === 'stdio') { + this.renderStdioFields(); + } else { + this.renderUrlFields(); + } + } + + private renderStdioFields() { + if (!this.typeFieldsEl) return; + + const cmdSetting = new Setting(this.typeFieldsEl) + .setName('Command') + .setDesc('Full command with arguments'); + cmdSetting.settingEl.addClass('claudian-mcp-cmd-setting'); + + const cmdTextarea = cmdSetting.controlEl.createEl('textarea', { + cls: 'claudian-mcp-cmd-textarea', + }); + cmdTextarea.value = this.command; + cmdTextarea.placeholder = 'Docker exec -i mcp-server python -m src.server'; + cmdTextarea.rows = 2; + cmdTextarea.addEventListener('input', () => { + this.command = cmdTextarea.value; + }); + + const envSetting = new Setting(this.typeFieldsEl) + .setName('Environment variables') + .setDesc('Key=value per line (optional)'); + envSetting.settingEl.addClass('claudian-mcp-env-setting'); + + const envTextarea = envSetting.controlEl.createEl('textarea', { + cls: 'claudian-mcp-env-textarea', + }); + envTextarea.value = this.env; + envTextarea.placeholder = 'API_key=your-key'; + envTextarea.rows = 2; + envTextarea.addEventListener('input', () => { + this.env = envTextarea.value; + }); + } + + private renderUrlFields() { + if (!this.typeFieldsEl) return; + + new Setting(this.typeFieldsEl) + .setName('URL') + .setDesc(this.serverType === 'sse' ? 'SSE endpoint URL' : 'HTTP endpoint URL') + .addText((text) => { + text.setValue(this.url); + text.setPlaceholder('HTTP://localhost:3000/sse'); + text.onChange((value) => { + this.url = value; + }); + text.inputEl.addEventListener('keydown', (e) => this.handleKeyDown(e)); + }); + + const headersSetting = new Setting(this.typeFieldsEl) + .setName('Headers') + .setDesc('HTTP headers (key=value per line)'); + headersSetting.settingEl.addClass('claudian-mcp-env-setting'); + + const headersTextarea = headersSetting.controlEl.createEl('textarea', { + cls: 'claudian-mcp-env-textarea', + }); + headersTextarea.value = this.headers; + headersTextarea.placeholder = 'Authorization=bearer token\ncontent-type=application/JSON'; + headersTextarea.rows = 3; + headersTextarea.addEventListener('input', () => { + this.headers = headersTextarea.value; + }); + } + + private handleKeyDown(e: KeyboardEvent) { + // !e.isComposing for IME support + if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) { + e.preventDefault(); + this.save(); + } else if (e.key === 'Escape' && !e.isComposing) { + e.preventDefault(); + this.close(); + } + } + + private save() { + const name = this.serverName.trim(); + if (!name) { + new Notice('Please enter a server name'); + this.nameInputEl?.focus(); + return; + } + + if (!/^[a-zA-Z0-9._-]+$/.test(name)) { + new Notice('Server name can only contain letters, numbers, dots, hyphens, and underscores'); + this.nameInputEl?.focus(); + return; + } + + let config: McpServerConfig; + + if (this.serverType === 'stdio') { + const fullCommand = this.command.trim(); + if (!fullCommand) { + new Notice('Please enter a command'); + return; + } + + const { cmd, args } = parseCommand(fullCommand); + const stdioConfig: McpStdioServerConfig = { command: cmd }; + + if (args.length > 0) { + stdioConfig.args = args; + } + + const env = this.parseEnvString(this.env); + if (Object.keys(env).length > 0) { + stdioConfig.env = env; + } + + config = stdioConfig; + } else { + const url = this.url.trim(); + if (!url) { + new Notice('Please enter a URL'); + return; + } + + if (this.serverType === 'sse') { + const sseConfig: McpSSEServerConfig = { type: 'sse', url }; + const headers = this.parseEnvString(this.headers); + if (Object.keys(headers).length > 0) { + sseConfig.headers = headers; + } + config = sseConfig; + } else { + const httpConfig: McpHttpServerConfig = { type: 'http', url }; + const headers = this.parseEnvString(this.headers); + if (Object.keys(headers).length > 0) { + httpConfig.headers = headers; + } + config = httpConfig; + } + } + + const server: ManagedMcpServer = { + name, + config, + enabled: this.enabled, + contextSaving: this.contextSaving, + disabledTools: this.existingServer?.disabledTools, + }; + + this.onSave(server); + this.close(); + } + + private parseEnvString(envStr: string): Record { + const result: Record = {}; + if (!envStr.trim()) return result; + + for (const line of envStr.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const eqIndex = trimmed.indexOf('='); + if (eqIndex === -1) continue; + + const key = trimmed.substring(0, eqIndex).trim(); + const value = trimmed.substring(eqIndex + 1).trim(); + + if (key) { + result[key] = value; + } + } + + return result; + } + + private envRecordToString(env: Record | undefined): string { + if (!env) return ''; + return Object.entries(env) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + } + + onClose() { + this.contentEl.empty(); + } +} diff --git a/src/shared/settings/McpSettingsManager.ts b/src/shared/settings/McpSettingsManager.ts new file mode 100644 index 0000000..08ca209 --- /dev/null +++ b/src/shared/settings/McpSettingsManager.ts @@ -0,0 +1,438 @@ +import type { App } from 'obsidian'; +import { Notice, setIcon } from 'obsidian'; + +import { tryParseClipboardConfig } from '../../core/mcp/McpConfigParser'; +import { testMcpServer } from '../../core/mcp/McpTester'; +import type { AppMcpStorage } from '../../core/providers/types'; +import { isNotifiedMutationError } from '../../core/storage/NotifiedMutationError'; +import type { ManagedMcpServer, McpServerConfig, McpServerType } from '../../core/types'; +import { DEFAULT_MCP_SERVER, getMcpServerType } from '../../core/types'; +import { confirmDelete } from '../modals/ConfirmModal'; +import { McpServerModal } from './McpServerModal'; +import { McpTestModal } from './McpTestModal'; + +export interface McpSettingsManagerDeps { + app: App; + mcpStorage: AppMcpStorage; + broadcastMcpReload: () => Promise; +} + +export class McpSettingsManager { + private app: App; + private containerEl: HTMLElement; + private mcpStorage: AppMcpStorage; + private broadcastMcpReload: () => Promise; + private servers: ManagedMcpServer[] = []; + + constructor(containerEl: HTMLElement, deps: McpSettingsManagerDeps) { + this.app = deps.app; + this.containerEl = containerEl; + this.mcpStorage = deps.mcpStorage; + this.broadcastMcpReload = deps.broadcastMcpReload; + void this.loadAndRender(); + } + + private async loadAndRender() { + this.servers = await this.mcpStorage.load(); + this.render(); + } + + private render() { + this.containerEl.empty(); + + const headerEl = this.containerEl.createDiv({ cls: 'claudian-mcp-header' }); + headerEl.createSpan({ text: 'MCP Servers', cls: 'claudian-mcp-label' }); + + const addContainer = headerEl.createDiv({ cls: 'claudian-mcp-add-container' }); + const addBtn = addContainer.createEl('button', { + cls: 'claudian-settings-action-btn', + attr: { 'aria-label': 'Add' }, + }); + setIcon(addBtn, 'plus'); + + const dropdown = addContainer.createDiv({ cls: 'claudian-mcp-add-dropdown' }); + + const stdioOption = dropdown.createDiv({ cls: 'claudian-mcp-add-option' }); + setIcon(stdioOption.createSpan({ cls: 'claudian-mcp-add-option-icon' }), 'terminal'); + stdioOption.createSpan({ text: 'stdio (local command)' }); + stdioOption.addEventListener('click', () => { + dropdown.removeClass('is-visible'); + this.openModal(null, 'stdio'); + }); + + const httpOption = dropdown.createDiv({ cls: 'claudian-mcp-add-option' }); + setIcon(httpOption.createSpan({ cls: 'claudian-mcp-add-option-icon' }), 'globe'); + httpOption.createSpan({ text: 'http / sse (remote)' }); + httpOption.addEventListener('click', () => { + dropdown.removeClass('is-visible'); + this.openModal(null, 'http'); + }); + + const importOption = dropdown.createDiv({ cls: 'claudian-mcp-add-option' }); + setIcon(importOption.createSpan({ cls: 'claudian-mcp-add-option-icon' }), 'clipboard-paste'); + importOption.createSpan({ text: 'Import from clipboard' }); + importOption.addEventListener('click', () => { + dropdown.removeClass('is-visible'); + void this.importFromClipboard(); + }); + + addBtn.addEventListener('click', (e) => { + e.stopPropagation(); + dropdown.toggleClass('is-visible', !dropdown.hasClass('is-visible')); + }); + + (this.containerEl.ownerDocument ?? window.document).addEventListener('click', () => { + dropdown.removeClass('is-visible'); + }); + + if (this.servers.length === 0) { + const emptyEl = this.containerEl.createDiv({ cls: 'claudian-mcp-empty' }); + emptyEl.setText('No mcp servers configured. Click "add" to add one.'); + return; + } + + const listEl = this.containerEl.createDiv({ cls: 'claudian-mcp-list' }); + for (const server of this.servers) { + this.renderServerItem(listEl, server); + } + } + + private renderServerItem(listEl: HTMLElement, server: ManagedMcpServer) { + const itemEl = listEl.createDiv({ cls: 'claudian-mcp-item' }); + if (!server.enabled) { + itemEl.addClass('claudian-mcp-item-disabled'); + } + + const statusEl = itemEl.createDiv({ cls: 'claudian-mcp-status' }); + statusEl.addClass( + server.enabled ? 'claudian-mcp-status-enabled' : 'claudian-mcp-status-disabled' + ); + + const infoEl = itemEl.createDiv({ cls: 'claudian-mcp-info' }); + + const nameRow = infoEl.createDiv({ cls: 'claudian-mcp-name-row' }); + + const nameEl = nameRow.createSpan({ cls: 'claudian-mcp-name' }); + nameEl.setText(server.name); + + const serverType = getMcpServerType(server.config); + const typeEl = nameRow.createSpan({ cls: 'claudian-mcp-type-badge' }); + typeEl.setText(serverType); + + if (server.contextSaving) { + const csEl = nameRow.createSpan({ cls: 'claudian-mcp-context-saving-badge' }); + csEl.setText('@'); + csEl.setAttribute('title', 'Context-saving: mention with @' + server.name + ' to enable'); + } + + const previewEl = infoEl.createDiv({ cls: 'claudian-mcp-preview' }); + if (server.description) { + previewEl.setText(server.description); + } else { + previewEl.setText(this.getServerPreview(server, serverType)); + } + + const actionsEl = itemEl.createDiv({ cls: 'claudian-mcp-actions' }); + + const testBtn = actionsEl.createEl('button', { + cls: 'claudian-mcp-action-btn', + attr: { 'aria-label': 'Verify (show tools)' }, + }); + setIcon(testBtn, 'zap'); + testBtn.addEventListener('click', () => { + void this.testServer(server); + }); + + const toggleBtn = actionsEl.createEl('button', { + cls: 'claudian-mcp-action-btn', + attr: { 'aria-label': server.enabled ? 'Disable' : 'Enable' }, + }); + setIcon(toggleBtn, server.enabled ? 'toggle-right' : 'toggle-left'); + toggleBtn.addEventListener('click', () => { + void this.toggleServer(server).catch((error: unknown) => { + this.showMutationError(error, 'Failed to update MCP server'); + }); + }); + + const editBtn = actionsEl.createEl('button', { + cls: 'claudian-mcp-action-btn', + attr: { 'aria-label': 'Edit' }, + }); + setIcon(editBtn, 'pencil'); + editBtn.addEventListener('click', () => this.openModal(server)); + + const deleteBtn = actionsEl.createEl('button', { + cls: 'claudian-mcp-action-btn claudian-mcp-delete-btn', + attr: { 'aria-label': 'Delete' }, + }); + setIcon(deleteBtn, 'trash-2'); + deleteBtn.addEventListener('click', () => { + void this.deleteServer(server).catch((error: unknown) => { + this.showMutationError(error, 'Failed to delete MCP server'); + }); + }); + } + + private async testServer(server: ManagedMcpServer) { + const modal = new McpTestModal( + this.app, + server.name, + server.disabledTools, + async (toolName, enabled) => { + await this.updateDisabledTool(server, toolName, enabled); + }, + async (disabledTools) => { + await this.updateAllDisabledTools(server, disabledTools); + } + ); + modal.open(); + + try { + const result = await testMcpServer(server); + modal.setResult(result); + } catch (error) { + modal.setError(error instanceof Error ? error.message : 'Verification failed'); + } + } + + /** Rolls back on save failure; warns on reload failure (since save succeeded). */ + private async updateServerDisabledTools( + server: ManagedMcpServer, + newDisabledTools: string[] | undefined + ): Promise { + const previous = server.disabledTools ? [...server.disabledTools] : undefined; + server.disabledTools = newDisabledTools; + + try { + await this.mcpStorage.save(this.servers); + } catch (error) { + server.disabledTools = previous; + throw error; + } + + try { + await this.broadcastMcpReload(); + } catch { + // Save succeeded but reload failed - don't rollback since disk has correct state + new Notice('Setting saved but reload failed. Changes will apply on next session.'); + } + } + + private async updateDisabledTool( + server: ManagedMcpServer, + toolName: string, + enabled: boolean + ) { + const disabledTools = new Set(server.disabledTools ?? []); + if (enabled) { + disabledTools.delete(toolName); + } else { + disabledTools.add(toolName); + } + await this.updateServerDisabledTools( + server, + disabledTools.size > 0 ? Array.from(disabledTools) : undefined + ); + } + + private async updateAllDisabledTools(server: ManagedMcpServer, disabledTools: string[]) { + await this.updateServerDisabledTools( + server, + disabledTools.length > 0 ? disabledTools : undefined + ); + } + + private getServerPreview(server: ManagedMcpServer, type: McpServerType): string { + if (type === 'stdio') { + const config = server.config as { command: string; args?: string[] }; + const args = config.args?.join(' ') || ''; + return args ? `${config.command} ${args}` : config.command; + } else { + const config = server.config as { url: string }; + return config.url; + } + } + + private openModal(existing: ManagedMcpServer | null, initialType?: McpServerType) { + const modal = new McpServerModal( + this.app, + existing, + (server) => { + void this.saveServer(server, existing).catch((error: unknown) => { + this.showMutationError(error, 'Failed to save MCP server'); + }); + }, + initialType + ); + modal.open(); + } + + private async importFromClipboard() { + try { + const text = await navigator.clipboard.readText(); + if (!text.trim()) { + new Notice('Clipboard is empty'); + return; + } + + const parsed = tryParseClipboardConfig(text); + if (!parsed || parsed.servers.length === 0) { + new Notice('No valid mcp configuration found in clipboard'); + return; + } + + if (parsed.needsName || parsed.servers.length === 1) { + const server = parsed.servers[0]; + const type = getMcpServerType(server.config); + const modal = new McpServerModal( + this.app, + null, + (savedServer) => { + void this.saveServer(savedServer, null).catch((error: unknown) => { + this.showMutationError(error, 'Failed to save MCP server'); + }); + }, + type, + server // Pre-fill with parsed config + ); + modal.open(); + if (parsed.needsName) { + new Notice('Enter a name for the server'); + } + return; + } + + await this.importServers(parsed.servers); + } catch (error) { + if (!isNotifiedMutationError(error)) { + new Notice('Failed to read clipboard'); + } + } + } + + private async saveServer(server: ManagedMcpServer, existing: ManagedMcpServer | null) { + const previousServers = [...this.servers]; + if (existing) { + const index = this.servers.findIndex((s) => s.name === existing.name); + if (index !== -1) { + if (server.name !== existing.name) { + const conflict = this.servers.find((s) => s.name === server.name); + if (conflict) { + new Notice(`Server "${server.name}" already exists`); + return; + } + } + this.servers[index] = server; + } + } else { + const conflict = this.servers.find((s) => s.name === server.name); + if (conflict) { + new Notice(`Server "${server.name}" already exists`); + return; + } + this.servers.push(server); + } + + try { + await this.mcpStorage.save(this.servers); + } catch (error) { + this.servers = previousServers; + throw error; + } + await this.broadcastMcpReload(); + this.render(); + new Notice(existing ? `MCP server "${server.name}" updated` : `MCP server "${server.name}" added`); + } + + private async importServers(servers: Array<{ name: string; config: McpServerConfig }>) { + const previousServers = [...this.servers]; + const added: string[] = []; + const skipped: string[] = []; + + for (const server of servers) { + const name = server.name.trim(); + if (!name || !/^[a-zA-Z0-9._-]+$/.test(name)) { + skipped.push(server.name || ''); + continue; + } + + const conflict = this.servers.find((s) => s.name === name); + if (conflict) { + skipped.push(name); + continue; + } + + this.servers.push({ + name, + config: server.config, + enabled: DEFAULT_MCP_SERVER.enabled, + contextSaving: DEFAULT_MCP_SERVER.contextSaving, + }); + added.push(name); + } + + if (added.length === 0) { + new Notice('No new mcp servers imported'); + return; + } + + try { + await this.mcpStorage.save(this.servers); + } catch (error) { + this.servers = previousServers; + throw error; + } + await this.broadcastMcpReload(); + this.render(); + + let message = `Imported ${added.length} MCP server${added.length > 1 ? 's' : ''}`; + if (skipped.length > 0) { + message += ` (${skipped.length} skipped)`; + } + new Notice(message); + } + + private async toggleServer(server: ManagedMcpServer) { + const previousEnabled = server.enabled; + server.enabled = !server.enabled; + try { + await this.mcpStorage.save(this.servers); + } catch (error) { + server.enabled = previousEnabled; + throw error; + } + await this.broadcastMcpReload(); + this.render(); + new Notice(`MCP server "${server.name}" ${server.enabled ? 'enabled' : 'disabled'}`); + } + + private async deleteServer(server: ManagedMcpServer) { + if (!(await confirmDelete(this.app, `Delete MCP server "${server.name}"?`))) { + return; + } + + const previousServers = this.servers; + this.servers = this.servers.filter((s) => s.name !== server.name); + try { + await this.mcpStorage.save(this.servers); + } catch (error) { + this.servers = previousServers; + throw error; + } + await this.broadcastMcpReload(); + this.render(); + new Notice(`MCP server "${server.name}" deleted`); + } + + /** Refresh the server list (call after external changes). */ + public refresh() { + void this.loadAndRender(); + } + + private showMutationError(error: unknown, fallback: string): void { + if (isNotifiedMutationError(error)) { + return; + } + new Notice(error instanceof Error ? error.message : fallback); + } +} diff --git a/src/shared/settings/McpTestModal.ts b/src/shared/settings/McpTestModal.ts new file mode 100644 index 0000000..684645d --- /dev/null +++ b/src/shared/settings/McpTestModal.ts @@ -0,0 +1,351 @@ +import type { App } from 'obsidian'; +import { Modal, Notice, setIcon } from 'obsidian'; + +import type { McpTestResult, McpTool } from '../../core/mcp/McpTester'; +import { isNotifiedMutationError } from '../../core/storage/NotifiedMutationError'; + +function formatToggleError(error: unknown): string { + if (!(error instanceof Error)) return 'Failed to update tool setting'; + + const msg = error.message.toLowerCase(); + if (msg.includes('permission') || msg.includes('eacces')) { + return 'Permission denied. Check .claude/ folder permissions.'; + } + if (msg.includes('enospc') || msg.includes('disk full') || msg.includes('no space')) { + return 'Disk full. Free up space and try again.'; + } + if (msg.includes('json') || msg.includes('syntax')) { + return 'Config file corrupted. Check .claude/mcp.json'; + } + return error.message || 'Failed to update tool setting'; +} + +const SVG_NS = 'http://www.w3.org/2000/svg'; + +function appendSpinnerSvg(container: HTMLElement): void { + const svg = container.ownerDocument.createElementNS(SVG_NS, 'svg'); + svg.setAttribute('viewBox', '0 0 24 24'); + svg.setAttribute('fill', 'none'); + svg.setAttribute('stroke', 'currentColor'); + svg.setAttribute('stroke-width', '2'); + + const path = container.ownerDocument.createElementNS(SVG_NS, 'path'); + path.setAttribute( + 'd', + 'M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83' + ); + svg.appendChild(path); + + container.appendChild(svg); +} + +export class McpTestModal extends Modal { + private serverName: string; + private result: McpTestResult | null = null; + private loading = true; + private contentEl_: HTMLElement | null = null; + private disabledTools: Set; + private onToolToggle?: (toolName: string, enabled: boolean) => Promise; + private onBulkToggle?: (disabledTools: string[]) => Promise; + private toolToggles: Map = + new Map(); + private toolElements: Map = new Map(); + private toggleAllBtn: HTMLButtonElement | null = null; + private pendingToggle = false; + + constructor( + app: App, + serverName: string, + initialDisabledTools?: string[], + onToolToggle?: (toolName: string, enabled: boolean) => Promise, + onBulkToggle?: (disabledTools: string[]) => Promise + ) { + super(app); + this.serverName = serverName; + this.disabledTools = new Set( + (initialDisabledTools ?? []) + .map((tool) => tool.trim()) + .filter((tool) => tool.length > 0) + ); + this.onToolToggle = onToolToggle; + this.onBulkToggle = onBulkToggle; + } + + onOpen() { + this.setTitle(`Verify: ${this.serverName}`); + this.modalEl.addClass('claudian-mcp-test-modal'); + this.contentEl_ = this.contentEl; + this.renderLoading(); + } + + setResult(result: McpTestResult) { + this.result = result; + this.loading = false; + this.render(); + } + + setError(error: string) { + this.result = { success: false, tools: [], error }; + this.loading = false; + this.render(); + } + + private renderLoading() { + if (!this.contentEl_) return; + this.contentEl_.empty(); + + const loadingEl = this.contentEl_.createDiv({ cls: 'claudian-mcp-test-loading' }); + + const spinnerEl = loadingEl.createDiv({ cls: 'claudian-mcp-test-spinner' }); + appendSpinnerSvg(spinnerEl); + + loadingEl.createSpan({ text: 'Connecting to MCP server...' }); + } + + private render() { + if (!this.contentEl_) return; + this.contentEl_.empty(); + + if (!this.result) { + this.renderLoading(); + return; + } + + const statusEl = this.contentEl_.createDiv({ cls: 'claudian-mcp-test-status' }); + + const iconEl = statusEl.createSpan({ cls: 'claudian-mcp-test-icon' }); + if (this.result.success) { + setIcon(iconEl, 'check-circle'); + iconEl.addClass('success'); + } else { + setIcon(iconEl, 'x-circle'); + iconEl.addClass('error'); + } + + const textEl = statusEl.createSpan({ cls: 'claudian-mcp-test-text' }); + if (this.result.success) { + let statusText = 'Connected successfully'; + if (this.result.serverName) { + statusText += ` to ${this.result.serverName}`; + if (this.result.serverVersion) { + statusText += ` v${this.result.serverVersion}`; + } + } + textEl.setText(statusText); + } else { + textEl.setText('Connection failed'); + } + + if (this.result.error) { + const errorEl = this.contentEl_.createDiv({ cls: 'claudian-mcp-test-error' }); + errorEl.setText(this.result.error); + } + + this.toolToggles.clear(); + this.toolElements.clear(); + + if (this.result.tools.length > 0) { + const toolsSection = this.contentEl_.createDiv({ cls: 'claudian-mcp-test-tools' }); + + const toolsHeader = toolsSection.createDiv({ cls: 'claudian-mcp-test-tools-header' }); + toolsHeader.setText(`Available Tools (${this.result.tools.length})`); + + const toolsList = toolsSection.createDiv({ cls: 'claudian-mcp-test-tools-list' }); + + for (const tool of this.result.tools) { + this.renderTool(toolsList, tool); + } + } else if (this.result.success) { + const noToolsEl = this.contentEl_.createDiv({ cls: 'claudian-mcp-test-no-tools' }); + noToolsEl.setText('No tools information available. Tools will be loaded when used in chat.'); + } + + const buttonContainer = this.contentEl_.createDiv({ cls: 'claudian-mcp-test-buttons' }); + + if (this.result.tools.length > 0 && this.onToolToggle) { + this.toggleAllBtn = buttonContainer.createEl('button', { + cls: 'claudian-mcp-toggle-all-btn', + }); + this.updateToggleAllButton(); + this.toggleAllBtn.addEventListener('click', () => { + void this.handleToggleAll(); + }); + } + + const closeBtn = buttonContainer.createEl('button', { + text: 'Close', + cls: 'mod-cta', + }); + closeBtn.addEventListener('click', () => this.close()); + } + + private renderTool(container: HTMLElement, tool: McpTool) { + const toolEl = container.createDiv({ cls: 'claudian-mcp-test-tool' }); + + const headerEl = toolEl.createDiv({ cls: 'claudian-mcp-test-tool-header' }); + + const iconEl = headerEl.createSpan({ cls: 'claudian-mcp-test-tool-icon' }); + setIcon(iconEl, 'wrench'); + + const nameEl = headerEl.createSpan({ cls: 'claudian-mcp-test-tool-name' }); + nameEl.setText(tool.name); + + const toggleEl = headerEl.createDiv({ cls: 'claudian-mcp-test-tool-toggle' }); + const toggleContainer = toggleEl.createDiv({ cls: 'checkbox-container' }); + const checkbox = toggleContainer.createEl('input', { + type: 'checkbox', + attr: { tabindex: '0' }, + }); + + const isEnabled = !this.disabledTools.has(tool.name); + checkbox.checked = isEnabled; + toggleContainer.toggleClass('is-enabled', isEnabled); + this.updateToolState(toolEl, isEnabled); + + this.toolToggles.set(tool.name, { checkbox, container: toggleContainer }); + this.toolElements.set(tool.name, toolEl); + + if (!this.onToolToggle) { + checkbox.disabled = true; + } else { + // Click on container instead of checkbox change event for cross-browser reliability + toggleContainer.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (checkbox.disabled) return; + checkbox.checked = !checkbox.checked; + void this.handleToolToggle(tool.name, checkbox, toggleContainer); + }); + } + + if (tool.description) { + const descEl = toolEl.createDiv({ cls: 'claudian-mcp-test-tool-desc' }); + descEl.setText(tool.description); + } + } + + private async handleToolToggle( + toolName: string, + checkbox: HTMLInputElement, + container: HTMLElement + ) { + const toolEl = this.toolElements.get(toolName); + if (!toolEl) return; + + const wasDisabled = this.disabledTools.has(toolName); + const nextDisabled = !checkbox.checked; + + if (nextDisabled) { + this.disabledTools.add(toolName); + } else { + this.disabledTools.delete(toolName); + } + + container.toggleClass('is-enabled', !nextDisabled); + this.updateToolState(toolEl, !nextDisabled); + this.updateToggleAllButton(); + checkbox.disabled = true; + + try { + await this.onToolToggle?.(toolName, !nextDisabled); + } catch (error) { + // Rollback + if (nextDisabled) { + this.disabledTools.delete(toolName); + } else { + this.disabledTools.add(toolName); + } + checkbox.checked = !wasDisabled; + container.toggleClass('is-enabled', !wasDisabled); + this.updateToolState(toolEl, !wasDisabled); + this.updateToggleAllButton(); + if (!isNotifiedMutationError(error)) { + new Notice(formatToggleError(error)); + } + } finally { + checkbox.disabled = false; + } + } + + private updateToolState(toolEl: HTMLElement, enabled: boolean) { + toolEl.toggleClass('claudian-mcp-test-tool-disabled', !enabled); + } + + private updateToggleAllButton() { + if (!this.toggleAllBtn || !this.result) return; + + const allEnabled = this.disabledTools.size === 0; + const allDisabled = this.disabledTools.size === this.result.tools.length; + + if (allEnabled) { + this.toggleAllBtn.setText('Disable all'); + this.toggleAllBtn.toggleClass('is-destructive', true); + } else { + this.toggleAllBtn.setText(allDisabled ? 'Enable All' : 'Enable All'); + this.toggleAllBtn.toggleClass('is-destructive', false); + } + } + + private async handleToggleAll() { + if (!this.result || this.pendingToggle || !this.onBulkToggle) return; + + const allEnabled = this.disabledTools.size === 0; + const previousDisabled = new Set(this.disabledTools); + + const newDisabledTools: string[] = allEnabled + ? this.result.tools.map((t) => t.name) // Disable all + : []; // Enable all + + this.pendingToggle = true; + if (this.toggleAllBtn) this.toggleAllBtn.disabled = true; + + for (const { checkbox } of this.toolToggles.values()) { + checkbox.disabled = true; + } + + // Optimistic UI update + this.disabledTools = new Set(newDisabledTools); + for (const tool of this.result.tools) { + const toggle = this.toolToggles.get(tool.name); + const toolEl = this.toolElements.get(tool.name); + if (!toggle || !toolEl) continue; + + const isEnabled = !this.disabledTools.has(tool.name); + toggle.checkbox.checked = isEnabled; + toggle.container.toggleClass('is-enabled', isEnabled); + this.updateToolState(toolEl, isEnabled); + } + this.updateToggleAllButton(); + + try { + await this.onBulkToggle(newDisabledTools); + } catch (error) { + this.disabledTools = previousDisabled; + for (const tool of this.result.tools) { + const toggle = this.toolToggles.get(tool.name); + const toolEl = this.toolElements.get(tool.name); + if (!toggle || !toolEl) continue; + + const isEnabled = !this.disabledTools.has(tool.name); + toggle.checkbox.checked = isEnabled; + toggle.container.toggleClass('is-enabled', isEnabled); + this.updateToolState(toolEl, isEnabled); + } + this.updateToggleAllButton(); + if (!isNotifiedMutationError(error)) { + new Notice(formatToggleError(error)); + } + } + + for (const { checkbox } of this.toolToggles.values()) { + checkbox.disabled = false; + } + + this.pendingToggle = false; + if (this.toggleAllBtn) this.toggleAllBtn.disabled = false; + } + + onClose() { + this.contentEl.empty(); + } +} diff --git a/src/shared/settings/ProviderModelPicker.ts b/src/shared/settings/ProviderModelPicker.ts new file mode 100644 index 0000000..6a9f466 --- /dev/null +++ b/src/shared/settings/ProviderModelPicker.ts @@ -0,0 +1,419 @@ +import { Setting } from 'obsidian'; + +const ALL_PROVIDERS_KEY = 'all'; + +export interface ProviderModelPickerModel { + aliasPlaceholder?: string; + catalogBadge?: string; + description?: string; + id: string; + isAvailable?: boolean; + name: string; + providerKey?: string; + providerLabel?: string; + unavailableMessage?: string; + unavailableTitle?: string; +} + +export interface ProviderModelPickerState { + aliases: Record; + discoveredCount: number; + models: ProviderModelPickerModel[]; + selectedIds: string[]; +} + +export interface ProviderModelPickerOptions { + container: HTMLElement; + emptyCatalogText: string; + failedCatalogText: string; + getState(): ProviderModelPickerState; + initiallyOpen?: boolean; + loadCatalog(force: boolean): Promise<'empty' | 'failed' | 'loaded'>; + loadCatalogOnRender?: boolean; + loadingCatalogText: string; + modifier: string; + onAliasesChange(aliases: Record): Promise; + onModelSelected?(model: ProviderModelPickerModel): Promise; + onSelectedIdsChange(selectedIds: string[]): Promise; + providerName: string; + searchPlaceholder?: string; + settingDescription: string; +} + +export function renderProviderModelPicker(options: ProviderModelPickerOptions): void { + new Setting(options.container) + .setName('Visible models') + .setDesc(options.settingDescription); + + const pickerEl = options.container.createDiv({ + cls: `claudian-provider-model-picker claudian-provider-model-picker--${options.modifier}`, + }); + let searchQuery = ''; + let providerFilter = ALL_PROVIDERS_KEY; + let loadingCatalog = false; + let catalogLoadFailed = false; + + const summaryEl = pickerEl.createDiv({ cls: 'claudian-provider-model-picker-summary' }); + const selectedEl = pickerEl.createDiv({ cls: 'claudian-provider-model-picker-selected' }); + const catalogEl = pickerEl.createEl('details', { cls: 'claudian-provider-model-picker-catalog' }); + catalogEl.open = options.initiallyOpen ?? options.getState().selectedIds.length === 0; + + const catalogSummaryEl = catalogEl.createEl('summary', { + cls: 'claudian-provider-model-picker-catalog-summary', + }); + catalogSummaryEl.createSpan({ + cls: 'claudian-provider-model-picker-catalog-caret', + text: '▸', + }); + catalogSummaryEl.createSpan({ + cls: 'claudian-provider-model-picker-catalog-title', + text: 'Browse models', + }); + const catalogSummaryCountEl = catalogSummaryEl.createSpan({ + cls: 'claudian-provider-model-picker-catalog-count', + }); + + const controlsEl = catalogEl.createDiv({ cls: 'claudian-provider-model-picker-controls' }); + const searchInput = controlsEl.createEl('input', { + cls: 'claudian-provider-model-picker-search', + type: 'search', + }); + searchInput.placeholder = options.searchPlaceholder ?? 'Filter by model, provider, or ID...'; + searchInput.addEventListener('input', () => { + searchQuery = searchInput.value.trim().toLowerCase(); + renderList(); + }); + + const providerSelectEl = controlsEl.createEl('select', { + cls: 'claudian-provider-model-picker-provider', + }); + providerSelectEl.addEventListener('change', () => { + providerFilter = providerSelectEl.value; + renderList(); + }); + + const catalogActionEl = controlsEl.createEl('button', { + cls: 'claudian-provider-model-picker-action', + text: 'Discover', + }); + catalogActionEl.setAttribute('type', 'button'); + catalogActionEl.addEventListener('click', () => { + void loadCatalog(true); + }); + + const listEl = catalogEl.createDiv({ cls: 'claudian-provider-model-picker-list' }); + + const renderSummary = (): void => { + summaryEl.empty(); + const state = options.getState(); + const providerCount = new Set( + state.models.map(model => model.providerKey).filter((key): key is string => Boolean(key)), + ).size; + + summaryEl.createSpan({ text: 'Visible: ' }); + summaryEl.createSpan({ + cls: 'claudian-provider-model-picker-summary-value', + text: String(state.selectedIds.length), + }); + summaryEl.createSpan({ + text: providerCount > 0 + ? ` of ${state.discoveredCount} discovered | ${providerCount} ${providerCount === 1 ? 'provider' : 'providers'}` + : ` of ${state.discoveredCount} discovered`, + }); + + catalogSummaryCountEl.setText( + loadingCatalog + ? 'Loading models...' + : state.discoveredCount > 0 + ? `${state.discoveredCount} available` + : 'No models discovered yet', + ); + catalogActionEl.disabled = loadingCatalog; + catalogActionEl.setText( + loadingCatalog + ? 'Loading...' + : state.discoveredCount > 0 + ? 'Refresh' + : 'Discover', + ); + }; + + const persistAlias = async (modelId: string, value: string): Promise => { + const state = options.getState(); + const existing = state.aliases[modelId] ?? ''; + const next = value.trim(); + if (next === existing) { + return; + } + + const aliases = { ...state.aliases }; + if (next) { + aliases[modelId] = next; + } else { + delete aliases[modelId]; + } + await options.onAliasesChange(aliases); + renderSelected(); + }; + + const renderSelected = (): void => { + selectedEl.empty(); + const state = options.getState(); + if (state.selectedIds.length === 0) { + selectedEl.toggleClass('claudian-hidden', true); + return; + } + + selectedEl.toggleClass('claudian-hidden', false); + const modelsById = new Map(state.models.map(model => [model.id, model] as const)); + const headerEl = selectedEl.createDiv({ cls: 'claudian-provider-model-picker-selected-header' }); + headerEl.createEl('span', { + cls: 'claudian-provider-model-picker-selected-label', + text: `Selected (${state.selectedIds.length})`, + }); + const clearAllButton = headerEl.createEl('button', { + cls: 'claudian-provider-model-picker-selected-clear', + text: 'Clear all', + }); + clearAllButton.setAttribute('type', 'button'); + clearAllButton.setAttribute('aria-label', `Clear all selected ${options.providerName} models`); + clearAllButton.addEventListener('click', () => { + void persistSelectedIds([]); + }); + + const rowsEl = selectedEl.createDiv({ cls: 'claudian-provider-model-picker-selected-rows' }); + for (const modelId of state.selectedIds) { + const model = modelsById.get(modelId) ?? { + id: modelId, + isAvailable: false, + name: modelId, + }; + const defaultLabel = model.aliasPlaceholder + ?? (model.providerLabel ? `${model.providerLabel}/${model.name}` : model.name); + const rowEl = rowsEl.createDiv({ cls: 'claudian-provider-model-picker-selected-row' }); + if (model.isAvailable === false) { + rowEl.classList.add('claudian-provider-model-picker-selected-row--unavailable'); + } + + const infoEl = rowEl.createDiv({ cls: 'claudian-provider-model-picker-selected-info' }); + const titleEl = infoEl.createDiv({ cls: 'claudian-provider-model-picker-selected-title' }); + if (model.providerLabel) { + titleEl.createEl('span', { + cls: 'claudian-provider-model-picker-selected-badge', + text: model.providerLabel, + }); + } + titleEl.createEl('span', { + cls: 'claudian-provider-model-picker-selected-name', + text: model.name, + }); + if (model.isAvailable === false && model.unavailableMessage) { + infoEl.createEl('div', { + cls: 'claudian-provider-model-picker-selected-unavailable', + text: model.unavailableMessage, + }); + } + infoEl.createEl('div', { + cls: 'claudian-provider-model-picker-selected-id', + text: model.id, + }); + + const rowControlsEl = rowEl.createDiv({ cls: 'claudian-provider-model-picker-selected-controls' }); + const aliasInput = rowControlsEl.createEl('input', { + cls: 'claudian-provider-model-picker-selected-alias', + type: 'text', + }); + aliasInput.placeholder = defaultLabel; + aliasInput.value = state.aliases[model.id] ?? ''; + aliasInput.setAttribute('aria-label', `Alias for ${defaultLabel}`); + aliasInput.title = 'Custom label shown in the model selector. Leave empty to use the default.'; + aliasInput.addEventListener('blur', () => { + void persistAlias(model.id, aliasInput.value); + }); + aliasInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + aliasInput.blur(); + } else if (event.key === 'Escape') { + event.preventDefault(); + aliasInput.value = options.getState().aliases[model.id] ?? ''; + aliasInput.blur(); + } + }); + + const removeButton = rowControlsEl.createEl('button', { + cls: 'claudian-provider-model-picker-selected-remove', + text: '×', + }); + removeButton.setAttribute('type', 'button'); + removeButton.setAttribute('aria-label', `Remove ${defaultLabel}`); + removeButton.addEventListener('click', () => { + void persistSelectedIds(options.getState().selectedIds.filter(id => id !== model.id)); + }); + } + }; + + const renderProviderSelect = (): void => { + const providers = new Map(); + const models = options.getState().models; + for (const model of models) { + if (!model.providerKey || !model.providerLabel) { + continue; + } + const existing = providers.get(model.providerKey); + if (existing) { + existing.count += 1; + } else { + providers.set(model.providerKey, { count: 1, label: model.providerLabel }); + } + } + + providerSelectEl.toggleClass('claudian-hidden', providers.size === 0); + providerSelectEl.empty(); + providerSelectEl.createEl('option', { + text: `All providers (${models.length})`, + value: ALL_PROVIDERS_KEY, + }); + for (const [key, { count, label }] of Array.from(providers.entries()) + .sort(([, left], [, right]) => left.label.localeCompare(right.label))) { + providerSelectEl.createEl('option', { + text: `${label} (${count})`, + value: key, + }); + } + + if (providerFilter !== ALL_PROVIDERS_KEY && !providers.has(providerFilter)) { + providerFilter = ALL_PROVIDERS_KEY; + } + providerSelectEl.value = providerFilter; + }; + + const matchesFilter = (model: ProviderModelPickerModel): boolean => { + if (providerFilter !== ALL_PROVIDERS_KEY && model.providerKey !== providerFilter) { + return false; + } + if (!searchQuery) { + return true; + } + + return [model.id, model.name, model.providerLabel ?? '', model.description ?? ''] + .some(value => value.toLowerCase().includes(searchQuery)); + }; + + const persistSelectedIds = async (selectedIds: string[]): Promise => { + await options.onSelectedIdsChange(selectedIds); + renderAll(); + }; + + const renderList = (): void => { + listEl.empty(); + const state = options.getState(); + const selectedIds = new Set(state.selectedIds); + const models = state.models.filter(matchesFilter); + + if (models.length === 0) { + listEl.createDiv({ + cls: 'claudian-provider-model-picker-empty', + text: loadingCatalog + ? options.loadingCatalogText + : catalogLoadFailed + ? options.failedCatalogText + : state.models.length === 0 + ? options.emptyCatalogText + : 'No models match your filter.', + }); + return; + } + + for (const model of models) { + const rowEl = listEl.createEl('label', { cls: 'claudian-provider-model-picker-row' }); + const isSelected = selectedIds.has(model.id); + if (isSelected) { + rowEl.classList.add('claudian-provider-model-picker-row--selected'); + } + rowEl.title = model.id; + + const checkboxEl = rowEl.createEl('input', { type: 'checkbox' }); + checkboxEl.checked = isSelected; + const persistSelection = async (): Promise => { + const selecting = checkboxEl.checked; + const currentIds = options.getState().selectedIds; + const nextIds = selecting + ? [...currentIds, model.id] + : currentIds.filter(id => id !== model.id); + await persistSelectedIds(nextIds); + if (selecting) { + await options.onModelSelected?.(model); + } + }; + checkboxEl.addEventListener('change', () => { + void persistSelection(); + }); + + const textEl = rowEl.createDiv({ cls: 'claudian-provider-model-picker-row-text' }); + const headerEl = textEl.createDiv({ cls: 'claudian-provider-model-picker-row-header' }); + headerEl.createEl('span', { + cls: 'claudian-provider-model-picker-row-name', + text: model.name, + }); + const badgeLabel = model.isAvailable === false + ? 'Unavailable' + : model.catalogBadge ?? model.providerLabel; + if (badgeLabel) { + const badgeEl = headerEl.createEl('span', { + cls: 'claudian-provider-model-picker-row-badge', + text: badgeLabel, + }); + if (model.isAvailable === false) { + badgeEl.classList.add('claudian-provider-model-picker-row-badge--unavailable'); + badgeEl.title = model.unavailableTitle ?? `Configured model not currently reported by ${options.providerName}`; + } + } + textEl.createDiv({ + cls: 'claudian-provider-model-picker-row-meta', + text: model.id, + }); + if (model.description) { + textEl.createDiv({ + cls: 'claudian-provider-model-picker-row-desc', + text: model.description, + }); + } + } + }; + + const renderAll = (): void => { + renderSummary(); + renderSelected(); + renderProviderSelect(); + renderList(); + }; + + const loadCatalog = async (force: boolean): Promise => { + if (loadingCatalog || (!force && options.getState().discoveredCount > 0)) { + return; + } + + loadingCatalog = true; + catalogLoadFailed = false; + renderAll(); + try { + catalogLoadFailed = await options.loadCatalog(force) === 'failed'; + } catch { + catalogLoadFailed = true; + } finally { + loadingCatalog = false; + renderAll(); + } + }; + + renderAll(); + catalogEl.addEventListener('toggle', () => { + if (catalogEl.open) { + void loadCatalog(false); + } + }); + if (options.loadCatalogOnRender) { + void loadCatalog(false); + } +} diff --git a/src/style/AGENTS.md b/src/style/AGENTS.md new file mode 100644 index 0000000..f4ba146 --- /dev/null +++ b/src/style/AGENTS.md @@ -0,0 +1,38 @@ +# CSS Style Guide + +`src/style/` contains modular CSS that builds into root `styles.css`. + +## Structure + +```text +src/style/ +├── base/ # container, animations, variables +├── components/ # header, history, messages, code, thinking, toolcalls, status-panel, input, tabs +├── toolbar/ # model selector, thinking selector, permission toggles, external context, MCP selector +├── features/ # file/image context, inline edit, diff, commands, plan mode, ask-user, resume session +├── modals/ # instruction, MCP, fork target +├── settings/ # shared settings panels and provider settings modules +├── accessibility.css +└── index.css # build order +``` + +## Build Rules + +- `npm run build:css` builds root `styles.css`. +- `npm run dev` and `npm run build` both invoke the CSS build. +- Every new module must be registered in `index.css`; otherwise the CSS build should fail. + +## Conventions + +- Claudian-owned classes use the `.claudian-` prefix. +- Shared Obsidian host selectors and generic state classes may remain unprefixed. +- Prefer BEM-lite names: `.claudian-{block}`, `.claudian-{block}-{element}`, `.claudian-{block}--{modifier}`. +- Avoid `!important` unless overriding Obsidian defaults. +- Use Obsidian CSS variables such as `--background-*`, `--text-*`, and `--interactive-*`. +- Use `var(--font-monospace)` for code blocks. + +## Gotchas + +- Obsidian uses `body.theme-dark` and `body.theme-light` for theme detection. +- Modal z-index must be greater than `1000` to overlay Obsidian UI. +- Keep fixed-format UI dimensions stable so dynamic labels, icons, loading text, and hover states do not shift layout. diff --git a/src/style/CLAUDE.md b/src/style/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/src/style/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/src/style/accessibility.css b/src/style/accessibility.css new file mode 100644 index 0000000..dba7cf7 --- /dev/null +++ b/src/style/accessibility.css @@ -0,0 +1,50 @@ +/* Accessibility - Focus Visible Styles */ + +/* outline + offset + border-radius */ +.claudian-tool-header:focus-visible, +.claudian-thinking-header:focus-visible, +.claudian-subagent-header:focus-visible, +.claudian-input-nav-btn:focus-visible, +.claudian-model-btn:focus-visible, +.claudian-thinking-current:focus-visible { + outline: 2px solid var(--interactive-accent); + outline-offset: 2px; + border-radius: 4px; +} + +/* outline + offset only */ +.claudian-action-btn:focus-visible, +.claudian-toggle-switch:focus-visible, +.claudian-context-chip-main:focus-visible, +.claudian-context-chip-remove:focus-visible, +.claudian-context-more:focus-visible, +.claudian-image-modal-close:focus-visible, +.claudian-approved-remove-btn:focus-visible, +.claudian-save-env-btn:focus-visible, +.claudian-restore-snippet-btn:focus-visible, +.claudian-edit-snippet-btn:focus-visible, +.claudian-delete-snippet-btn:focus-visible, +.claudian-cancel-btn:focus-visible, +.claudian-save-btn:focus-visible, +.claudian-code-lang-label:focus-visible { + outline: 2px solid var(--interactive-accent); + outline-offset: 2px; +} + +.claudian-inline-preview-action:focus-visible { + outline: 2px solid var(--interactive-accent); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .claudian-inline-spinner { + animation: none; + } +} + +/* outline + negative offset + border-radius */ +.claudian-history-item-content:focus-visible { + outline: 2px solid var(--interactive-accent); + outline-offset: -2px; + border-radius: 4px; +} diff --git a/src/style/base/animations.css b/src/style/base/animations.css new file mode 100644 index 0000000..ba4046b --- /dev/null +++ b/src/style/base/animations.css @@ -0,0 +1,44 @@ +@keyframes thinking-pulse { + 0%, + 100% { + opacity: 0.5; + } + + 50% { + opacity: 1; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes external-context-glow { + 0%, + 100% { + filter: drop-shadow(0 0 2px rgba(var(--claudian-brand-rgb), 0.4)); + } + + 50% { + filter: drop-shadow(0 0 6px rgba(var(--claudian-brand-rgb), 0.8)); + } +} + +@keyframes mcp-glow { + 0%, + 100% { + filter: drop-shadow(0 0 2px rgba(124, 58, 237, 0.4)); + } + + 50% { + filter: drop-shadow(0 0 8px rgba(124, 58, 237, 0.9)); + } +} + +@keyframes claudian-spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/style/base/container.css b/src/style/base/container.css new file mode 100644 index 0000000..cb2b9d2 --- /dev/null +++ b/src/style/base/container.css @@ -0,0 +1,20 @@ +.claudian-container { + display: flex; + flex-direction: column; + height: 100%; + padding: 0; + overflow: hidden; + font-family: var(--font-text); +} + +.claudian-provider-icon-variant--dark { + display: none; +} + +body.theme-dark .claudian-provider-icon-variant--light { + display: none; +} + +body.theme-dark .claudian-provider-icon-variant--dark { + display: inline; +} diff --git a/src/style/base/variables.css b/src/style/base/variables.css new file mode 100644 index 0000000..c196a73 --- /dev/null +++ b/src/style/base/variables.css @@ -0,0 +1,46 @@ +/* Brand & semantic color tokens */ +.claudian-container { + --claudian-brand: #D97757; + --claudian-brand-rgb: 217, 119, 87; + --claudian-brand-claude: #D97757; + --claudian-brand-claude-rgb: 217, 119, 87; + --claudian-brand-codex: #d0d0d0; + --claudian-brand-codex-rgb: 208, 208, 208; + --claudian-brand-opencode: #B8B8B8; + --claudian-brand-opencode-rgb: 184, 184, 184; + --claudian-brand-pi: #ffffff; + --claudian-brand-pi-rgb: 255, 255, 255; + --claudian-error: #dc3545; + --claudian-error-rgb: 220, 53, 69; + --claudian-compact: #5bc0de; +} + +body.theme-light .claudian-container { + --claudian-brand-codex: #000000; + --claudian-brand-codex-rgb: 0, 0, 0; + --claudian-brand-opencode: #707070; + --claudian-brand-opencode-rgb: 112, 112, 112; + --claudian-brand-pi: #000000; + --claudian-brand-pi-rgb: 0, 0, 0; +} + +/* Active-provider alias for single-provider surfaces. */ +.claudian-container[data-provider="claude"] { + --claudian-brand: var(--claudian-brand-claude); + --claudian-brand-rgb: var(--claudian-brand-claude-rgb); +} + +.claudian-container[data-provider="codex"] { + --claudian-brand: var(--claudian-brand-codex); + --claudian-brand-rgb: var(--claudian-brand-codex-rgb); +} + +.claudian-container[data-provider="opencode"] { + --claudian-brand: var(--claudian-brand-opencode); + --claudian-brand-rgb: var(--claudian-brand-opencode-rgb); +} + +.claudian-container[data-provider="pi"] { + --claudian-brand: var(--claudian-brand-pi); + --claudian-brand-rgb: var(--claudian-brand-pi-rgb); +} diff --git a/src/style/base/visibility.css b/src/style/base/visibility.css new file mode 100644 index 0000000..ac0ffc4 --- /dev/null +++ b/src/style/base/visibility.css @@ -0,0 +1,15 @@ +/* + * Visibility utilities are imported late so they can override component display + * defaults without importance flags. + */ +.claudian-hidden.claudian-hidden { + display: none; +} + +.claudian-visible-block.claudian-visible-block { + display: block; +} + +.claudian-visible-flex.claudian-visible-flex { + display: flex; +} diff --git a/src/style/components/code.css b/src/style/components/code.css new file mode 100644 index 0000000..c68fa38 --- /dev/null +++ b/src/style/components/code.css @@ -0,0 +1,97 @@ +/* Code block wrapper - contains pre + button outside scroll area */ +.claudian-code-wrapper { + position: relative; + margin: 8px 0; +} + +/* Code blocks in chat messages */ +.claudian-code-wrapper pre, +.claudian-message-content pre { + background: rgba(0, 0, 0, 0.2); + padding: 8px 12px; + border-radius: 6px; + overflow-x: auto; + margin: 0; +} + +/* Light mode: use a lighter background so hljs comment colors stay readable */ +body.theme-light .claudian-code-wrapper pre, +body.theme-light .claudian-message-content pre { + background: rgba(0, 0, 0, 0.08); +} + +/* Code blocks without language - wrap content */ +.claudian-code-wrapper:not(.has-language) pre { + white-space: pre-wrap; + word-wrap: break-word; + overflow-x: hidden; +} + +/* Unwrapped pre still needs margin */ +.claudian-message-content>pre { + margin: 8px 0; +} + +.claudian-message-content code { + font-family: var(--font-monospace); + font-size: 13px; +} + +/* Clickable language label - positioned outside scroll area */ +.claudian-code-wrapper .claudian-code-lang-label { + position: absolute; + top: 6px; + inset-inline-end: 6px; + padding: 2px 8px; + font-size: 12px; + font-family: var(--font-monospace); + color: var(--text-faint); + background: var(--background-primary); + border-radius: 3px; + cursor: pointer; + z-index: 2; + transition: color 0.15s ease, background 0.15s ease; +} + +.claudian-code-wrapper .claudian-code-lang-label:hover { + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +/* Hide default copy button when language label exists */ +.claudian-code-wrapper.has-language .copy-code-button { + display: none; +} + +/* Copy button - positioned outside scroll area */ +.claudian-code-wrapper .copy-code-button { + position: absolute; + top: 6px; + inset-inline-end: 6px; + padding: 4px 8px; + font-size: 11px; + background: var(--background-primary); + border: none; + color: var(--text-muted); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease; + border-radius: 3px; + z-index: 2; +} + +/* If copy button uses an icon (svg) */ +.claudian-code-wrapper .copy-code-button svg { + width: 14px; + height: 14px; +} + +/* Show copy button on hover */ +.claudian-code-wrapper:not(.has-language):hover .copy-code-button { + opacity: 1; +} + +.claudian-code-wrapper .copy-code-button:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} \ No newline at end of file diff --git a/src/style/components/context-footer.css b/src/style/components/context-footer.css new file mode 100644 index 0000000..b633d6e --- /dev/null +++ b/src/style/components/context-footer.css @@ -0,0 +1,76 @@ +/* Context usage meter (inline in toolbar) */ + +.claudian-context-meter { + position: relative; + display: flex; + align-items: center; + gap: 4px; + margin-inline-start: 8px; + cursor: default; +} + +/* Custom tooltip */ +.claudian-context-meter::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 6px; + padding: 4px 8px; + font-size: 11px; + color: var(--text-normal); + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; + pointer-events: none; + z-index: 100; +} + +.claudian-context-meter:hover::after { + opacity: 1; + visibility: visible; +} + +.claudian-context-meter-gauge { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; +} + +.claudian-context-meter-gauge svg { + width: 16px; + height: 16px; +} + +.claudian-meter-bg { + stroke: var(--background-modifier-border); +} + +.claudian-meter-fill { + stroke: var(--claudian-brand); + transition: stroke-dashoffset 0.3s ease, stroke 0.3s ease; +} + +.claudian-context-meter-percent { + font-size: 11px; + color: var(--claudian-brand); + min-width: 24px; + text-align: end; + transition: color 0.3s ease; +} + +/* Warning state (> 80%) - pale red */ +.claudian-context-meter.warning .claudian-meter-fill { + stroke: #E57373; +} + +.claudian-context-meter.warning .claudian-context-meter-percent { + color: #E57373; +} diff --git a/src/style/components/context-tray.css b/src/style/components/context-tray.css new file mode 100644 index 0000000..3e7d820 --- /dev/null +++ b/src/style/components/context-tray.css @@ -0,0 +1,160 @@ +/* Composer context tray */ +.claudian-context-row { + display: none; + align-items: center; + flex-wrap: wrap; + flex-shrink: 0; + gap: 6px; + min-width: 0; + max-width: 100%; + padding: 8px 8px 0; +} + +.claudian-context-row.has-content { + display: flex; +} + +.claudian-context-chip { + display: inline-flex; + align-items: center; + flex: 0 1 auto; + gap: 4px; + min-width: 0; + max-width: min(200px, 100%); + height: 24px; + padding: 3px 6px 3px 8px; + overflow: hidden; + box-sizing: border-box; + border: 1px solid var(--background-modifier-border); + border-radius: 12px; + background: var(--background-primary); + color: var(--text-normal); + font-size: 12px; + line-height: 1; +} + +.claudian-context-chip:hover { + background: var(--background-modifier-hover); +} + +.claudian-context-chip-main { + display: flex; + align-items: center; + gap: 4px; + flex: 1 1 auto; + min-width: 0; + height: 16px; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: inherit; + font-family: inherit; + font-size: 12px; + line-height: 1; + text-align: start; + box-shadow: none; +} + +/* Obsidian applies a button surface to clickable note and image labels. */ +button.claudian-context-chip-main, +button.claudian-context-chip-main:hover, +button.claudian-context-chip-main:focus, +button.claudian-context-chip-main:active { + appearance: none; + margin: 0; + border: 0 !important; + background: transparent !important; + background-image: none !important; + box-shadow: none !important; + cursor: pointer; +} + +.claudian-context-chip-icon { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 12px; + width: 12px; + height: 12px; + color: var(--text-muted); +} + +.claudian-context-chip-icon svg { + width: 12px; + height: 12px; +} + +.claudian-context-chip-label { + min-width: 0; + overflow: hidden; + color: var(--text-normal); + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-context-chip-remove { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 16px; + width: 16px; + height: 16px; + padding: 0; + border: 0; + border-radius: 50%; + background: transparent; + color: var(--text-muted); + font-family: inherit; + font-size: 14px; + line-height: 1; + cursor: pointer; + box-shadow: none; + transition: color 0.15s ease, background 0.15s ease; +} + +button.claudian-context-chip-remove, +button.claudian-context-chip-remove:focus, +button.claudian-context-chip-remove:active { + appearance: none; + margin: 0; + border: 0 !important; + background: transparent !important; + background-image: none !important; + box-shadow: none !important; +} + +button.claudian-context-chip-remove:hover { + border: 0; + background: var(--background-modifier-hover) !important; + color: var(--text-normal); + box-shadow: none; +} + +.claudian-context-chip--overflow-hidden { + display: none; +} + +.claudian-context-more { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + height: 24px; + padding: 0 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 12px; + background: var(--background-primary); + color: var(--text-muted); + font-family: inherit; + font-size: 11px; + line-height: 1; + cursor: pointer; + box-shadow: none; +} + +.claudian-context-more:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); + box-shadow: none; +} diff --git a/src/style/components/header.css b/src/style/components/header.css new file mode 100644 index 0000000..d41f2d5 --- /dev/null +++ b/src/style/components/header.css @@ -0,0 +1,27 @@ +/* Header - logo and title */ +.claudian-header { + position: relative; + display: flex; + align-items: center; + padding: 0 12px 12px 12px; +} + +.claudian-title { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; +} + +.claudian-title-text { + margin: 0; + font-size: 14px; + font-weight: 600; +} + +.claudian-logo { + display: flex; + align-items: center; + color: var(--claudian-brand); +} diff --git a/src/style/components/history.css b/src/style/components/history.css new file mode 100644 index 0000000..66d2940 --- /dev/null +++ b/src/style/components/history.css @@ -0,0 +1,221 @@ +.claudian-history-container { + position: static; +} + +/* History dropup menu (opens upward since it's at bottom of view) */ +.claudian-history-menu { + display: none; + position: absolute; + bottom: 100%; + margin-bottom: 4px; + background: var(--background-secondary); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.25); + z-index: 1000; + max-height: 400px; + overflow: hidden; + inset-inline-start: 0; + inset-inline-end: 0; +} + +.claudian-history-menu.visible { + display: block; +} + +.claudian-history-header { + padding: 8px 12px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.claudian-history-list { + max-height: 350px; + overflow-y: auto; +} + +.claudian-history-empty { + padding: 16px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.claudian-history-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--background-modifier-border); + transition: background 0.15s ease; +} + +.claudian-history-item-icon { + display: flex; + align-items: center; + color: var(--text-muted); + flex-shrink: 0; +} + +.claudian-history-item-icon svg { + width: 16px; + height: 16px; +} + +.claudian-history-item:last-child { + border-bottom: none; +} + +.claudian-history-item:hover { + background: var(--background-modifier-hover); +} + +.claudian-history-item.active { + background: var(--background-modifier-hover); + border-inline-start: 2px solid var(--interactive-accent); + padding-inline-start: 10px; +} + +.claudian-history-item.active:hover { + background: var(--background-modifier-active-hover, var(--background-modifier-hover)); +} + +.claudian-history-item.active .claudian-history-item-icon { + color: var(--interactive-accent); +} + +.claudian-history-item.active .claudian-history-item-content { + cursor: default; +} + +.claudian-history-item.active .claudian-history-item-date { + color: var(--text-faint); +} + +.claudian-history-item.open { + background: var(--background-modifier-hover); + border-inline-start: 2px solid var(--interactive-accent); + padding-inline-start: 10px; +} + +.claudian-history-item.open:hover { + background: var(--background-modifier-active-hover, var(--background-modifier-hover)); +} + +.claudian-history-item.open .claudian-history-item-icon { + color: var(--interactive-accent); +} + +.claudian-history-item.open .claudian-history-item-date { + color: var(--text-muted); +} + +.claudian-history-item.running .claudian-history-item-icon { + color: var(--interactive-accent); +} + +.claudian-history-item.running .claudian-history-item-icon svg { + animation: spin 1s linear infinite; +} + +.claudian-history-item.running .claudian-history-item-date { + color: var(--interactive-accent); + font-weight: 500; +} + +.claudian-history-item-content { + flex: 1; + min-width: 0; + cursor: pointer; +} + +.claudian-history-item-title { + font-size: 13px; + font-weight: 500; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.claudian-history-item-date { + font-size: 11px; + color: var(--text-faint); + margin-top: 2px; +} + +.claudian-history-item-actions { + display: flex; + gap: 4px; + margin-inline-start: 8px; + opacity: 0; + transition: opacity 0.15s ease; +} + +.claudian-history-item:hover .claudian-history-item-actions { + opacity: 1; +} + +.claudian-history-item:focus-within .claudian-history-item-actions { + opacity: 1; +} + +.claudian-action-btn { + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 4px; + color: var(--text-muted); + transition: background 0.15s ease, color 0.15s ease; +} + +.claudian-action-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.claudian-action-btn svg { + width: 14px; + height: 14px; +} + +.claudian-delete-btn:hover { + color: var(--color-red); +} + +.claudian-open-new-tab-btn:hover { + color: var(--interactive-accent); +} + +.claudian-rename-input { + width: 100%; + padding: 2px 4px; + font-size: 13px; + font-weight: 500; + border: 1px solid var(--interactive-accent); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-normal); +} + +.claudian-rename-input:focus { + outline: none; + box-shadow: 0 0 0 2px var(--interactive-accent-hover); +} + +/* Loading indicator for title generation */ +.claudian-action-loading { + display: flex; + align-items: center; + justify-content: center; + animation: spin 1s linear infinite; + opacity: 0.6; + cursor: default; +} diff --git a/src/style/components/input.css b/src/style/components/input.css new file mode 100644 index 0000000..adc23c5 --- /dev/null +++ b/src/style/components/input.css @@ -0,0 +1,216 @@ +/* Input area */ +.claudian-input-container { + position: relative; + padding: 12px 0 0 0; +} + +.claudian-input-footer { + position: relative; + flex-shrink: 0; + padding: 6px 0 0 0; +} + +.claudian-active-input-slot > .claudian-input-composer > .claudian-input-container { + padding-top: 0; +} + +/* Input wrapper (border container) - flex column so textarea expands when no chips */ +.claudian-input-wrapper { + --claudian-input-wrapper-border-color: var(--background-modifier-border); + --claudian-input-wrapper-box-shadow: none; + position: relative; + display: flex; + flex-direction: column; + min-height: 140px; + border: 1px solid var(--claudian-input-wrapper-border-color); + border-radius: 6px; + background: var(--background-primary); + box-shadow: var(--claudian-input-wrapper-box-shadow); +} + +/* Nav row (tab badges start, action icons end) - above input wrapper */ +.claudian-input-nav-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0 6px 0; + min-height: 0; +} + +.claudian-input-nav-row:empty { + display: none; +} + +.claudian-input-nav-content { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-width: 0; +} + +.claudian-input-nav-actions { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + margin-inline-start: auto; +} + +.claudian-input-nav-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + cursor: pointer; + color: var(--text-faint); + transition: color 0.15s ease, background 0.15s ease; +} + +.claudian-input-nav-btn:hover { + color: var(--text-normal); + background: var(--background-modifier-hover); +} + +.claudian-input-nav-btn svg { + width: 16px; + height: 16px; +} + +.claudian-new-tab-btn svg { + width: 16.8px; + height: 16.8px; +} + +.claudian-input-wrapper textarea.claudian-input { + width: 100%; + flex: 1 1 0; + min-height: var(--claudian-textarea-min-height, 60px); + max-height: var(--claudian-textarea-max-height, none); + resize: none; + padding: 8px 10px 10px 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--text-normal); + font-family: inherit; + font-size: 14px; + line-height: 1.4; + box-shadow: none; + overflow-y: auto; + unicode-bidi: plaintext; /* Proper BiDi text handling for mixed RTL/LTR */ +} + +.claudian-input-wrapper textarea.claudian-input:hover, +.claudian-input-wrapper textarea.claudian-input:focus { + outline: none; + border: none; + background: transparent; + box-shadow: none; +} + +.claudian-input-wrapper textarea.claudian-input::placeholder { + color: var(--text-muted); +} + +/* Input toolbar */ +.claudian-input-toolbar { + display: flex; + align-items: center; + justify-content: flex-start; + flex-shrink: 0; + padding: 4px 6px 6px 6px; +} + +/* Composer queue status row (queued follow-up preview + steer action) */ +.claudian-input-queue-row { + display: none; + font-size: 12px; + color: var(--text-muted); + font-style: normal; + align-items: center; + gap: 8px; + padding: 0 2px 8px 2px; +} + +.claudian-queue-indicator-text { + min-width: 0; + flex: 1 1 auto; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.claudian-queue-indicator-actions { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 4px; +} + +.claudian-queue-indicator-action { + flex: 0 0 auto; + padding: 1px 8px; + border: 0; + background: transparent; + color: var(--interactive-accent); + font: inherit; + cursor: pointer; +} + +.claudian-queue-indicator-action:hover { + text-decoration: underline; +} + +.claudian-queue-indicator-action[disabled] { + color: var(--text-faint); + cursor: default; + text-decoration: none; +} + +.claudian-queue-indicator-icon-action { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 22px; + height: 22px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} + +.claudian-queue-indicator-icon-action:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.claudian-queue-indicator-icon-action svg { + width: 14px; + height: 14px; +} + +/* Light blue border when instruction mode is active */ +.claudian-input-wrapper.claudian-input-instruction-mode { + --claudian-input-wrapper-border-color: #60a5fa; + --claudian-input-wrapper-box-shadow: 0 0 0 1px #60a5fa; +} + +/* Pink border when bash mode is active */ +.claudian-input-wrapper.claudian-input-bang-bash-mode { + --claudian-input-wrapper-border-color: #f472b6; + --claudian-input-wrapper-box-shadow: 0 0 0 1px #f472b6; +} + +/* Monospace input while in bash mode */ +.claudian-input-wrapper.claudian-input-bang-bash-mode .claudian-input { + font-family: var(--font-monospace); +} diff --git a/src/style/components/messages.css b/src/style/components/messages.css new file mode 100644 index 0000000..1961a85 --- /dev/null +++ b/src/style/components/messages.css @@ -0,0 +1,262 @@ +/* Messages wrapper (for scroll-to-bottom button positioning) */ +.claudian-messages-wrapper { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.claudian-messages { + flex: 1; + overflow-y: auto; + padding: 12px 0 0; + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Focusable messages panel for vim-style navigation */ +.claudian-messages-focusable:focus { + outline: none; +} + +/* Welcome message - claude.ai style */ +.claudian-welcome { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 20px; + min-height: 200px; +} + +.claudian-welcome-greeting { + font-family: 'Copernicus', 'Tiempos Headline', 'Tiempos', Georgia, 'Times New Roman', serif; + font-size: 28px; + font-weight: 300; + color: var(--text-muted); + letter-spacing: -0.01em; +} + +.claudian-message { + padding: 10px 14px; + border-radius: 8px; + max-width: 95%; + word-wrap: break-word; +} + +.claudian-message-user { + position: relative; + background: rgba(0, 0, 0, 0.3); + align-self: flex-end; + border-end-end-radius: 4px; +} + +/* Text selection in user messages - visible highlight */ +.claudian-message-user ::selection { + background: rgba(255, 255, 255, 0.35); + color: inherit; +} + +.claudian-message-assistant { + background: transparent; + align-self: stretch; + width: 100%; + max-width: 100%; + border-end-start-radius: 4px; + text-align: start; +} + +.claudian-message-content { + line-height: 1.5; + user-select: text; + -webkit-user-select: text; + unicode-bidi: plaintext; /* Proper BiDi text handling for mixed RTL/LTR */ +} + +.claudian-interrupted { + color: #d45d5d; +} + +.claudian-interrupted-hint { + color: var(--text-muted); +} + +.claudian-text-block { + position: relative; + margin: 0; +} + +.claudian-text-copy-btn { + position: absolute; + bottom: 0; + inset-inline-end: 0; + border: none; + color: var(--text-faint); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease, color 0.15s ease; + z-index: 2; + display: flex; + align-items: center; + gap: 4px; +} + +.claudian-text-copy-btn svg { + width: 16px; + height: 16px; +} + +.claudian-text-block:hover .claudian-text-copy-btn { + opacity: 1; +} + +.claudian-text-copy-btn:hover { + color: var(--text-normal); +} + +.claudian-text-copy-btn.copied { + color: var(--text-accent); + font-size: 11px; + font-family: var(--font-monospace); +} + +.claudian-text-block+.claudian-tool-call { + margin-top: 8px; +} + +.claudian-tool-call+.claudian-text-block { + margin-top: 8px; +} + +.claudian-message-content p { + margin: 0 0 8px 0; +} + +.claudian-message-content p:last-child { + margin-bottom: 0; +} + +.claudian-message-content ul, +.claudian-message-content ol { + margin: 8px 0; + padding-inline-start: 20px; +} + +/* Full-width tables */ +.claudian-message-content table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; +} + +.claudian-message-content th, +.claudian-message-content td { + border: 1px solid var(--background-modifier-border); + padding: 6px 10px; + text-align: start; +} + +.claudian-message-content th { + background: var(--background-secondary); + font-weight: 600; +} + +.claudian-message-content tr:hover { + background: var(--background-secondary-alt); +} + +.claudian-messages::-webkit-scrollbar { + width: 6px; +} + +.claudian-messages::-webkit-scrollbar-track { + background: transparent; +} + +.claudian-messages::-webkit-scrollbar-thumb { + background: var(--background-modifier-border); + border-radius: 3px; +} + +.claudian-messages::-webkit-scrollbar-thumb:hover { + background: var(--background-modifier-border-hover); +} + +/* Response duration footer - styled as another line of content */ +.claudian-response-footer { + margin-top: 8px; +} + +.claudian-baked-duration { + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + font-style: italic; +} + +/* Action buttons toolbar below user messages */ +.claudian-user-msg-actions { + position: absolute; + bottom: -20px; + right: 0; + display: flex; + gap: 12px; + opacity: 0; + transition: opacity 0.15s; + z-index: 1; +} + +.claudian-message-user:hover .claudian-user-msg-actions { + opacity: 1; +} + +.claudian-user-msg-actions span { + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-faint); + transition: color 0.15s; +} + +.claudian-user-msg-actions span svg { + width: 16px; + height: 16px; +} + +.claudian-user-msg-actions span:hover { + color: var(--text-normal); +} + +.claudian-user-msg-actions span.copied { + color: var(--text-accent); + font-size: 11px; + font-family: var(--font-monospace); +} + +/* Compact boundary indicator */ +.claudian-compact-boundary { + display: flex; + align-items: center; + gap: 10px; + margin: 12px 0; +} + +.claudian-compact-boundary::before, +.claudian-compact-boundary::after { + content: ''; + flex: 1; + height: 1px; + background: var(--background-modifier-border); +} + +.claudian-compact-boundary-label { + color: var(--text-muted); + font-size: 11px; + white-space: nowrap; +} diff --git a/src/style/components/nav-sidebar.css b/src/style/components/nav-sidebar.css new file mode 100644 index 0000000..e130b40 --- /dev/null +++ b/src/style/components/nav-sidebar.css @@ -0,0 +1,124 @@ +/* Navigation Sidebar */ +.claudian-nav-sidebar { + position: absolute; + right: 2px; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 4px; + z-index: 100; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; +} + +.claudian-nav-sidebar.visible { + opacity: 0.15; + pointer-events: auto; +} + +.claudian-nav-sidebar.visible:hover { + opacity: 1; +} + +.claudian-nav-btn { + width: 32px; + height: 32px; + border-radius: 16px; + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + color: var(--text-muted); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + transition: all 0.2s ease; +} + +.claudian-nav-btn:hover { + background: var(--background-secondary); + color: var(--text-normal); + transform: scale(1.05); +} + +.claudian-nav-btn svg { + width: 18px; + height: 18px; +} + +.claudian-nav-toc-popover { + position: absolute; + right: 42px; + top: 50%; + transform: translateY(-50%); + width: min(260px, calc(100% - 56px)); + max-height: min(420px, calc(100% - 32px)); + z-index: 101; + display: flex; + flex-direction: column; + padding: 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + overflow: hidden; + background-color: #ffffff; + background-image: linear-gradient(var(--background-primary), var(--background-primary)); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); +} + +body.theme-dark .claudian-nav-toc-popover { + background-color: #1e1e1e; +} + +.claudian-nav-toc-title { + padding: 4px 6px 8px; + color: var(--text-muted); + font-size: var(--font-ui-smaller); + font-weight: 600; +} + +.claudian-nav-toc-list { + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + min-height: 0; +} + +.claudian-nav-toc-item { + box-sizing: border-box; + min-height: calc(1.35em + 12px); + max-width: 100%; + padding: 6px 8px; + border-radius: 6px; + color: var(--text-normal); + font-size: var(--font-ui-small); + line-height: 1.35; + cursor: pointer; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-nav-toc-item:hover, +.claudian-nav-toc-item:focus { + background: var(--background-secondary); + outline: none; +} + +.claudian-nav-toc-empty { + padding: 8px; + color: var(--text-muted); + font-size: var(--font-ui-small); + line-height: 1.35; +} + +/* Specific button spacing/grouping if needed */ +.claudian-nav-btn-top { + margin-bottom: 4px; +} + +.claudian-nav-btn-bottom { + margin-top: 4px; +} diff --git a/src/style/components/status-panel.css b/src/style/components/status-panel.css new file mode 100644 index 0000000..c5cc605 --- /dev/null +++ b/src/style/components/status-panel.css @@ -0,0 +1,206 @@ +/* Status Panel - persistent bottom panel for todos and command output */ + +.claudian-status-panel-container { + flex-shrink: 0; + padding: 0 14px; +} + +.claudian-status-panel { + padding-top: 0; +} + +.claudian-status-panel--visible { + padding-top: 12px; +} + +/* Todo Section */ + +.claudian-status-panel-todos { + margin-top: 4px; +} + +.claudian-status-panel-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + cursor: pointer; + border-radius: 4px; + transition: background 0.15s ease; + overflow: hidden; +} + +.claudian-status-panel-header:hover { + background: var(--background-modifier-hover); +} + +.claudian-status-panel-header:focus-visible { + outline: 2px solid var(--interactive-accent); + outline-offset: 2px; +} + +.claudian-status-panel-icon { + display: flex; + align-items: center; + color: var(--text-accent); + flex-shrink: 0; +} + +.claudian-status-panel-icon svg { + width: 16px; + height: 16px; +} + +.claudian-status-panel-label { + font-family: var(--font-monospace); + font-size: 13px; + font-weight: 500; + color: var(--text-normal); +} + +.claudian-status-panel-current { + flex: 1; + font-family: var(--font-monospace); + font-size: 13px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-status-panel-status { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.claudian-status-panel-status svg { + width: 14px; + height: 14px; +} + +.claudian-status-panel-status.status-completed { + color: var(--color-green); +} + +.claudian-status-panel-content { + padding: 2px 0; +} + +/* Individual todo item - shared by status panel and inline tool via .claudian-todo-list-container */ +.claudian-todo-list-container .claudian-todo-item { + display: flex; + align-items: flex-start; + padding: 1px 0; +} + +.claudian-todo-list-container .claudian-todo-status-icon { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.claudian-todo-list-container .claudian-todo-status-icon svg { + width: 12px; + height: 12px; +} + +.claudian-todo-list-container .claudian-todo-text { + font-family: var(--font-monospace); + font-size: 12px; + line-height: 1.4; + flex: 1; + padding-left: 12px; +} + +.claudian-todo-list-container .claudian-todo-pending .claudian-todo-status-icon { + color: var(--text-normal); +} + +.claudian-todo-list-container .claudian-todo-pending .claudian-todo-status-icon svg { + transform: scale(2); +} + +.claudian-todo-list-container .claudian-todo-pending .claudian-todo-text { + color: var(--text-normal); +} + +.claudian-todo-list-container .claudian-todo-in_progress .claudian-todo-status-icon { + color: var(--interactive-accent); +} + +.claudian-todo-list-container .claudian-todo-in_progress .claudian-todo-status-icon svg { + transform: scale(2); +} + +.claudian-todo-list-container .claudian-todo-in_progress .claudian-todo-text { + color: var(--text-normal); +} + +.claudian-todo-list-container .claudian-todo-completed .claudian-todo-status-icon { + color: var(--color-green); +} + +.claudian-todo-list-container .claudian-todo-completed .claudian-todo-text { + color: var(--text-muted); +} + +/* Bash Output Section */ + +.claudian-status-panel-bash { + margin-bottom: 4px; +} + +.claudian-status-panel-bash-header { + padding: 4px 0; +} + +.claudian-status-panel-bash-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.claudian-status-panel-bash-action { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 4px; + color: var(--text-muted); +} + +.claudian-status-panel-bash-action:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.claudian-status-panel-bash-content { + padding-top: 2px; + max-height: min(40vh, 320px); + overflow-y: auto; + overscroll-behavior: contain; +} + +.claudian-status-panel-bash-entry { + margin: 4px 0; +} + +.claudian-status-panel-bash-entry .claudian-tool-icon svg { + width: 14px; + height: 14px; + position: relative; + top: -1px; +} + +.claudian-status-panel-bash-entry .claudian-tool-call { + margin: 0; +} + +/* Keep bash output blocks from growing without bound */ +.claudian-status-panel-bash-entry .claudian-tool-result-text { + max-height: 200px; + overflow-y: auto; + word-break: break-word; +} diff --git a/src/style/components/subagent.css b/src/style/components/subagent.css new file mode 100644 index 0000000..32846a1 --- /dev/null +++ b/src/style/components/subagent.css @@ -0,0 +1,248 @@ +.claudian-subagent-list { + margin: 8px 0; +} + +.claudian-text-block+.claudian-subagent-list { + margin-top: 8px; +} + +.claudian-subagent-list+.claudian-text-block { + margin-top: 8px; +} + +.claudian-tool-call+.claudian-subagent-list { + margin-top: 8px; +} + +.claudian-subagent-list+.claudian-tool-call { + margin-top: 8px; +} + +.claudian-subagent-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + cursor: pointer; + overflow: hidden; +} + +.claudian-subagent-icon { + display: flex; + align-items: center; + color: var(--interactive-accent); + flex-shrink: 0; +} + +.claudian-subagent-icon svg { + width: 16px; + height: 16px; +} + +.claudian-subagent-label { + flex: 1; + font-family: var(--font-monospace); + font-size: 13px; + font-weight: 400; + color: var(--text-normal); +} + +.claudian-subagent-status { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.claudian-subagent-status svg { + width: 14px; + height: 14px; +} + +.claudian-subagent-status.status-running { + color: var(--text-accent); +} + +.claudian-subagent-status.status-completed { + color: var(--color-green); +} + +.claudian-subagent-status.status-error { + color: var(--color-red); +} + +.claudian-subagent-content { + padding: 4px 0; + padding-inline-start: 16px; + margin-inline-start: 7px; + border-inline-start: 2px solid var(--background-modifier-border); +} + +.claudian-subagent-section { + margin: 2px 0 6px; +} + +.claudian-subagent-section-header { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; + font-family: var(--font-monospace); + font-size: 12px; + color: var(--text-muted); + padding: 2px 0; +} + +.claudian-subagent-section-header:hover { + color: var(--text-normal); +} + +.claudian-subagent-section-title { + flex: 1; + min-width: 0; +} + +.claudian-subagent-section-body { + padding-inline-start: 6px; +} + +.claudian-subagent-prompt-text, +.claudian-subagent-result-output { + font-family: var(--font-monospace); + font-size: 12px; + line-height: 1.4; + color: var(--text-muted); + white-space: pre-wrap; + word-break: break-word; +} + +.claudian-subagent-result-output { + max-height: 220px; + overflow-y: auto; +} + +.claudian-subagent-tools { + display: flex; + flex-direction: column; + gap: 4px; +} + +.claudian-subagent-tool-item { + display: block; +} + +.claudian-subagent-tool-header { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + overflow: hidden; + padding: 2px 0; +} + +.claudian-subagent-tool-header:hover { + opacity: 0.85; +} + +.claudian-subagent-tool-icon { + display: flex; + align-items: center; + color: var(--text-accent); + flex-shrink: 0; +} + +.claudian-subagent-tool-icon svg { + width: 13px; + height: 13px; +} + +.claudian-subagent-tool-name { + font-family: var(--font-monospace); + font-size: 12px; + color: var(--text-normal); + white-space: nowrap; + flex-shrink: 0; +} + +.claudian-subagent-tool-summary { + font-family: var(--font-monospace); + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +.claudian-subagent-tool-status { + display: flex; + align-items: center; + flex-shrink: 0; +} + +.claudian-subagent-tool-status svg { + width: 12px; + height: 12px; +} + +.claudian-subagent-tool-status.status-running { + color: var(--text-accent); +} + +.claudian-subagent-tool-status.status-completed { + color: var(--color-green); +} + +.claudian-subagent-tool-status.status-error, +.claudian-subagent-tool-status.status-blocked { + color: var(--color-red); +} + +.claudian-subagent-tool-content { + padding: 2px 0 2px 16px; +} + +.claudian-subagent-tool-empty { + color: var(--text-faint); + font-style: italic; + font-family: var(--font-monospace); + font-size: 12px; + padding: 2px 0; +} + +.claudian-subagent-status-text { + font-size: 11px; + font-family: var(--font-monospace); + color: var(--text-muted); + margin-inline-start: auto; + padding-inline-start: 8px; +} + +.claudian-subagent-list.async .claudian-subagent-icon { + color: var(--interactive-accent); +} + +.claudian-subagent-list.async.pending .claudian-subagent-status-text { + color: var(--text-muted); +} + +.claudian-subagent-list.async.running .claudian-subagent-status-text { + color: var(--text-accent); +} + +.claudian-subagent-list.async.awaiting .claudian-subagent-status-text { + color: var(--color-yellow); +} + +.claudian-subagent-list.async.completed .claudian-subagent-status-text { + color: var(--color-green); +} + +.claudian-subagent-list.async.error .claudian-subagent-status-text { + color: var(--color-red); +} + +.claudian-subagent-list.async.orphaned .claudian-subagent-status-text { + color: var(--color-orange); +} diff --git a/src/style/components/tabs.css b/src/style/components/tabs.css new file mode 100644 index 0000000..ef88023 --- /dev/null +++ b/src/style/components/tabs.css @@ -0,0 +1,112 @@ +.claudian-tab-bar-container { + display: flex; + align-items: center; + flex: 1 1 auto; + gap: 4px; + min-width: 0; + max-width: 100%; +} + +.claudian-tab-badges { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: nowrap; + min-width: 0; + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + overscroll-behavior-x: contain; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.claudian-tab-badges::-webkit-scrollbar { + display: none; +} + +.claudian-tab-badge { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 24px; + width: 24px; + max-width: 24px; + height: 24px; + box-sizing: border-box; + padding: 0; + border-radius: 4px; + border: 2px solid var(--background-modifier-border); + font-size: 12px; + font-weight: 500; + cursor: pointer; + color: var(--text-muted); + background: var(--background-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease; +} + +.claudian-tab-badge-expanded { + flex: 0 0 auto; + justify-content: flex-start; + width: auto; + max-width: none; + padding: 0 8px; +} + +.claudian-tab-badge:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.claudian-tab-badge-active { + border-color: var(--interactive-accent); + color: var(--text-normal); +} + +.claudian-tab-badge-streaming { + border-color: var(--claudian-brand, #da7756); +} + +.claudian-tab-badge-streaming[data-provider="claude"] { + border-color: var(--claudian-brand-claude, #D97757); +} + +.claudian-tab-badge-streaming[data-provider="codex"] { + border-color: var(--claudian-brand-codex, #d0d0d0); +} + +.claudian-tab-badge-streaming[data-provider="opencode"] { + border-color: var(--claudian-brand-opencode, #C8C8C8); +} + +.claudian-tab-badge-streaming[data-provider="pi"] { + border-color: var(--claudian-brand-pi, #ffffff); +} + +.claudian-tab-badge-attention { + border-color: var(--text-error); +} + +.claudian-tab-badge-idle { + border-color: var(--background-modifier-border); +} + +.claudian-tab-content-container { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.claudian-tab-content { + position: relative; /* For scroll-to-bottom button positioning */ + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} diff --git a/src/style/components/thinking.css b/src/style/components/thinking.css new file mode 100644 index 0000000..cd71cbe --- /dev/null +++ b/src/style/components/thinking.css @@ -0,0 +1,88 @@ +.claudian-thinking { + color: var(--claudian-brand); + font-style: italic; + padding: 4px 0; + text-align: start; + animation: thinking-pulse 1.5s ease-in-out infinite; +} + +.claudian-thinking.claudian-thinking--compact { + color: var(--claudian-compact); +} + +.claudian-thinking-hint { + color: var(--text-muted); + font-style: normal; + font-variant-numeric: tabular-nums; +} + +.claudian-thinking-block { + margin: 8px 0; +} + +.claudian-text-block+.claudian-thinking-block { + margin-top: 8px; +} + +.claudian-thinking-block+.claudian-text-block { + margin-top: 8px; +} + +.claudian-thinking-block+.claudian-tool-call { + margin-top: 8px; +} + +.claudian-tool-call+.claudian-thinking-block { + margin-top: 8px; +} + +.claudian-thinking-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + cursor: pointer; + overflow: hidden; +} + +.claudian-thinking-label { + flex: 1; + font-size: 13px; + font-weight: 500; + color: var(--claudian-brand); +} + +/* Thinking block content - tree-branch style */ +.claudian-thinking-content { + padding: 4px 0; + padding-inline-start: 24px; + font-size: 13px; + line-height: 1.5; + color: var(--text-muted); + max-height: 400px; + overflow-y: auto; + border-inline-start: 2px solid var(--background-modifier-border); + margin-inline-start: 7px; +} + +.claudian-thinking-content p { + margin: 0 0 8px 0; +} + +.claudian-thinking-content p:last-child { + margin-bottom: 0; +} + +.claudian-thinking-content .claudian-code-wrapper { + margin: 8px 0; +} + +.claudian-thinking-content .claudian-code-wrapper pre { + padding: 8px 10px; + border-radius: 4px; +} + +.claudian-thinking-content code { + font-family: var(--font-monospace); + font-size: 12px; +} diff --git a/src/style/components/toolcalls.css b/src/style/components/toolcalls.css new file mode 100644 index 0000000..9cfdb06 --- /dev/null +++ b/src/style/components/toolcalls.css @@ -0,0 +1,278 @@ +.claudian-tool-call { + margin: 8px 0; +} + +.claudian-tool-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + cursor: pointer; + overflow: hidden; +} + +.claudian-tool-header:hover { + opacity: 0.85; +} + +.claudian-tool-icon { + display: flex; + align-items: center; + color: var(--text-accent); + flex-shrink: 0; +} + +.claudian-tool-icon svg { + width: 16px; + height: 16px; +} + +.claudian-tool-name { + font-family: var(--font-monospace); + font-size: 13px; + font-weight: 400; + color: var(--text-normal); + white-space: nowrap; + flex-shrink: 0; +} + +.claudian-tool-summary { + font-family: var(--font-monospace); + font-size: 13px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +.claudian-tool-summary:empty { + display: none; +} + +.claudian-tool-call-bash.expanded .claudian-tool-summary { + display: none; +} + +.claudian-tool-bash-command { + font-family: var(--font-monospace); + font-size: 12px; + line-height: 1.4; + color: var(--text-normal); + white-space: pre-wrap; + word-break: break-word; + padding: 2px 0 4px; +} + +/* Legacy: StatusPanel bash entries still use claudian-tool-label */ +.claudian-tool-label { + font-family: var(--font-monospace); + font-size: 13px; + font-weight: 400; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.claudian-tool-current { + font-family: var(--font-monospace); + font-size: 13px; + color: var(--text-muted); + white-space: nowrap; +} + +.claudian-tool-current:empty { + display: none; +} + +.claudian-tool-status { + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: auto; +} + +.claudian-tool-status svg { + width: 14px; + height: 14px; +} + +.claudian-tool-status.status-running { + color: var(--text-accent); +} + +.claudian-tool-status.status-completed { + color: var(--color-green); +} + +.claudian-tool-status.status-error { + color: var(--color-red); +} + +.claudian-tool-status.status-blocked { + color: var(--color-orange); +} + +/* Tool call content - border style (like thinking block) */ +.claudian-tool-content { + padding: 4px 0; + padding-inline-start: 16px; + margin-inline-start: 7px; + border-inline-start: 2px solid var(--background-modifier-border); +} + +/* Tool content variants that render inline widgets instead of bordered results */ +.claudian-tool-content-todo, +.claudian-tool-content-ask { + border-inline-start: none; + margin-inline-start: 0; + padding-inline-start: 0; +} + +/* Expanded content: per-line rendering */ +.claudian-tool-lines { + font-family: var(--font-monospace); + font-size: 12px; + line-height: 1.4; + overflow-x: auto; +} + +.claudian-tool-line { + padding: 1px 0; + color: var(--text-muted); + white-space: pre; +} + +.claudian-tool-line-wrap { + white-space: pre-wrap; + word-break: break-word; +} + +/* Hover highlight for file search results */ +.claudian-tool-line.hoverable:hover { + background: var(--background-modifier-hover); +} + +/* Truncation indicator: "... N more lines" */ +.claudian-tool-truncated { + color: var(--text-faint); + font-style: italic; + padding: 4px 0; + font-family: var(--font-monospace); + font-size: 12px; +} + +/* ToolSearch expanded: icon + tool name rows */ +.claudian-tool-search-item { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 0; + font-family: var(--font-monospace); + font-size: 12px; + color: var(--text-muted); +} + +.claudian-tool-search-icon { + display: flex; + align-items: center; + flex-shrink: 0; + width: 14px; + height: 14px; + color: var(--text-faint); +} + +.claudian-tool-search-icon svg { + width: 14px; + height: 14px; +} + +/* Web search links */ +.claudian-tool-link { + display: flex; + align-items: flex-start; + gap: 6px; + padding: 3px 0; + color: var(--text-muted); + text-decoration: none; + cursor: pointer; + font-family: var(--font-monospace); + font-size: 12px; + line-height: 1.4; +} + +.claudian-tool-link:hover { + color: var(--text-accent); +} + +.claudian-tool-link-icon { + flex-shrink: 0; + width: 12px; + height: 12px; + margin-top: 2px; + color: var(--text-faint); +} + +.claudian-tool-link-icon svg { + width: 12px; + height: 12px; +} + +.claudian-tool-link-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Web search summary section */ +.claudian-tool-web-summary { + font-family: var(--font-monospace); + font-size: 12px; + color: var(--text-muted); + padding-top: 4px; + border-top: 1px solid var(--background-modifier-border); + margin-top: 4px; + white-space: pre-wrap; + word-break: break-word; + max-height: 200px; + overflow-y: auto; +} + +/* Empty state for file search */ +.claudian-tool-empty { + color: var(--text-faint); + font-style: italic; + font-family: var(--font-monospace); + font-size: 12px; + padding: 4px 0; +} + +.claudian-tool-result-row { + display: flex; + align-items: flex-start; +} + +.claudian-tool-result-text { + font-family: var(--font-monospace); + font-size: 12px; + color: var(--text-muted); + line-height: 1.4; + white-space: pre-wrap; + overflow: hidden; + flex: 1; +} + +.claudian-tool-result-item { + display: block; + margin-bottom: 2px; +} + +.claudian-tool-patch-section + .claudian-tool-patch-section { + margin-top: 10px; +} + +.claudian-tool-result-item:last-child { + margin-bottom: 0; +} diff --git a/src/style/features/ask-user-question.css b/src/style/features/ask-user-question.css new file mode 100644 index 0000000..3a9ab51 --- /dev/null +++ b/src/style/features/ask-user-question.css @@ -0,0 +1,315 @@ +/* AskUserQuestion - inline widget rendered in chat panel */ + +.claudian-ask-question-inline { + font-family: var(--font-monospace); + font-size: 12px; + outline: none; +} + +.claudian-ask-inline-title { + font-weight: 700; + color: var(--text-muted); + padding: 6px 10px 0; +} + +/* ── Tab bar ─────────────────────────────────── */ + +.claudian-ask-tab-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-bottom: 1px solid var(--background-modifier-border); + line-height: 1.4; +} + +.claudian-ask-tab { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: 3px; + cursor: pointer; + user-select: none; + color: var(--text-muted); + transition: + background 0.15s ease, + color 0.15s ease; +} + +.claudian-ask-tab:hover { + color: var(--text-normal); +} + +.claudian-ask-tab.is-active { + background: hsla(55, 30%, 50%, 0.18); + color: var(--text-normal); +} + +.claudian-ask-tab-label { + font-weight: 600; + max-width: 14ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-ask-tab-tick { + color: var(--color-green); + font-weight: 700; +} + +.claudian-ask-tab-submit-check { + color: var(--color-green); + white-space: pre; +} + +/* ── Content area ────────────────────────────── */ + +.claudian-ask-content { + padding: 8px 10px; +} + +.claudian-ask-question-text { + font-weight: 700; + color: var(--text-normal); + margin-bottom: 8px; + line-height: 1.4; +} + +/* ── Item list ───────────────────────────────── */ + +.claudian-ask-list { + display: flex; + flex-direction: column; + gap: 2px; + margin-bottom: 8px; +} + +.claudian-ask-item { + display: flex; + align-items: flex-start; + padding: 3px 4px; + cursor: pointer; + line-height: 1.4; + color: var(--text-normal); + border-radius: 3px; +} + +.claudian-ask-item:hover { + background: var(--background-modifier-hover); +} + +.claudian-ask-cursor { + display: inline-block; + width: 2ch; + flex-shrink: 0; + color: var(--text-accent); + font-weight: 700; +} + +.claudian-ask-item-num { + color: var(--text-muted); + flex-shrink: 0; +} + +.claudian-ask-item-content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; +} + +.claudian-ask-label-row { + display: flex; + align-items: baseline; +} + +.claudian-ask-item-label { + font-weight: 600; +} + +.claudian-ask-item-desc { + color: var(--text-muted); + font-weight: 400; + line-height: 1.4; + margin-top: 1px; +} + +/* Selected items: green text */ +.claudian-ask-item.is-selected .claudian-ask-item-label { + color: var(--color-green); +} + +/* Disabled items: muted text */ +.claudian-ask-item.is-disabled .claudian-ask-item-label { + color: var(--text-faint); +} + +/* ── Multi-select brackets ───────────────────── */ + +.claudian-ask-check { + color: var(--text-faint); + flex-shrink: 0; + white-space: pre; +} + +.claudian-ask-check.is-checked { + color: var(--color-green); +} + +/* ── Single-select check mark ────────────────── */ + +.claudian-ask-check-mark { + color: var(--color-green); + font-weight: 700; +} + +/* ── Custom input ────────────────────────────── */ + +.claudian-ask-item input.claudian-ask-custom-text, +.claudian-ask-item input.claudian-ask-custom-text:hover, +.claudian-ask-item input.claudian-ask-custom-text:focus { + border: none; + border-radius: 0; + background: transparent; + box-shadow: none; + font-family: var(--font-monospace); + font-size: inherit; + color: var(--text-normal); + outline: none; + padding: 0; + width: 0; + height: auto; + min-height: 0; + line-height: 1.4; + flex: 1 1 0; + min-width: 0; +} + +.claudian-ask-custom-text::placeholder { + color: var(--text-faint); +} + +/* ── Submit review tab ───────────────────────── */ + +.claudian-ask-review-title { + font-weight: 700; + color: var(--text-normal); + margin-bottom: 6px; +} + +.claudian-ask-review { + font-family: var(--font-monospace); + margin-bottom: 16px; +} + +.claudian-tool-content .claudian-ask-review { + font-size: 12px; +} + +.claudian-ask-review:last-child { + margin-bottom: 0; +} + +.claudian-ask-review-pair { + display: flex; + gap: 6px; + margin-bottom: 4px; +} + +.claudian-ask-review-pair:last-child { + margin-bottom: 0; +} + +.claudian-ask-review-num { + color: var(--text-muted); + flex-shrink: 0; +} + +.claudian-ask-review-body { + min-width: 0; +} + +.claudian-ask-review-q-text { + color: var(--text-muted); +} + +.claudian-ask-review-a-text { + color: var(--text-normal); +} + +.claudian-ask-review-empty { + color: var(--text-faint); + font-style: italic; +} + +.claudian-ask-review-prompt { + color: var(--text-muted); + margin-bottom: 6px; +} + +/* ── Hints ───────────────────────────────────── */ + +.claudian-ask-hints { + color: var(--text-faint); + padding-top: 6px; + border-top: 1px solid var(--background-modifier-border); +} + +/* ── Approval header (inline permission request) ── */ + +.claudian-ask-approval-info { + padding: 8px 10px; +} + +.claudian-ask-approval-tool { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--background-secondary); + border-radius: 6px; + margin-bottom: 8px; +} + +.claudian-ask-approval-icon { + color: var(--claudian-brand); +} + +.claudian-ask-approval-tool-name { + font-weight: 600; + color: var(--text-normal); +} + +.claudian-ask-approval-reason { + color: var(--text-muted); + font-size: 12px; + margin-bottom: 6px; +} + +.claudian-ask-approval-blocked-path { + font-family: var(--font-monospace); + font-size: 11px; + color: var(--text-muted); + padding: 3px 6px; + background: var(--background-primary-alt); + border-radius: 4px; + margin-bottom: 6px; + word-break: break-all; +} + +.claudian-ask-approval-agent { + color: var(--text-muted); + font-size: 12px; + margin-bottom: 6px; +} + +.claudian-ask-approval-desc { + padding: 8px 10px; + background: var(--background-primary-alt); + border-radius: 6px; + font-family: var(--font-monospace); + font-size: 12px; + color: var(--text-normal); + word-break: break-all; +} diff --git a/src/style/features/diff.css b/src/style/features/diff.css new file mode 100644 index 0000000..ca519b4 --- /dev/null +++ b/src/style/features/diff.css @@ -0,0 +1,197 @@ +/* Write/Edit Diff Block - Subagent style */ +.claudian-write-edit-block { + margin: 4px 0; + background: transparent; + overflow: hidden; +} + +.claudian-text-block+.claudian-write-edit-block { + margin-top: 8px; +} + +.claudian-write-edit-block+.claudian-text-block { + margin-top: 8px; +} + +.claudian-write-edit-header { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + cursor: pointer; + user-select: none; + background: transparent; + overflow: hidden; +} + +.claudian-write-edit-icon { + width: 16px; + height: 16px; + color: var(--text-accent); + flex-shrink: 0; +} + +.claudian-write-edit-icon svg { + width: 16px; + height: 16px; +} + +/* Two-part header: name (fixed) + summary (flexible) */ +.claudian-write-edit-name { + font-family: var(--font-monospace); + font-size: 13px; + font-weight: 400; + color: var(--text-normal); + white-space: nowrap; + flex-shrink: 0; +} + +.claudian-write-edit-summary { + font-family: var(--font-monospace); + font-size: 13px; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + min-width: 0; +} + +.claudian-write-edit-stats { + font-family: var(--font-monospace); + font-size: 11px; + flex-shrink: 0; +} + +.claudian-write-edit-stats .added { + color: var(--color-green); +} + +.claudian-write-edit-stats .removed { + color: var(--color-red); + margin-left: 4px; +} + +.claudian-write-edit-status { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +/* Hide empty status on successful completion so stats align to right edge */ +.claudian-write-edit-block.done .claudian-write-edit-status:empty { + display: none; +} + +.claudian-write-edit-status svg { + width: 16px; + height: 16px; +} + +.claudian-write-edit-status.status-completed { + color: var(--color-green); +} + +.claudian-write-edit-status.status-error, +.claudian-write-edit-status.status-blocked { + color: var(--color-red); +} + +.claudian-write-edit-content { + padding: 0; + background: transparent; + overflow: hidden; +} + +.claudian-write-edit-diff-row { + display: flex; +} + +.claudian-write-edit-diff { + flex: 1; + font-family: var(--font-monospace); + font-size: 12px; + line-height: 1.5; + background: transparent; + max-height: 300px; + overflow-y: auto; + overflow-x: auto; +} + +.claudian-write-edit-loading, +.claudian-write-edit-binary, +.claudian-write-edit-error, +.claudian-write-edit-done-text { + font-family: var(--font-monospace); + font-size: 12px; + color: var(--text-muted); +} + +.claudian-write-edit-error { + color: var(--color-red); +} + +/* Diff line styling */ +.claudian-diff-hunk { + margin-bottom: 4px; +} + +.claudian-diff-line { + display: flex; + white-space: pre-wrap; + word-break: break-all; +} + +.claudian-diff-prefix { + flex-shrink: 0; + width: 16px; + text-align: center; + color: var(--text-muted); + user-select: none; +} + +.claudian-diff-text { + flex: 1; + min-width: 0; +} + +/* Diff colors - NO strikethrough for Write/Edit blocks */ +.claudian-diff-equal { + color: var(--text-muted); +} + +.claudian-diff-delete { + background: rgba(255, 80, 80, 0.25); + color: var(--text-normal); +} + +.claudian-diff-delete .claudian-diff-prefix { + color: var(--color-red); +} + +.claudian-diff-insert { + background: rgba(80, 200, 80, 0.25); + color: var(--text-normal); +} + +.claudian-diff-insert .claudian-diff-prefix { + color: var(--color-green); +} + +/* Hunk separator */ +.claudian-diff-separator { + color: var(--text-muted); + text-align: center; + padding: 4px 0; + font-style: italic; + border-top: 1px dashed var(--background-modifier-border); + border-bottom: 1px dashed var(--background-modifier-border); + margin: 8px 0; + font-size: 11px; +} + +.claudian-diff-no-changes { + color: var(--text-muted); + font-style: italic; + padding: 8px; +} diff --git a/src/style/features/file-context.css b/src/style/features/file-context.css new file mode 100644 index 0000000..8f771e6 --- /dev/null +++ b/src/style/features/file-context.css @@ -0,0 +1,188 @@ +/* @ Mention dropdown */ +.claudian-mention-dropdown { + display: none; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + margin-bottom: 4px; + background: var(--background-secondary); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.2); + z-index: 1000; + max-height: 250px; + overflow-y: auto; +} + +.claudian-mention-dropdown.visible { + display: block; +} + +/* Fixed positioning for inline editor */ +.claudian-mention-dropdown-fixed { + position: fixed; + bottom: var(--claudian-fixed-dropdown-bottom); + left: var(--claudian-fixed-dropdown-left); + right: auto; + width: var(--claudian-fixed-dropdown-width); + z-index: 10001; +} + +.claudian-mention-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + transition: background 0.1s ease; +} + +.claudian-mention-item:hover, +.claudian-mention-item.selected { + background: var(--background-modifier-hover); +} + +.claudian-mention-icon { + display: flex; + align-items: center; + color: var(--text-muted); + flex-shrink: 0; +} + +.claudian-mention-icon svg { + width: 14px; + height: 14px; +} + +.claudian-mention-path { + font-size: 13px; + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-mention-empty { + padding: 12px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +/* Scrollbar for mention dropdown */ +.claudian-mention-dropdown::-webkit-scrollbar { + width: 6px; +} + +.claudian-mention-dropdown::-webkit-scrollbar-track { + background: transparent; +} + +.claudian-mention-dropdown::-webkit-scrollbar-thumb { + background: var(--background-modifier-border); + border-radius: 3px; +} + +/* MCP items in @-mention dropdown */ +.claudian-mention-item.mcp-server .claudian-mention-icon { + color: var(--interactive-accent); +} + +.claudian-mention-item.vault-folder .claudian-mention-icon { + color: var(--text-muted); +} + +.claudian-mention-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.claudian-mention-name { + font-size: 13px; + font-weight: 500; + color: var(--interactive-accent); +} + +.claudian-mention-desc { + font-size: 11px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Context file items in @-mention dropdown */ +.claudian-mention-item.context-file .claudian-mention-icon { + color: var(--claudian-brand); +} + +.claudian-mention-item.context-file .claudian-mention-text { + flex-direction: row; + overflow: hidden; + white-space: nowrap; +} + +.claudian-mention-item.context-file .claudian-mention-name-context { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +/* Context folder filter items in @-mention dropdown */ +.claudian-mention-item.context-folder .claudian-mention-icon { + color: var(--text-muted); +} + +.claudian-mention-name-folder { + color: var(--text-normal); + font-weight: 500; +} + +.claudian-mention-name-context { + color: var(--text-normal); +} + +/* Agent folder items in @-mention dropdown */ +.claudian-mention-item.agent-folder .claudian-mention-icon { + color: var(--link-color); +} + +.claudian-mention-name-agent-folder { + color: var(--link-color); + font-weight: 600; +} + +/* Agent items in @-mention dropdown (inside @Agents/) */ +.claudian-mention-item.agent .claudian-mention-icon { + color: var(--link-color); +} + +.claudian-mention-item.agent .claudian-mention-text { + flex-direction: row; + align-items: baseline; + gap: 6px; + overflow: hidden; + white-space: nowrap; +} + +.claudian-mention-item.agent .claudian-mention-name-agent { + color: var(--text-normal); + font-size: 13px; + flex-shrink: 0; +} + +.claudian-mention-item.agent .claudian-mention-agent-desc { + font-size: 11px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} diff --git a/src/style/features/file-link.css b/src/style/features/file-link.css new file mode 100644 index 0000000..4a5083f --- /dev/null +++ b/src/style/features/file-link.css @@ -0,0 +1,22 @@ +/* Clickable file links that open files in Obsidian */ +.claudian-file-link { + color: var(--text-accent); + text-decoration: none; + cursor: pointer; + border-radius: 3px; + transition: color 0.15s ease; +} + +.claudian-file-link:hover { + color: var(--text-accent-hover); + text-decoration: underline; +} + +/* File link inside inline code */ +code .claudian-file-link { + color: var(--text-accent); +} + +code .claudian-file-link:hover { + color: var(--text-accent-hover); +} diff --git a/src/style/features/image-context.css b/src/style/features/image-context.css new file mode 100644 index 0000000..a011480 --- /dev/null +++ b/src/style/features/image-context.css @@ -0,0 +1,73 @@ +/* Drop overlay - inside input wrapper */ +.claudian-drop-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(var(--claudian-brand-rgb), 0.08); + border: 2px dashed var(--claudian-brand); + border-radius: 6px; + display: none; + align-items: center; + justify-content: center; + z-index: 100; + pointer-events: none; +} + +.claudian-drop-overlay.visible { + display: flex; +} + +.claudian-drop-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + color: var(--claudian-brand); +} + +.claudian-drop-content svg { + opacity: 0.7; +} + +.claudian-drop-content span { + font-size: 12px; + font-weight: 500; +} + +/* Images in Messages (displayed above user bubble) */ + +/* Images container - right-aligned above user message */ +.claudian-message-images { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; + margin-bottom: 6px; + padding-right: 4px; +} + +/* Individual image in message - standardized size */ +.claudian-message-image { + width: 120px; + height: 120px; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); +} + +.claudian-message-image:hover { + transform: scale(1.03); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.claudian-message-image img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} diff --git a/src/style/features/image-embed.css b/src/style/features/image-embed.css new file mode 100644 index 0000000..d0b1dc0 --- /dev/null +++ b/src/style/features/image-embed.css @@ -0,0 +1,40 @@ +/* Image embed styles - displays ![[image.png]] wikilinks as actual images */ + +.claudian-embedded-image { + display: inline-block; + max-width: 100%; + margin: 0.5em 0; + vertical-align: middle; +} + +.claudian-embedded-image img { + max-width: 100%; + height: auto; + border-radius: var(--radius-s); + cursor: pointer; + transition: opacity 0.2s ease; +} + +.claudian-embedded-image img:hover { + opacity: 0.9; +} + +/* When image is inline with text */ +.claudian-text-block p .claudian-embedded-image { + margin: 0.25em 0; +} + +/* Block-level image (standalone on its own line) */ +.claudian-text-block p > .claudian-embedded-image:only-child { + display: block; + margin: 0.75em 0; +} + +/* Fallback when image file not found */ +.claudian-embedded-image-fallback { + color: var(--text-muted); + font-style: italic; + background: var(--background-modifier-hover); + padding: 0.1em 0.4em; + border-radius: var(--radius-s); +} diff --git a/src/style/features/image-modal.css b/src/style/features/image-modal.css new file mode 100644 index 0000000..ce72c60 --- /dev/null +++ b/src/style/features/image-modal.css @@ -0,0 +1,52 @@ +/* Full-size Image Modal */ +.claudian-image-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + cursor: pointer; +} + +.claudian-image-modal { + position: relative; + max-width: 90vw; + max-height: 90vh; + cursor: default; +} + +.claudian-image-modal img { + max-width: 90vw; + max-height: 90vh; + object-fit: contain; + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +.claudian-image-modal-close { + position: absolute; + top: -12px; + right: -12px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--background-secondary); + border-radius: 50%; + cursor: pointer; + font-size: 20px; + color: var(--text-muted); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + transition: background 0.15s ease, color 0.15s ease; +} + +.claudian-image-modal-close:hover { + background: var(--background-modifier-error); + color: var(--text-on-accent); +} diff --git a/src/style/features/inline-edit.css b/src/style/features/inline-edit.css new file mode 100644 index 0000000..e3bfbc4 --- /dev/null +++ b/src/style/features/inline-edit.css @@ -0,0 +1,278 @@ +/* Inline Edit (CM6 decorations) */ + +/* Selection highlight (shared by inline edit and chat) */ +.cm-line .claudian-selection-highlight, +.claudian-selection-highlight.claudian-selection-highlight { + background: var(--text-selection); + border-radius: 2px; + padding: 3px 0; + margin: -3px 0; +} + +/* Preview mode selection highlight via CSS Custom Highlight API */ +::highlight(claudian-selection) { + background: var(--text-selection); +} + +/* CM6 widget container - ensure transparent background */ +.markdown-source-view.mod-cm6 .cm-widgetBuffer, +.markdown-source-view.mod-cm6 .cm-line.claudian-inline-input-line { + background: transparent; +} + +/* Input container - fully transparent */ +.claudian-inline-input-container { + display: flex; + flex-direction: column; + gap: 0; + padding: 2px 0; + background: transparent; +} + +/* Input wrapper - contains input and spinner */ +.claudian-inline-input-wrap { + flex: 1; + position: relative; + background: transparent; + overflow: visible; +} + +/* Input - fully transparent */ +.claudian-inline-input-wrap input.claudian-inline-input { + width: 100%; + padding: 4px 8px; + padding-inline-end: 30px; + border-width: 1px; + border-style: solid; + border-color: var(--background-modifier-border); + border-radius: 8px; + background: transparent; + color: var(--text-normal); + font-family: var(--font-interface, -apple-system, BlinkMacSystemFont, sans-serif); + font-size: var(--font-ui-small, 13px); +} + +.claudian-inline-input-wrap input.claudian-inline-input:focus, +.claudian-inline-input-wrap input.claudian-inline-input:focus-visible { + outline: none; + box-shadow: none; +} + +.claudian-inline-input-wrap input.claudian-inline-input::placeholder { + color: var(--text-faint); +} + +.claudian-inline-input-wrap input.claudian-inline-input:disabled { + opacity: 0.6; +} + +/* Spinner - inside input box on end side */ +.claudian-inline-spinner { + position: absolute; + inset-inline-end: 8px; + top: 50%; + width: 14px; + height: 14px; + margin-top: -7px; + border: 2px solid var(--background-modifier-border); + border-top-color: var(--claudian-brand); + border-radius: 50%; + box-sizing: border-box; + animation: claudian-spin 0.8s linear infinite; +} + +/* Agent reply - shown when agent asks clarifying question */ +.claudian-inline-agent-reply { + padding: 8px; + margin-bottom: 4px; + background: transparent; + border-width: 1px; + border-style: solid; + border-color: var(--background-modifier-border); + border-radius: 8px; + font-family: var(--font-interface, -apple-system, BlinkMacSystemFont, sans-serif); + font-size: var(--font-ui-small, 13px); + line-height: 1.4; + color: var(--text-muted); + overflow-x: auto; + word-wrap: break-word; +} + +.claudian-inline-agent-reply > :first-child, +.claudian-inline-diff-preview-body > :first-child { + margin-top: 0; +} + +.claudian-inline-agent-reply > :last-child, +.claudian-inline-diff-preview-body > :last-child { + margin-bottom: 0; +} + +.claudian-inline-diff-preview { + margin: 4px 0 6px; + padding: 8px 10px; + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + background: var(--background-primary); + color: var(--text-normal); + overflow: hidden; +} + +.claudian-inline-diff-preview-body { + font-family: var(--font-text); + font-size: var(--font-text-size); + line-height: var(--line-height-normal); + overflow-x: auto; + --p-spacing: 0.4rem; + --heading-spacing: 0.5rem; +} + +.claudian-inline-diff-preview img { + max-width: 100%; + height: auto; +} + +.claudian-inline-diff-preview .markdown-rendered p { + margin: 0 0 6px; +} + +.claudian-inline-diff-preview .markdown-rendered p:last-child { + margin-bottom: 0; +} + +.claudian-inline-diff-preview .markdown-rendered h1, +.claudian-inline-diff-preview .markdown-rendered h2, +.claudian-inline-diff-preview .markdown-rendered h3, +.claudian-inline-diff-preview .markdown-rendered h4, +.claudian-inline-diff-preview .markdown-rendered h5, +.claudian-inline-diff-preview .markdown-rendered h6 { + margin: 0 0 6px; + line-height: 1.25; +} + +.claudian-inline-diff-preview .markdown-rendered h1:first-child, +.claudian-inline-diff-preview .markdown-rendered h2:first-child, +.claudian-inline-diff-preview .markdown-rendered h3:first-child, +.claudian-inline-diff-preview .markdown-rendered h4:first-child, +.claudian-inline-diff-preview .markdown-rendered h5:first-child, +.claudian-inline-diff-preview .markdown-rendered h6:first-child { + margin-top: 0; +} + +.claudian-inline-diff-preview .markdown-rendered ul, +.claudian-inline-diff-preview .markdown-rendered ol { + margin: 2px 0 8px; + padding-inline-start: 1.5em; +} + +.claudian-inline-diff-preview .markdown-rendered li { + margin: 2px 0; +} + +.claudian-inline-diff-preview .markdown-rendered li > p { + margin: 0; +} + +.claudian-inline-diff-preview .markdown-rendered pre, +.claudian-inline-diff-preview .markdown-rendered table, +.claudian-inline-diff-preview .markdown-rendered blockquote { + margin: 2px 0 8px; +} + +.claudian-inline-diff-preview .markdown-rendered .el-h1, +.claudian-inline-diff-preview .markdown-rendered .el-h2, +.claudian-inline-diff-preview .markdown-rendered .el-h3, +.claudian-inline-diff-preview .markdown-rendered .el-h4, +.claudian-inline-diff-preview .markdown-rendered .el-h5, +.claudian-inline-diff-preview .markdown-rendered .el-h6, +.claudian-inline-diff-preview .markdown-rendered .el-p, +.claudian-inline-diff-preview .markdown-rendered .el-ul, +.claudian-inline-diff-preview .markdown-rendered .el-ol, +.claudian-inline-diff-preview .markdown-rendered .el-pre, +.claudian-inline-diff-preview .markdown-rendered .el-table, +.claudian-inline-diff-preview .markdown-rendered .el-blockquote { + margin-block-start: 0; + margin-block-end: 6px; +} + +.claudian-inline-markdown-fallback { + white-space: pre-wrap; + word-wrap: break-word; +} + +.claudian-diff-block { + border-radius: 4px; + padding: 2px 4px; +} + +.claudian-diff-block + .claudian-diff-block { + margin-top: 2px; +} + +.claudian-diff-block > :first-child { + margin-top: 0; +} + +.claudian-diff-block > :last-child { + margin-bottom: 0; +} + +.claudian-diff-del:not(.claudian-diff-block) { + background: rgba(255, 80, 80, 0.2); + text-decoration: line-through; + color: var(--text-muted); +} + +.claudian-diff-ins:not(.claudian-diff-block) { + background: rgba(80, 200, 80, 0.2); +} + +.claudian-diff-block.claudian-diff-del { + background: rgba(255, 80, 80, 0.14); + color: var(--text-muted); +} + +.claudian-diff-block.claudian-diff-ins { + background: rgba(80, 200, 80, 0.14); +} + +.claudian-inline-preview-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--background-modifier-border); +} + +.claudian-inline-preview-actions button.claudian-inline-preview-action { + min-height: 28px; + padding: 4px 10px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-secondary); + box-shadow: none; + color: var(--text-normal); + font-family: var(--font-interface, -apple-system, BlinkMacSystemFont, sans-serif); + font-size: var(--font-ui-small, 13px); + line-height: 1.2; + cursor: pointer; +} + +.claudian-inline-preview-actions button.claudian-inline-preview-action:hover { + background: var(--background-modifier-hover); +} + +.claudian-inline-preview-actions button.claudian-inline-preview-action.reject { + color: var(--color-red); +} + +.claudian-inline-preview-actions button.claudian-inline-preview-action.accept { + border-color: var(--interactive-accent); + background: var(--interactive-accent); + color: var(--text-on-accent); +} + +.claudian-inline-preview-actions button.claudian-inline-preview-action.accept:hover { + background: var(--interactive-accent-hover); +} diff --git a/src/style/features/plan-mode.css b/src/style/features/plan-mode.css new file mode 100644 index 0000000..8170458 --- /dev/null +++ b/src/style/features/plan-mode.css @@ -0,0 +1,103 @@ +/* Plan Mode - inline cards for EnterPlanMode / ExitPlanMode */ + +.claudian-plan-approval-inline { + font-family: var(--font-monospace); + font-size: 12px; + outline: none; +} + +.claudian-plan-inline-title { + font-weight: 700; + color: var(--text-muted); + padding: 6px 10px 0; +} + +/* ── Plan content preview ────────────────────────── */ + +.claudian-plan-content-preview { + max-height: 300px; + overflow-y: auto; + margin: 6px 10px 8px; + padding: 8px 10px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + font-size: 12px; + line-height: 1.5; + color: var(--text-normal); +} + +.claudian-plan-content-preview::-webkit-scrollbar { + width: 4px; +} + +.claudian-plan-content-preview::-webkit-scrollbar-thumb { + background: var(--background-modifier-border); + border-radius: 2px; +} + +.claudian-plan-content-preview p { + margin: 0 0 6px; +} + +.claudian-plan-content-preview p:last-child { + margin-bottom: 0; +} + +.claudian-plan-content-preview h1, +.claudian-plan-content-preview h2, +.claudian-plan-content-preview h3, +.claudian-plan-content-preview h4 { + margin: 8px 0 4px; + font-size: 13px; +} + +.claudian-plan-content-preview ul, +.claudian-plan-content-preview ol { + margin: 2px 0 6px; + padding-left: 18px; +} + +.claudian-plan-content-preview li { + margin: 0; +} + +.claudian-plan-content-preview code { + font-size: 11px; +} + +.claudian-plan-content-text { + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-monospace); +} + +/* ── Permissions list ──────────────────────────── */ + +.claudian-plan-permissions { + padding: 4px 10px 8px; +} + +.claudian-plan-permissions-label { + color: var(--text-muted); + font-weight: 600; + margin-bottom: 4px; +} + +.claudian-plan-permissions-list { + margin: 0; + padding-left: 18px; + color: var(--text-normal); + line-height: 1.5; +} + +.claudian-plan-permissions-list li { + margin: 0; +} + +/* ── Plan mode input border ──────────────────────── */ + +.claudian-input-wrapper.claudian-input-plan-mode { + --claudian-input-wrapper-border-color: rgb(92, 148, 140); + --claudian-input-wrapper-box-shadow: 0 0 0 1px rgb(92, 148, 140); +} diff --git a/src/style/features/resume-session.css b/src/style/features/resume-session.css new file mode 100644 index 0000000..20a9e41 --- /dev/null +++ b/src/style/features/resume-session.css @@ -0,0 +1,119 @@ +/* Resume Session Dropdown */ +.claudian-resume-dropdown { + display: none; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + margin-bottom: 4px; + background: var(--background-secondary); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.2); + z-index: 1000; + max-height: 400px; + overflow: hidden; +} + +.claudian-resume-dropdown.visible { + display: block; +} + +.claudian-resume-header { + padding: 8px 12px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--background-modifier-border); +} + +.claudian-resume-list { + max-height: 350px; + overflow-y: auto; +} + +.claudian-resume-empty { + padding: 16px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +.claudian-resume-item { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + cursor: pointer; + border-bottom: 1px solid var(--background-modifier-border); + transition: background 0.1s ease; +} + +.claudian-resume-item:last-child { + border-bottom: none; +} + +.claudian-resume-item:hover, +.claudian-resume-item.selected { + background: var(--background-modifier-hover); +} + +.claudian-resume-item.current { + background: var(--background-secondary); + border-inline-start: 2px solid var(--interactive-accent); + padding-inline-start: 10px; +} + +.claudian-resume-item-icon { + display: flex; + align-items: center; + color: var(--text-muted); + flex-shrink: 0; +} + +.claudian-resume-item-icon svg { + width: 16px; + height: 16px; +} + +.claudian-resume-item.current .claudian-resume-item-icon { + color: var(--interactive-accent); +} + +.claudian-resume-item-content { + flex: 1; + min-width: 0; +} + +.claudian-resume-item-title { + font-size: 13px; + font-weight: 500; + color: var(--text-normal); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.claudian-resume-item-date { + font-size: 11px; + color: var(--text-faint); + margin-top: 2px; +} + +/* Scrollbar */ +.claudian-resume-list::-webkit-scrollbar { + width: 6px; +} + +.claudian-resume-list::-webkit-scrollbar-track { + background: transparent; +} + +.claudian-resume-list::-webkit-scrollbar-thumb { + background: var(--background-modifier-border); + border-radius: 3px; +} diff --git a/src/style/features/slash-commands.css b/src/style/features/slash-commands.css new file mode 100644 index 0000000..da67637 --- /dev/null +++ b/src/style/features/slash-commands.css @@ -0,0 +1,91 @@ +/* Slash Command Dropdown */ +.claudian-slash-dropdown { + display: none; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + margin-bottom: 4px; + background: var(--background-secondary); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.2); + z-index: 1000; + max-height: 300px; + overflow-y: auto; +} + +.claudian-slash-dropdown.visible { + display: block; +} + +/* Fixed positioning for inline editor */ +.claudian-slash-dropdown-fixed { + position: fixed; + bottom: var(--claudian-fixed-dropdown-bottom); + left: var(--claudian-fixed-dropdown-left); + right: auto; + width: var(--claudian-fixed-dropdown-width); + z-index: 10001; +} + +.claudian-slash-item { + padding: 8px 12px; + cursor: pointer; + transition: background 0.1s ease; + border-bottom: 1px solid var(--background-modifier-border); +} + +.claudian-slash-item:last-child { + border-bottom: none; +} + +.claudian-slash-item:hover, +.claudian-slash-item.selected { + background: var(--background-modifier-hover); +} + +.claudian-slash-name { + font-size: 12px; + font-weight: 500; + color: var(--text-normal); + font-family: var(--font-monospace); +} + +.claudian-slash-hint { + font-size: 12px; + color: var(--text-muted); + margin-left: 8px; +} + +.claudian-slash-desc { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-slash-empty { + padding: 16px; + text-align: center; + color: var(--text-muted); + font-size: 13px; +} + +/* Scrollbar */ +.claudian-slash-dropdown::-webkit-scrollbar { + width: 6px; +} + +.claudian-slash-dropdown::-webkit-scrollbar-track { + background: transparent; +} + +.claudian-slash-dropdown::-webkit-scrollbar-thumb { + background: var(--background-modifier-border); + border-radius: 3px; +} diff --git a/src/style/index.css b/src/style/index.css new file mode 100644 index 0000000..a044322 --- /dev/null +++ b/src/style/index.css @@ -0,0 +1,64 @@ +/* CSS module order. scripts/build-css.mjs reads these @import lines. */ +/* Add new modules here to include them in the build. */ + +/* Base */ +@import "./base/variables.css"; +@import "./base/container.css"; +@import "./base/animations.css"; + +/* Components */ +@import "./components/header.css"; +@import "./components/tabs.css"; +@import "./components/history.css"; +@import "./components/messages.css"; +@import "./components/nav-sidebar.css"; +@import "./components/code.css"; +@import "./components/thinking.css"; +@import "./components/toolcalls.css"; +@import "./components/status-panel.css"; +@import "./components/subagent.css"; +@import "./components/input.css"; +@import "./components/context-tray.css"; +@import "./components/context-footer.css"; + +/* Toolbar */ +@import "./toolbar/model-selector.css"; +@import "./toolbar/mode-selector.css"; +@import "./toolbar/thinking-selector.css"; +@import "./toolbar/permission-toggle.css"; +@import "./toolbar/service-tier-toggle.css"; +@import "./toolbar/external-context.css"; +@import "./toolbar/mcp-selector.css"; + +/* Features */ +@import "./features/file-context.css"; +@import "./features/file-link.css"; +@import "./features/image-context.css"; +@import "./features/image-embed.css"; +@import "./features/image-modal.css"; +@import "./features/inline-edit.css"; +@import "./features/diff.css"; +@import "./features/slash-commands.css"; +@import "./features/resume-session.css"; +@import "./features/ask-user-question.css"; +@import "./features/plan-mode.css"; + +/* Modals */ +@import "./modals/instruction.css"; +@import "./modals/mcp-modal.css"; +@import "./modals/fork-target.css"; + +/* Settings */ +@import "./settings/base.css"; +@import "./settings/env-snippets.css"; +@import "./settings/slash-settings.css"; +@import "./settings/mcp-settings.css"; +@import "./settings/plugin-settings.css"; +@import "./settings/agent-settings.css"; +@import "./settings/provider-model-picker.css"; + +/* Accessibility */ +@import "./accessibility.css"; + +/* Utilities imported last to preserve cascade priority without importance flags */ +@import "./base/visibility.css"; diff --git a/src/style/modals/fork-target.css b/src/style/modals/fork-target.css new file mode 100644 index 0000000..f46374e --- /dev/null +++ b/src/style/modals/fork-target.css @@ -0,0 +1,21 @@ +/* Fork Target Modal */ +.claudian-fork-target-modal { + max-width: 340px; +} + +.claudian-fork-target-list { + display: flex; + flex-direction: column; +} + +.claudian-fork-target-option { + padding: 10px 12px; + border-radius: 6px; + cursor: pointer; + color: var(--text-normal); + font-size: 14px; +} + +.claudian-fork-target-option:hover { + background: var(--background-modifier-hover); +} diff --git a/src/style/modals/instruction.css b/src/style/modals/instruction.css new file mode 100644 index 0000000..5769eaa --- /dev/null +++ b/src/style/modals/instruction.css @@ -0,0 +1,161 @@ +/* Instruction Mode */ + +/* Instruction Confirm Modal */ +.claudian-instruction-modal { + max-width: 500px; +} + +.claudian-instruction-section { + margin-bottom: 16px; +} + +.claudian-instruction-label { + font-size: 12px; + font-weight: 500; + color: var(--text-muted); + margin-bottom: 6px; +} + +.claudian-instruction-original { + padding: 10px 12px; + background: var(--background-secondary); + border-radius: 6px; + font-size: 13px; + color: var(--text-muted); + font-style: italic; + white-space: pre-wrap; +} + +.claudian-instruction-refined { + padding: 12px; + background: var(--background-primary-alt); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + font-size: 14px; + color: var(--text-normal); + line-height: 1.5; + white-space: pre-wrap; +} + +.claudian-instruction-clarification { + padding: 12px; + background: var(--background-primary-alt); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + font-size: 14px; + color: var(--text-normal); + line-height: 1.5; + white-space: pre-wrap; +} + +.claudian-instruction-edit-container { + margin-top: 6px; +} + +.claudian-instruction-edit-textarea { + width: 100%; + padding: 10px 12px; + font-size: 14px; + line-height: 1.5; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-primary); + color: var(--text-normal); + resize: vertical; + min-height: 80px; +} + +.claudian-instruction-edit-textarea:focus { + outline: none; + border-color: var(--interactive-accent); +} + +.claudian-instruction-response-textarea { + width: 100%; + padding: 10px 12px; + font-size: 14px; + line-height: 1.5; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-primary); + color: var(--text-normal); + resize: vertical; + min-height: 60px; +} + +.claudian-instruction-response-textarea:focus { + outline: none; + border-color: var(--interactive-accent); +} + +.claudian-instruction-buttons { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 16px; +} + +.claudian-instruction-btn { + padding: 8px 16px; + font-size: 13px; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 500; +} + +.claudian-instruction-reject-btn { + background: var(--background-modifier-border); + color: var(--text-normal); +} + +.claudian-instruction-reject-btn:hover { + background: var(--background-modifier-border-hover); +} + +.claudian-instruction-edit-btn { + background: var(--background-modifier-border); + color: var(--text-normal); +} + +.claudian-instruction-edit-btn:hover { + background: var(--background-modifier-border-hover); +} + +.claudian-instruction-accept-btn { + background: var(--interactive-accent); + color: var(--text-on-accent); +} + +.claudian-instruction-accept-btn:hover { + opacity: 0.9; +} + +/* Instruction loading state */ +.claudian-instruction-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 20px; + color: var(--text-muted); +} + +.claudian-instruction-spinner { + width: 18px; + height: 18px; + border: 2px solid var(--background-modifier-border); + border-top-color: var(--interactive-accent); + border-radius: 50%; + animation: claudian-spin 0.8s linear infinite; +} + +/* Instruction modal content sections */ +.claudian-instruction-content-section { + margin: 8px 0; +} + +.claudian-instruction-clarification-section, +.claudian-instruction-confirmation-section { + margin-top: 8px; +} diff --git a/src/style/modals/mcp-modal.css b/src/style/modals/mcp-modal.css new file mode 100644 index 0000000..5eb6976 --- /dev/null +++ b/src/style/modals/mcp-modal.css @@ -0,0 +1,241 @@ +/* MCP Server Modal */ +.claudian-mcp-modal .modal-content { + width: 480px; + max-width: 90vw; +} + +.claudian-mcp-type-fields { + margin: 12px 0; + padding: 12px; + background: var(--background-secondary); + border-radius: 6px; +} + +.claudian-mcp-type-fields .setting-item { + padding: 8px 0; + border-top: none; +} + +.claudian-mcp-type-fields .setting-item:first-child { + padding-top: 0; +} + +.claudian-mcp-cmd-setting, +.claudian-mcp-env-setting { + flex-direction: column; + align-items: flex-start; +} + +.claudian-mcp-cmd-setting .setting-item-control, +.claudian-mcp-env-setting .setting-item-control { + width: 100%; + margin-top: 8px; +} + +.claudian-mcp-cmd-textarea, +.claudian-mcp-env-textarea { + width: 100%; + min-height: 50px; + resize: vertical; + font-family: var(--font-monospace); + font-size: 12px; + padding: 8px; + border-radius: 4px; + border: 1px solid var(--background-modifier-border); + background: var(--background-primary); +} + +.claudian-mcp-cmd-textarea:focus, +.claudian-mcp-env-textarea:focus { + border-color: var(--interactive-accent); + outline: none; +} + +.claudian-mcp-buttons { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +/* MCP Test Modal */ +.claudian-mcp-test-modal { + width: 500px; + max-width: 90vw; +} + +.claudian-mcp-test-modal .modal-content { + padding: 0 20px 20px 20px; +} + +.claudian-mcp-test-modal .modal-title { + padding: 20px 20px 12px 20px; + margin: 0 -20px; +} + +.claudian-mcp-test-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 32px; + color: var(--text-muted); +} + +.claudian-mcp-test-spinner { + width: 20px; + height: 20px; + animation: claudian-spin 1s linear infinite; +} + +.claudian-mcp-test-spinner svg { + width: 100%; + height: 100%; +} + +.claudian-mcp-test-status { + display: flex; + align-items: center; + gap: 10px; + padding: 12px; + background: var(--background-secondary); + border-radius: 6px; + margin-bottom: 12px; +} + +.claudian-mcp-test-icon { + display: flex; + align-items: center; +} + +.claudian-mcp-test-icon svg { + width: 20px; + height: 20px; +} + +.claudian-mcp-test-icon.success { + color: var(--color-green); +} + +.claudian-mcp-test-icon.error { + color: var(--text-error); +} + +.claudian-mcp-test-text { + font-weight: 500; +} + +.claudian-mcp-test-error { + padding: 10px 12px; + background: rgba(var(--color-red-rgb), 0.1); + border: 1px solid var(--text-error); + border-radius: 6px; + color: var(--text-error); + font-size: 12px; + margin-bottom: 12px; +} + +.claudian-mcp-test-tools { + margin-bottom: 16px; +} + +.claudian-mcp-test-tools-header { + font-weight: 600; + font-size: 13px; + margin-bottom: 8px; + color: var(--text-muted); +} + +.claudian-mcp-test-tools-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 300px; + overflow-y: auto; +} + +.claudian-mcp-test-tool { + padding: 10px 12px; + background: var(--background-secondary); + border-radius: 6px; +} + +.claudian-mcp-test-tool-header { + display: flex; + align-items: center; + gap: 8px; +} + +.claudian-mcp-test-tool-icon { + display: flex; + align-items: center; + color: var(--text-muted); +} + +.claudian-mcp-test-tool-icon svg { + width: 14px; + height: 14px; +} + +.claudian-mcp-test-tool-name { + font-weight: 500; + font-size: 13px; +} + +.claudian-mcp-test-tool-toggle { + margin-left: auto; +} + +.claudian-mcp-test-tool-toggle .checkbox-container { + display: flex; + align-items: center; +} + +.claudian-mcp-test-tool-disabled { + opacity: 0.75; +} + +.claudian-mcp-test-tool-disabled .claudian-mcp-test-tool-name { + text-decoration: line-through; + color: var(--text-muted); +} + +.claudian-mcp-toggle-all-btn { + margin-right: 8px; +} + +.claudian-mcp-toggle-all-btn.is-destructive { + background: rgba(var(--color-red-rgb), 0.1); + border-color: rgba(var(--color-red-rgb), 0.3); + color: var(--text-error); +} + +.claudian-mcp-toggle-all-btn.is-destructive:hover { + background: rgba(var(--color-red-rgb), 0.2); +} + +.claudian-mcp-test-tool-desc { + font-size: 12px; + color: var(--text-muted); + margin-top: 4px; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.claudian-mcp-test-no-tools { + padding: 16px; + text-align: center; + color: var(--text-muted); + font-size: 13px; + background: var(--background-secondary); + border-radius: 6px; + margin-bottom: 16px; +} + +.claudian-mcp-test-buttons { + display: flex; + justify-content: center; + margin-top: 16px; +} diff --git a/src/style/settings/agent-settings.css b/src/style/settings/agent-settings.css new file mode 100644 index 0000000..ee0dd71 --- /dev/null +++ b/src/style/settings/agent-settings.css @@ -0,0 +1,2 @@ +/* Agent Settings — all structural rules live in base.css .claudian-sp-* */ +/* This file is kept as a placeholder for future agent-specific overrides. */ diff --git a/src/style/settings/base.css b/src/style/settings/base.css new file mode 100644 index 0000000..53c3451 --- /dev/null +++ b/src/style/settings/base.css @@ -0,0 +1,300 @@ +/* ── Settings tab navigation ── */ + +.claudian-settings-tabs { + display: flex; + gap: 2px; + border-bottom: 1px solid var(--background-modifier-border); + margin-bottom: 16px; +} + +.claudian-settings-tab { + padding: 8px 16px; + border: none; + background: transparent; + color: var(--text-muted); + font-size: var(--font-ui-small); + font-weight: var(--font-medium); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: color 0.15s ease, border-color 0.15s ease; + margin-bottom: -1px; +} + +.claudian-settings-tab:hover { + color: var(--text-normal); +} + +.claudian-settings-tab--active { + color: var(--text-normal); + border-bottom-color: var(--interactive-accent); +} + +.claudian-settings-tab-content { + display: none; +} + +.claudian-settings-tab-content--active { + display: block; +} + +/* Codex placeholder */ +.claudian-settings-codex-placeholder { + padding: 40px 20px; + text-align: center; + color: var(--text-muted); + font-size: var(--font-ui-small); +} + +/* Settings page - remove separator lines from setting items */ +.claudian-settings .setting-item { + border-top: none; +} + +/* Settings section headings (via setHeading()) */ +.claudian-settings .setting-item-heading { + padding-top: 18px; + margin-top: 12px; + border-top: 1px solid var(--background-modifier-border); +} + +.claudian-settings .setting-item-heading:first-child { + padding-top: 0; + margin-top: 0; + border-top: none; +} + +.claudian-settings .setting-item-heading .setting-item-name { + font-size: var(--font-ui-medium); + font-weight: var(--font-semibold); + color: var(--text-normal); +} + +/* Custom section descriptions - align with items */ +.claudian-sp-settings-desc, +.claudian-mcp-settings-desc, +.claudian-plugin-settings-desc, +.claudian-approved-desc { + padding: 0 12px; +} + +/* Unified icon action buttons for settings */ +.claudian-settings-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + background: transparent; + border-radius: 4px; + cursor: pointer; + color: var(--text-muted); + transition: background 0.15s ease, color 0.15s ease; +} + +.claudian-settings-action-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.claudian-settings-action-btn svg { + width: 14px; + height: 14px; +} + +.claudian-settings-delete-btn:hover { + color: var(--text-error); +} + +.claudian-setting-validation { + font-size: var(--font-ui-smaller); + margin-top: -0.5em; + margin-bottom: 0.5em; +} + +.claudian-setting-validation-error { + color: var(--text-error); +} + +.claudian-setting-validation-warning { + color: var(--text-warning); +} + +.claudian-settings-cli-path-input { + width: 100%; +} + +.claudian-settings-cli-path-input.claudian-input-error { + border-color: var(--text-error); +} + +/* Hotkey grid - 3 columns */ +.claudian-hotkey-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px 12px; + padding: 4px 0; +} + +.claudian-hotkey-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + cursor: pointer; + border-radius: 6px; +} + +.claudian-hotkey-item:hover { + background: var(--background-modifier-hover); +} + +.claudian-hotkey-name { + flex: 1; + color: var(--text-normal); + font-size: var(--font-ui-small); +} + +.claudian-hotkey-badge { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + background: var(--background-modifier-hover); + padding: 2px 6px; + border-radius: 4px; + font-family: var(--font-monospace); +} + +/* Media folder input width */ +.claudian-settings-media-input { + width: 200px; +} + +/* ── Shared settings panel layout (used by slash-settings + agent-settings) ── */ + +.claudian-sp-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding: 0 12px; +} + +.claudian-sp-label { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-medium); +} + +.claudian-sp-header-actions { + display: flex; + gap: 4px; +} + +.claudian-sp-empty-state { + padding: 20px; + text-align: center; + color: var(--text-muted); + background: var(--background-secondary); + border-radius: 6px; + margin-top: 8px; +} + +.claudian-sp-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.claudian-sp-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 12px; + background: var(--background-secondary); + border-radius: 6px; +} + +.claudian-sp-item:hover { + background: var(--background-modifier-hover); +} + +.claudian-sp-info { + flex: 1; + min-width: 0; +} + +.claudian-sp-item-header { + display: flex; + align-items: baseline; + gap: 8px; +} + +.claudian-sp-item-name { + font-weight: 600; + font-family: var(--font-monospace); + color: var(--text-normal); +} + +.claudian-sp-item-desc { + font-size: 13px; + color: var(--text-muted); + margin-top: 2px; +} + +.claudian-sp-item-actions { + display: flex; + gap: 4px; + margin-left: 16px; + flex-shrink: 0; +} + +.claudian-sp-advanced-section { + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + padding: 0 12px; + margin: 8px 0; +} + +.claudian-sp-advanced-summary { + cursor: pointer; + padding: 8px 0; + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-medium); +} + +.claudian-sp-advanced-section[open] .claudian-sp-advanced-summary { + margin-bottom: 4px; +} + +.claudian-sp-modal .modal-content { + max-width: 600px; + width: auto; +} + +.claudian-sp-content-area { + width: 100%; + font-family: var(--font-monospace); + font-size: 13px; + padding: 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-normal); + resize: vertical; + margin-top: 8px; +} + +.claudian-sp-content-area:focus { + outline: none; + border-color: var(--interactive-accent); +} + +.claudian-sp-modal-buttons { + display: flex; + gap: 8px; + margin-top: 16px; + justify-content: flex-end; +} diff --git a/src/style/settings/env-snippets.css b/src/style/settings/env-snippets.css new file mode 100644 index 0000000..f442ee3 --- /dev/null +++ b/src/style/settings/env-snippets.css @@ -0,0 +1,366 @@ +/* Context Limits Styles */ +.claudian-context-limits-container { + margin-top: 16px; +} + +.claudian-context-limits-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + margin-top: 16px; + padding: 0 12px; +} + +.claudian-context-limits-label { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-medium); +} + +.claudian-context-limits-desc { + font-size: var(--font-ui-smaller); + color: var(--text-muted); + padding: 0 12px; + margin-bottom: 8px; +} + +.claudian-context-limits-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.claudian-context-limits-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background: var(--background-secondary); + border-radius: 6px; + transition: background-color 0.2s; +} + +.claudian-context-limits-item:hover { + background: var(--background-modifier-hover); +} + +.claudian-context-limits-model { + font-family: var(--font-monospace); + font-size: var(--font-ui-small); + color: var(--text-normal); + flex: 1; + min-width: 0; + word-break: break-all; +} + +.claudian-context-limits-input-wrapper { + display: flex; + align-items: center; + gap: 8px; + margin-left: 16px; + flex-shrink: 0; +} + +.claudian-context-alias-input { + width: 180px; + padding: 4px 8px; + font-size: var(--font-ui-small); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-normal); +} + +.claudian-context-alias-input:focus { + border-color: var(--interactive-accent); + outline: none; +} + +.claudian-context-limits-input { + width: 80px; + padding: 4px 8px; + font-size: var(--font-ui-small); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-normal); +} + +.claudian-context-limits-input:focus { + border-color: var(--interactive-accent); + outline: none; +} + +.claudian-context-limits-input.claudian-input-error { + border-color: var(--text-error); +} + +.claudian-context-limit-validation { + font-size: var(--font-ui-smaller); + color: var(--text-error); + margin-left: 4px; +} + +/* Environment Snippets Styles */ +.claudian-env-snippets-container { + margin-top: 16px; +} + +.claudian-snippet-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + margin-top: 16px; + padding: 0 12px; +} + +.claudian-snippet-label { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-medium); +} + +.claudian-save-env-btn { + padding: 6px 16px; + font-size: 13px; + background: var(--interactive-accent); + color: var(--text-on-accent); + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.2s; +} + +.claudian-save-env-btn:hover { + opacity: 0.9; +} + +.claudian-snippet-empty { + padding: 20px; + text-align: center; + color: var(--text-muted); + background: var(--background-secondary); + border-radius: 6px; + margin-top: 8px; +} + +.claudian-snippet-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.claudian-snippet-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background: var(--background-secondary); + border-radius: 6px; + transition: background-color 0.2s; +} + +.claudian-snippet-item:hover { + background: var(--background-modifier-hover); +} + +.claudian-snippet-info { + flex: 1; + min-width: 0; +} + +.claudian-snippet-name { + font-weight: 600; + margin-bottom: 4px; + word-break: break-word; +} + +.claudian-snippet-description { + font-size: 13px; + color: var(--text-muted); +} + +.claudian-snippet-actions { + display: flex; + gap: 4px; + margin-left: 16px; + flex-shrink: 0; +} + +.claudian-restore-snippet-btn { + padding: 4px 12px; + font-size: 12px; + background: var(--interactive-accent); + color: var(--text-on-accent); + border: none; + border-radius: 4px; + cursor: pointer; +} + +.claudian-restore-snippet-btn:hover { + opacity: 0.9; +} + +.claudian-edit-snippet-btn { + padding: 4px 12px; + font-size: 12px; + background: var(--background-modifier-border); + color: var(--text-normal); + border: none; + border-radius: 4px; + cursor: pointer; +} + +.claudian-edit-snippet-btn:hover { + background: var(--background-modifier-border-hover); +} + +.claudian-delete-snippet-btn { + padding: 4px 12px; + font-size: 12px; + background: var(--background-modifier-error); + color: var(--text-on-accent); + border: none; + border-radius: 4px; + cursor: pointer; +} + +.claudian-delete-snippet-btn:hover { + opacity: 0.9; +} + +/* Env Snippet Modal */ +.claudian-env-snippet-modal .modal-content { + max-width: 550px; + width: 550px; + padding: 16px; +} + +.claudian-env-snippet-modal h2 { + margin: 0 0 16px 0; +} + +.claudian-env-snippet-modal .setting-item { + padding: 8px 0; + margin: 0; +} + +.claudian-env-snippet-modal .setting-item-info { + margin-bottom: 4px; +} + +/* Full-width env vars textarea setting */ +.claudian-env-snippet-setting { + flex-direction: column; + align-items: flex-start; +} + +.claudian-env-snippet-setting .setting-item-info { + width: 100%; + margin-bottom: 8px; +} + +.claudian-env-snippet-control { + width: 100%; +} + +.claudian-env-snippet-control textarea { + width: 100%; + min-width: 100%; + font-family: var(--font-monospace); + font-size: 12px; + resize: vertical; +} + +.claudian-snippet-preview { + margin: 8px 0; + padding: 6px; + background: var(--background-secondary); + border-radius: 6px; +} + +.claudian-env-preview { + background: var(--background-primary); + padding: 6px; + border-radius: 4px; + font-family: var(--font-monospace); + font-size: 11px; + line-height: 1.3; + white-space: pre-wrap; + word-break: break-all; + color: var(--text-muted); + max-height: 120px; + overflow-y: auto; + margin: 0; +} + +.claudian-snippet-buttons { + display: flex; + gap: 8px; + margin-top: 16px; + justify-content: flex-end; +} + +.claudian-cancel-btn, +.claudian-save-btn { + padding: 6px 16px; + font-size: 13px; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.claudian-cancel-btn { + background: var(--background-modifier-border); + color: var(--text-normal); +} + +.claudian-cancel-btn:hover { + background: var(--background-modifier-border-hover); +} + +.claudian-save-btn { + background: var(--interactive-accent); + color: var(--text-on-accent); +} + +.claudian-save-btn:hover { + opacity: 0.9; +} + +/* Context limits section in snippet modal */ +.claudian-snippet-context-limits { + margin-top: 1em; +} + +.claudian-snippet-context-limits .setting-item-description { + margin-bottom: 0.5em; +} + +.claudian-snippet-limit-row { + display: flex; + align-items: center; + gap: 0.5em; + margin-bottom: 0.5em; +} + +.claudian-snippet-limit-model { + font-family: var(--font-monospace); + font-size: var(--font-ui-small); +} + +.claudian-snippet-limit-spacer { + flex: 1; +} + +.claudian-snippet-limit-input { + width: 80px; +} + +.claudian-snippet-alias-input { + width: 160px; +} diff --git a/src/style/settings/mcp-settings.css b/src/style/settings/mcp-settings.css new file mode 100644 index 0000000..e037212 --- /dev/null +++ b/src/style/settings/mcp-settings.css @@ -0,0 +1,211 @@ +/* MCP Server Settings */ +.claudian-mcp-settings-desc { + margin-bottom: 12px; +} + +.claudian-mcp-container { + margin-top: 8px; +} + +.claudian-mcp-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding: 0 12px; +} + +.claudian-mcp-label { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-medium); +} + +.claudian-mcp-add-container { + position: relative; +} + +.claudian-add-mcp-btn { + padding: 4px 12px; + border-radius: 4px; + background: var(--interactive-accent); + color: var(--text-on-accent); + font-size: 12px; + cursor: pointer; + border: none; +} + +.claudian-add-mcp-btn:hover { + background: var(--interactive-accent-hover); +} + +.claudian-mcp-add-dropdown { + display: none; + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + min-width: 180px; + background-color: var(--modal-background, var(--background-primary)); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + z-index: 100; + overflow: hidden; +} + +.claudian-mcp-add-dropdown.is-visible { + display: block; +} + +.claudian-mcp-add-option { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + cursor: pointer; + font-size: 13px; + color: var(--text-normal); +} + +.claudian-mcp-add-option:hover { + background: var(--background-modifier-hover); +} + +.claudian-mcp-add-option-icon { + display: flex; + align-items: center; + color: var(--text-muted); +} + +.claudian-mcp-add-option-icon svg { + width: 16px; + height: 16px; +} + +.claudian-mcp-empty { + padding: 16px; + text-align: center; + color: var(--text-muted); + font-size: 13px; + background: var(--background-secondary); + border-radius: 6px; +} + +.claudian-mcp-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.claudian-mcp-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + background: var(--background-secondary); + border-radius: 6px; + transition: background 0.15s ease; +} + +.claudian-mcp-item:hover { + background: var(--background-modifier-hover); +} + +.claudian-mcp-item-disabled { + opacity: 0.6; +} + +.claudian-mcp-status { + width: 8px; + height: 8px; + border-radius: 50%; + margin-top: 6px; + flex-shrink: 0; +} + +.claudian-mcp-status-enabled { + background: var(--color-green); +} + +.claudian-mcp-status-disabled { + background: var(--text-muted); +} + +.claudian-mcp-info { + flex: 1; + min-width: 0; +} + +.claudian-mcp-name-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.claudian-mcp-name { + font-weight: 600; +} + +.claudian-mcp-type-badge { + font-size: 10px; + padding: 2px 6px; + background: var(--background-modifier-border); + border-radius: 4px; + color: var(--text-muted); + text-transform: uppercase; +} + +.claudian-mcp-context-saving-badge { + font-size: 11px; + padding: 2px 6px; + background: var(--interactive-accent); + color: var(--text-on-accent); + border-radius: 4px; + font-weight: 600; +} + +.claudian-mcp-preview { + font-size: 12px; + color: var(--text-muted); + margin-top: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-mcp-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.claudian-mcp-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + background: transparent; + border-radius: 4px; + cursor: pointer; + color: var(--text-muted); + transition: background 0.15s ease, color 0.15s ease; +} + +.claudian-mcp-action-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.claudian-mcp-action-btn svg { + width: 14px; + height: 14px; +} + +.claudian-mcp-delete-btn:hover { + color: var(--text-error); +} diff --git a/src/style/settings/plugin-settings.css b/src/style/settings/plugin-settings.css new file mode 100644 index 0000000..4966117 --- /dev/null +++ b/src/style/settings/plugin-settings.css @@ -0,0 +1,164 @@ +/* Plugin Settings */ +.claudian-plugin-settings-desc { + margin-bottom: 12px; +} + +.claudian-plugins-container { + margin-top: 8px; +} + +.claudian-plugin-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding: 0 12px; +} + +.claudian-plugin-label { + font-size: var(--font-ui-small); + color: var(--text-muted); + font-weight: var(--font-medium); +} + +.claudian-plugin-empty { + padding: 16px; + text-align: center; + color: var(--text-muted); + font-size: 13px; + background: var(--background-secondary); + border-radius: 6px; +} + +.claudian-plugin-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.claudian-plugin-section-header { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + padding: 8px 12px 4px; + font-weight: 600; +} + +.claudian-plugin-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + background: var(--background-secondary); + border-radius: 6px; + transition: background 0.15s ease; +} + +.claudian-plugin-item:hover { + background: var(--background-modifier-hover); +} + +.claudian-plugin-item-disabled { + opacity: 0.6; +} + +.claudian-plugin-item-error { + opacity: 0.8; +} + +.claudian-plugin-status { + width: 8px; + height: 8px; + border-radius: 50%; + margin-top: 6px; + flex-shrink: 0; +} + +.claudian-plugin-status-enabled { + background: var(--color-green); +} + +.claudian-plugin-status-disabled { + background: var(--text-muted); +} + +.claudian-plugin-status-error { + background: var(--text-error); +} + +.claudian-plugin-info { + flex: 1; + min-width: 0; +} + +.claudian-plugin-name-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.claudian-plugin-name { + font-weight: 600; +} + +.claudian-plugin-version-badge { + font-size: 10px; + padding: 2px 6px; + background: var(--background-modifier-border); + border-radius: 4px; + color: var(--text-muted); +} + +.claudian-plugin-error-badge { + font-size: 10px; + padding: 2px 6px; + background: var(--text-error); + color: var(--text-on-accent); + border-radius: 4px; + text-transform: uppercase; +} + +.claudian-plugin-preview { + font-size: 12px; + color: var(--text-muted); + margin-top: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-plugin-preview-error { + color: var(--text-error); +} + +.claudian-plugin-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.claudian-plugin-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + background: transparent; + border-radius: 4px; + cursor: pointer; + color: var(--text-muted); + transition: background 0.15s ease, color 0.15s ease; +} + +.claudian-plugin-action-btn:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.claudian-plugin-action-btn svg { + width: 14px; + height: 14px; +} diff --git a/src/style/settings/provider-model-picker.css b/src/style/settings/provider-model-picker.css new file mode 100644 index 0000000..0459ebc --- /dev/null +++ b/src/style/settings/provider-model-picker.css @@ -0,0 +1,367 @@ +/* Provider model picker */ + +.claudian-provider-model-picker { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +.claudian-provider-model-picker-summary { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + font-size: var(--font-ui-small); + color: var(--text-muted); +} + +.claudian-provider-model-picker-summary-value { + color: var(--text-normal); + font-weight: var(--font-medium); +} + +.claudian-provider-model-picker-selected { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + background: var(--background-secondary); + border-radius: 6px; +} + +.claudian-provider-model-picker-selected-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.claudian-provider-model-picker-selected-label { + font-size: var(--font-ui-smaller); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.claudian-provider-model-picker-selected-clear { + padding: 2px 8px; + font-size: var(--font-ui-smaller); + color: var(--text-muted); + background: transparent; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + cursor: pointer; +} + +.claudian-provider-model-picker-selected-clear:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.claudian-provider-model-picker-action { + padding: 5px 10px; + font-size: var(--font-ui-small); + color: var(--text-muted); + background: transparent; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + cursor: pointer; +} + +.claudian-provider-model-picker-action:hover:not(:disabled) { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.claudian-provider-model-picker-action:disabled { + cursor: default; + opacity: 0.6; +} + +.claudian-provider-model-picker-selected-rows { + display: flex; + flex-direction: column; + gap: 6px; +} + +.claudian-provider-model-picker-selected-row { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 10px; + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-radius: 6px; +} + +.claudian-provider-model-picker-selected-row--unavailable { + border-style: dashed; +} + +.claudian-provider-model-picker-selected-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.claudian-provider-model-picker-selected-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + min-width: 0; +} + +.claudian-provider-model-picker-selected-badge { + font-size: var(--font-ui-smaller); + padding: 1px 6px; + border-radius: 999px; + background: var(--background-modifier-hover); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.claudian-provider-model-picker-selected-name { + color: var(--text-normal); + font-weight: var(--font-medium); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-provider-model-picker-selected-id { + font-size: var(--font-ui-smaller); + color: var(--text-muted); + font-family: var(--font-monospace); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-provider-model-picker-selected-unavailable { + font-size: var(--font-ui-smaller); + color: var(--text-muted); + font-style: italic; +} + +.claudian-provider-model-picker-selected-controls { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +.claudian-provider-model-picker-selected-alias { + width: 180px; + padding: 4px 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + background: var(--background-primary); + color: var(--text-normal); + font-size: var(--font-ui-small); +} + +.claudian-provider-model-picker-selected-alias:focus { + outline: none; + border-color: var(--interactive-accent); +} + +.claudian-provider-model-picker-selected-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + padding: 0; + border: none; + background: transparent; + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; + line-height: 1; + font-size: 16px; +} + +.claudian-provider-model-picker-selected-remove:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.claudian-provider-model-picker-catalog { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + background: var(--background-secondary); + border-radius: 6px; +} + +.claudian-provider-model-picker-catalog-summary { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; + list-style: none; +} + +.claudian-provider-model-picker-catalog-summary::-webkit-details-marker { + display: none; +} + +.claudian-provider-model-picker-catalog-caret { + font-size: 16px; + color: var(--text-muted); + transition: transform 0.12s ease; + display: inline-flex; + width: 18px; + justify-content: center; + line-height: 1; +} + +.claudian-provider-model-picker-catalog[open] .claudian-provider-model-picker-catalog-caret { + transform: rotate(90deg); +} + +.claudian-provider-model-picker-catalog-title { + color: var(--text-normal); + font-weight: var(--font-medium); + font-size: var(--font-ui-small); +} + +.claudian-provider-model-picker-catalog-count { + font-size: var(--font-ui-smaller); + color: var(--text-muted); + margin-left: auto; +} + +.claudian-provider-model-picker-controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.claudian-provider-model-picker-search { + flex: 1 1 220px; + min-width: 180px; + padding: 6px 10px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-primary); + color: var(--text-normal); + font-size: var(--font-ui-small); +} + +.claudian-provider-model-picker-search:focus { + outline: none; + border-color: var(--interactive-accent); +} + +.claudian-provider-model-picker-provider { + padding: 6px 10px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-primary); + color: var(--text-normal); + font-size: var(--font-ui-small); +} + +.claudian-provider-model-picker-list { + display: flex; + flex-direction: column; + margin-top: 8px; + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + background: var(--background-primary); + max-height: 360px; + overflow-y: auto; +} + +.claudian-provider-model-picker-row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 8px 12px; + border-bottom: 1px solid var(--background-modifier-border); + cursor: pointer; +} + +.claudian-provider-model-picker-row:last-child { + border-bottom: none; +} + +.claudian-provider-model-picker-row:hover { + background: var(--background-modifier-hover); +} + +.claudian-provider-model-picker-row--selected { + background: var(--background-modifier-hover); +} + +.claudian-provider-model-picker-row input[type="checkbox"] { + margin-top: 3px; +} + +.claudian-provider-model-picker-row-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.claudian-provider-model-picker-row-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-width: 0; +} + +.claudian-provider-model-picker-row-name { + color: var(--text-normal); + font-weight: var(--font-medium); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-provider-model-picker-row-badge { + font-size: var(--font-ui-smaller); + padding: 1px 6px; + border-radius: 999px; + background: var(--background-modifier-hover); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.claudian-provider-model-picker-row-badge--unavailable { + background: var(--background-modifier-error); + color: var(--text-on-accent); +} + +.claudian-provider-model-picker-row-meta { + font-size: var(--font-ui-smaller); + color: var(--text-muted); + font-family: var(--font-monospace); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-provider-model-picker-row-desc { + font-size: var(--font-ui-smaller); + color: var(--text-muted); +} + +.claudian-provider-model-picker-empty { + padding: 24px 16px; + text-align: center; + color: var(--text-muted); + font-size: var(--font-ui-small); +} diff --git a/src/style/settings/slash-settings.css b/src/style/settings/slash-settings.css new file mode 100644 index 0000000..432aed6 --- /dev/null +++ b/src/style/settings/slash-settings.css @@ -0,0 +1,16 @@ +/* Slash Command Settings — unique rules only (shared layout in base.css .claudian-sp-*) */ + +.claudian-slash-item-hint { + font-size: 12px; + color: var(--text-muted); + font-style: italic; +} + +.claudian-slash-item-badge { + font-size: 10px; + padding: 2px 6px; + background: var(--background-modifier-border); + border-radius: 4px; + color: var(--text-muted); + text-transform: uppercase; +} diff --git a/src/style/toolbar/external-context.css b/src/style/toolbar/external-context.css new file mode 100644 index 0000000..17010a3 --- /dev/null +++ b/src/style/toolbar/external-context.css @@ -0,0 +1,177 @@ +/* External Context Selector */ +.claudian-external-context-selector { + position: relative; + display: flex; + align-items: center; + margin-left: 8px; +} + +.claudian-external-context-icon-wrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.claudian-external-context-icon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + color: var(--text-faint); + transition: color 0.15s ease; +} + +.claudian-external-context-icon-wrapper:hover .claudian-external-context-icon { + color: var(--text-normal); +} + +.claudian-external-context-icon.active { + color: var(--claudian-brand); + animation: external-context-glow 2s ease-in-out infinite; +} + +.claudian-external-context-icon svg { + width: 16px; + height: 16px; +} + +.claudian-external-context-badge { + position: absolute; + top: 0; + right: 0; + font-size: 9px; + font-weight: 600; + color: var(--claudian-brand); + opacity: 0; + transition: opacity 0.15s ease; + pointer-events: none; +} + +.claudian-external-context-badge.visible { + opacity: 1; +} + +.claudian-external-context-dropdown { + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: 100%; + margin-bottom: 4px; + min-width: 260px; + max-width: 320px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; + z-index: 100; +} + +.claudian-external-context-selector:hover .claudian-external-context-dropdown { + opacity: 1; + visibility: visible; +} + +.claudian-external-context-header { + padding: 10px 12px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + border-bottom: 1px solid var(--background-modifier-border); +} + +.claudian-external-context-list { + max-height: 200px; + overflow-y: auto; +} + +.claudian-external-context-empty { + padding: 16px 12px; + text-align: center; + color: var(--text-muted); + font-size: 12px; + font-style: italic; +} + +.claudian-external-context-item { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 8px; + border-bottom: 1px solid var(--background-modifier-border-focus); +} + +.claudian-external-context-item:last-child { + border-bottom: none; +} + +.claudian-external-context-text { + flex: 1; + font-size: 12px; + font-family: var(--font-monospace); + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-external-context-lock { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + cursor: pointer; + color: var(--text-muted); + opacity: 0.4; + transition: all 0.15s ease; +} + +.claudian-external-context-lock:hover { + background: var(--background-modifier-hover); + opacity: 0.8; +} + +.claudian-external-context-lock.locked { + color: var(--claudian-brand); + opacity: 0.9; +} + +.claudian-external-context-lock.locked:hover { + opacity: 1; +} + +.claudian-external-context-lock svg { + width: 12px; + height: 12px; +} + +.claudian-external-context-remove { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + cursor: pointer; + color: var(--text-muted); + opacity: 0.6; + transition: all 0.15s ease; +} + +.claudian-external-context-remove:hover { + background: rgba(var(--claudian-error-rgb), 0.15); + color: var(--claudian-error); + opacity: 1; +} + +.claudian-external-context-remove svg { + width: 14px; + height: 14px; +} diff --git a/src/style/toolbar/mcp-selector.css b/src/style/toolbar/mcp-selector.css new file mode 100644 index 0000000..15de33e --- /dev/null +++ b/src/style/toolbar/mcp-selector.css @@ -0,0 +1,176 @@ +/* MCP Server Selector */ +.claudian-mcp-selector { + position: relative; + display: flex; + align-items: center; + margin-left: 8px; +} + +.claudian-mcp-selector-icon-wrapper { + position: relative; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.claudian-mcp-selector-icon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + color: var(--text-faint); + transition: color 0.15s ease; +} + +.claudian-mcp-selector-icon-wrapper:hover .claudian-mcp-selector-icon { + color: var(--text-normal); +} + +.claudian-mcp-selector-icon.active { + color: var(--claudian-brand); + animation: mcp-glow 2s ease-in-out infinite; +} + +.claudian-mcp-selector-icon svg { + width: 16px; + height: 16px; +} + +.claudian-mcp-selector-badge { + position: absolute; + top: 0; + right: 0; + font-size: 9px; + font-weight: 600; + color: var(--claudian-brand); + opacity: 0; + transition: opacity 0.15s ease; + pointer-events: none; +} + +.claudian-mcp-selector-badge.visible { + opacity: 1; +} + +.claudian-mcp-selector-dropdown { + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: 100%; + margin-bottom: 4px; + min-width: 200px; + max-width: 280px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; + z-index: 100; +} + +/* Bridge the gap between icon and dropdown to prevent hover breaks */ +.claudian-mcp-selector-dropdown::after { + content: ''; + position: absolute; + left: 0; + right: 0; + bottom: -8px; + height: 8px; +} + +.claudian-mcp-selector-dropdown.visible, +.claudian-mcp-selector:hover .claudian-mcp-selector-dropdown { + opacity: 1; + visibility: visible; +} + +.claudian-mcp-selector-header { + padding: 10px 12px; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + border-bottom: 1px solid var(--background-modifier-border); +} + +.claudian-mcp-selector-list { + max-height: 200px; + overflow-y: auto; +} + +.claudian-mcp-selector-empty { + padding: 16px 12px; + text-align: center; + color: var(--text-muted); + font-size: 12px; + font-style: italic; +} + +.claudian-mcp-selector-item { + display: flex; + align-items: center; + padding: 8px 12px; + gap: 8px; + cursor: pointer; + transition: background 0.15s ease; +} + +.claudian-mcp-selector-item:hover { + background: var(--background-modifier-hover); +} + +.claudian-mcp-selector-item.enabled { + background: rgba(var(--claudian-brand-rgb), 0.1); +} + +.claudian-mcp-selector-check { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: 1px solid var(--background-modifier-border); + border-radius: 3px; + color: var(--claudian-brand); +} + +.claudian-mcp-selector-item.enabled .claudian-mcp-selector-check { + background: rgba(var(--claudian-brand-rgb), 0.2); + border-color: var(--claudian-brand); +} + +.claudian-mcp-selector-check svg { + width: 12px; + height: 12px; +} + +.claudian-mcp-selector-item-info { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + flex: 1; + overflow: hidden; +} + +.claudian-mcp-selector-item-name { + font-size: 12px; + color: var(--text-normal); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.claudian-mcp-selector-cs-badge { + font-size: 10px; + font-weight: 600; + padding: 1px 4px; + border-radius: 3px; + background: rgba(var(--claudian-brand-rgb), 0.2); + color: var(--claudian-brand); + flex-shrink: 0; + margin-left: auto; +} diff --git a/src/style/toolbar/mode-selector.css b/src/style/toolbar/mode-selector.css new file mode 100644 index 0000000..67b5b2c --- /dev/null +++ b/src/style/toolbar/mode-selector.css @@ -0,0 +1,19 @@ +.claudian-mode-selector { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + padding-left: 12px; + padding-right: 8px; +} + +.claudian-mode-label { + font-size: 11px; + color: var(--text-muted); + min-width: 32px; +} + +.claudian-mode-label.active { + color: var(--claudian-brand); + font-weight: 600; +} diff --git a/src/style/toolbar/model-selector.css b/src/style/toolbar/model-selector.css new file mode 100644 index 0000000..d7a9518 --- /dev/null +++ b/src/style/toolbar/model-selector.css @@ -0,0 +1,99 @@ +/* Model selector */ +.claudian-model-selector { + position: relative; +} + +.claudian-model-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + color: var(--claudian-brand); + font-size: 12px; +} + +.claudian-model-label { + font-weight: 500; +} + +.claudian-model-chevron { + display: flex; + align-items: center; +} + +.claudian-model-chevron svg { + width: 12px; + height: 12px; +} + +.claudian-model-dropdown { + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 0; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.15); + z-index: 1000; + width: max-content; + padding: 4px; + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; +} + +.claudian-model-selector:hover .claudian-model-dropdown { + opacity: 1; + visibility: visible; +} + +.claudian-model-group { + padding: 3px 8px; + font-size: 8px; + font-weight: 600; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.05em; + pointer-events: none; +} + +.claudian-model-group:not(:first-child) { + margin-top: 4px; + border-top: 1px solid var(--background-modifier-border); + padding-top: 6px; +} + +.claudian-model-provider-icon { + flex-shrink: 0; + opacity: 0.7; +} + +.claudian-model-option { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + cursor: pointer; + font-size: 12px; + color: var(--text-muted); + border-radius: 3px; + transition: background 0.1s ease, color 0.1s ease; + white-space: nowrap; +} + +.claudian-model-option:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.claudian-model-option.selected { + background: rgba(var(--claudian-brand-rgb), 0.15); + color: var(--claudian-brand); + font-weight: 500; +} diff --git a/src/style/toolbar/permission-toggle.css b/src/style/toolbar/permission-toggle.css new file mode 100644 index 0000000..9be5fa2 --- /dev/null +++ b/src/style/toolbar/permission-toggle.css @@ -0,0 +1,56 @@ +/* Permission Mode Toggle */ +.claudian-permission-toggle { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; + padding-left: 12px; + padding-right: 8px; +} + +.claudian-permission-label { + font-size: 11px; + color: var(--text-muted); + min-width: 28px; +} + +.claudian-permission-label.plan-active { + color: rgb(92, 148, 140); + font-weight: 600; +} + +.claudian-toggle-switch { + width: 32px; + height: 18px; + border-radius: 9px; + background: var(--background-modifier-border); + cursor: pointer; + position: relative; + transition: background 0.2s ease; + flex-shrink: 0; +} + +.claudian-toggle-switch::after { + content: ''; + position: absolute; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--text-muted); + top: 2px; + left: 2px; + transition: transform 0.2s ease, background 0.2s ease; +} + +.claudian-toggle-switch:hover { + background: var(--background-modifier-hover); +} + +.claudian-toggle-switch.active { + background: rgba(var(--claudian-brand-rgb), 0.3); +} + +.claudian-toggle-switch.active::after { + transform: translateX(14px); + background: var(--claudian-brand); +} diff --git a/src/style/toolbar/service-tier-toggle.css b/src/style/toolbar/service-tier-toggle.css new file mode 100644 index 0000000..8d7fd50 --- /dev/null +++ b/src/style/toolbar/service-tier-toggle.css @@ -0,0 +1,39 @@ +/* Codex fast-mode / service-tier toggle */ +.claudian-service-tier-toggle { + display: flex; + align-items: center; + padding-left: 2px; +} + +.claudian-service-tier-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 4px; + color: var(--text-muted); + background: transparent; + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease; + flex-shrink: 0; +} + +.claudian-service-tier-icon { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.claudian-service-tier-icon svg { + width: 14px; + height: 14px; +} + +.claudian-service-tier-button:hover { + background: var(--background-modifier-hover); +} + +.claudian-service-tier-button.active { + color: var(--claudian-brand); +} diff --git a/src/style/toolbar/thinking-selector.css b/src/style/toolbar/thinking-selector.css new file mode 100644 index 0000000..fa5d0a3 --- /dev/null +++ b/src/style/toolbar/thinking-selector.css @@ -0,0 +1,83 @@ +/* Thinking selector (effort for adaptive models, token budget for custom) */ +.claudian-thinking-selector { + display: flex; + align-items: center; + gap: 6px; +} + +/* Effort / budget container (shared layout) */ +.claudian-thinking-effort, +.claudian-thinking-budget { + display: flex; + align-items: center; + gap: 6px; +} + +.claudian-thinking-label-text { + font-size: 11px; + color: var(--text-muted); +} + +.claudian-thinking-gears { + position: relative; + display: flex; + align-items: center; + border-radius: 4px; +} + +/* Current selection (visible when collapsed) */ +.claudian-thinking-current { + padding: 3px 8px; + font-size: 11px; + color: var(--claudian-brand); + font-weight: 500; + cursor: pointer; + border-radius: 3px; + white-space: nowrap; + background: transparent; +} + +/* Options container - expands vertically upward */ +.claudian-thinking-options { + position: absolute; + left: 0; + bottom: 100%; + margin-bottom: 0; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 4px; + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; +} + +/* Expand on hover */ +.claudian-thinking-gears:hover .claudian-thinking-options { + opacity: 1; + visibility: visible; +} + +.claudian-thinking-gear { + padding: 3px 8px; + font-size: 11px; + color: var(--text-muted); + cursor: pointer; + border-radius: 3px; + transition: background 0.1s ease, color 0.1s ease; + white-space: nowrap; +} + +.claudian-thinking-gear:hover { + background: var(--background-modifier-hover); + color: var(--text-normal); +} + +.claudian-thinking-gear.selected { + background: rgba(var(--claudian-brand-rgb), 0.15); + color: var(--claudian-brand); + font-weight: 500; +} diff --git a/src/types/smol-toml.d.ts b/src/types/smol-toml.d.ts new file mode 100644 index 0000000..321f94a --- /dev/null +++ b/src/types/smol-toml.d.ts @@ -0,0 +1,4 @@ +declare module 'smol-toml' { + export function parse(input: string): unknown; + export function stringify(value: unknown): string; +} diff --git a/src/utils/agent.ts b/src/utils/agent.ts new file mode 100644 index 0000000..e19a944 --- /dev/null +++ b/src/utils/agent.ts @@ -0,0 +1,50 @@ +import type { AgentDefinition } from '../core/types'; +import { validateSlugName } from './frontmatter'; +import { yamlString } from './slashCommand'; + +export function validateAgentName(name: string): string | null { + return validateSlugName(name, 'Agent'); +} + +function pushYamlList(lines: string[], key: string, items?: string[]): void { + if (!items || items.length === 0) return; + lines.push(`${key}:`); + for (const item of items) { + lines.push(` - ${yamlString(item)}`); + } +} + +export function serializeAgent(agent: AgentDefinition): string { + const lines: string[] = ['---']; + + lines.push(`name: ${agent.name}`); + lines.push(`description: ${yamlString(agent.description)}`); + + pushYamlList(lines, 'tools', agent.tools); + pushYamlList(lines, 'disallowedTools', agent.disallowedTools); + + if (agent.model && agent.model !== 'inherit') { + lines.push(`model: ${agent.model}`); + } + + if (agent.permissionMode) { + lines.push(`permissionMode: ${agent.permissionMode}`); + } + + pushYamlList(lines, 'skills', agent.skills); + + if (agent.hooks !== undefined) { + lines.push(`hooks: ${JSON.stringify(agent.hooks)}`); + } + + if (agent.extraFrontmatter) { + for (const [key, value] of Object.entries(agent.extraFrontmatter)) { + lines.push(`${key}: ${JSON.stringify(value)}`); + } + } + + lines.push('---'); + lines.push(agent.prompt); + + return lines.join('\n'); +} diff --git a/src/utils/animationFrame.ts b/src/utils/animationFrame.ts new file mode 100644 index 0000000..aaeb68f --- /dev/null +++ b/src/utils/animationFrame.ts @@ -0,0 +1,46 @@ +export interface ScheduledAnimationFrame { + kind: 'raf' | 'timeout'; + id: number; + ownerWindow: Window | null; +} + +function getRendererWindow(): Window | null { + return typeof window === 'undefined' ? null : window; +} + +export function scheduleAnimationFrame( + callback: () => void, + ownerWindow: Window | null = getRendererWindow(), +): ScheduledAnimationFrame { + const targetWindow = ownerWindow ?? getRendererWindow(); + if (!targetWindow) { + callback(); + return { kind: 'timeout', id: 0, ownerWindow: null }; + } + + if (typeof targetWindow.requestAnimationFrame === 'function') { + return { + kind: 'raf', + id: targetWindow.requestAnimationFrame(() => callback()), + ownerWindow: targetWindow, + }; + } + + return { + kind: 'timeout', + id: targetWindow.setTimeout(callback, 16), + ownerWindow: targetWindow, + }; +} + +export function cancelScheduledAnimationFrame(frame: ScheduledAnimationFrame): void { + const targetWindow = frame.ownerWindow ?? getRendererWindow(); + if (!targetWindow) return; + + if (frame.kind === 'raf' && typeof targetWindow.cancelAnimationFrame === 'function') { + targetWindow.cancelAnimationFrame(frame.id); + return; + } + + targetWindow.clearTimeout(frame.id); +} diff --git a/src/utils/browser.ts b/src/utils/browser.ts new file mode 100644 index 0000000..0ea8758 --- /dev/null +++ b/src/utils/browser.ts @@ -0,0 +1,46 @@ +export interface BrowserSelectionContext { + source: string; + selectedText: string; + title?: string; + url?: string; +} + +function escapeXmlAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); +} + +function buildAttributeList(context: BrowserSelectionContext): string { + const attrs: string[] = []; + const source = context.source.trim() || 'unknown'; + attrs.push(`source="${escapeXmlAttribute(source)}"`); + + if (context.title?.trim()) { + attrs.push(`title="${escapeXmlAttribute(context.title.trim())}"`); + } + + if (context.url?.trim()) { + attrs.push(`url="${escapeXmlAttribute(context.url.trim())}"`); + } + + return attrs.join(' '); +} + +function escapeXmlBody(text: string): string { + return text.replace(/<\/browser_selection>/gi, '</browser_selection>'); +} + +export function formatBrowserContext(context: BrowserSelectionContext): string { + const selectedText = context.selectedText.trim(); + if (!selectedText) return ''; + const attrs = buildAttributeList(context); + return `\n${escapeXmlBody(selectedText)}\n`; +} + +export function appendBrowserContext(prompt: string, context: BrowserSelectionContext): string { + const formatted = formatBrowserContext(context); + return formatted ? `${prompt}\n\n${formatted}` : prompt; +} diff --git a/src/utils/canvas.ts b/src/utils/canvas.ts new file mode 100644 index 0000000..1a2d269 --- /dev/null +++ b/src/utils/canvas.ts @@ -0,0 +1,14 @@ +export interface CanvasSelectionContext { + canvasPath: string; + nodeIds: string[]; +} + +export function formatCanvasContext(context: CanvasSelectionContext): string { + if (context.nodeIds.length === 0) return ''; + return `\n${context.nodeIds.join(', ')}\n`; +} + +export function appendCanvasContext(prompt: string, context: CanvasSelectionContext): string { + const formatted = formatCanvasContext(context); + return formatted ? `${prompt}\n\n${formatted}` : prompt; +} diff --git a/src/utils/cliBinaryLocator.ts b/src/utils/cliBinaryLocator.ts new file mode 100644 index 0000000..97b1f9b --- /dev/null +++ b/src/utils/cliBinaryLocator.ts @@ -0,0 +1,97 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import { getEnhancedPath } from './env'; +import { expandHomePath, parsePathEntries } from './path'; + +export function isExistingFile(filePath: string): boolean { + try { + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +export function resolveConfiguredCliPath(configuredPath: string | undefined): string | null { + const trimmed = (configuredPath ?? '').trim(); + if (!trimmed) { + return null; + } + + try { + const expandedPath = expandHomePath(trimmed); + return isExistingFile(expandedPath) ? expandedPath : null; + } catch { + return null; + } +} + +export function findCliBinaryPath( + binaryName: string, + additionalPath?: string, + platform: NodeJS.Platform = process.platform, +): string | null { + const binaryNames = platform === 'win32' + ? [`${binaryName}.exe`, `${binaryName}.cmd`, binaryName] + : [binaryName]; + const searchEntries = platform === process.platform + ? parsePathEntries(getEnhancedPath(additionalPath)) + : parsePathEntriesForPlatform(additionalPath, platform); + + for (const dir of searchEntries) { + if (!dir) continue; + + for (const candidateName of binaryNames) { + const candidate = path.join(dir, candidateName); + if (isExistingFile(candidate)) { + return candidate; + } + } + } + + return null; +} + +function parsePathEntriesForPlatform(pathValue: string | undefined, platform: NodeJS.Platform): string[] { + if (!pathValue) { + return []; + } + + const delimiter = platform === 'win32' ? ';' : ':'; + return pathValue + .split(delimiter) + .map(segment => stripSurroundingQuotes(segment.trim())) + .filter(segment => { + if (!segment) return false; + const upper = segment.toUpperCase(); + return upper !== '$PATH' && upper !== '${PATH}' && upper !== '%PATH%'; + }) + .map(segment => translateMsysPathForPlatform(expandHomePath(segment), platform)); +} + +function stripSurroundingQuotes(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; +} + +function translateMsysPathForPlatform(value: string, platform: NodeJS.Platform): string { + if (platform !== 'win32') { + return value; + } + + const msysMatch = value.match(/^\/([a-zA-Z])(?:\/(.*))?$/); + if (!msysMatch) { + return value; + } + + const driveLetter = msysMatch[1].toUpperCase(); + const restOfPath = msysMatch[2] ?? ''; + return restOfPath + ? `${driveLetter}:\\${restOfPath.replace(/\//g, '\\')}` + : `${driveLetter}:`; +} diff --git a/src/utils/context.ts b/src/utils/context.ts new file mode 100644 index 0000000..a60aae2 --- /dev/null +++ b/src/utils/context.ts @@ -0,0 +1,118 @@ +/** + * Claudian - Context Utilities + * + * Note and context file formatting for prompts. + */ + +const LINKED_NOTE_TAG = 'linked_note'; +const NOTE_CONTEXT_TAG_PATTERN = '(linked_note|current_note)'; + +// Matches note context at the START of prompt (legacy placement) +const NOTE_CONTEXT_PREFIX_REGEX = new RegExp(`^<${NOTE_CONTEXT_TAG_PATTERN}>\\n[\\s\\S]*?<\\/\\1>\\n\\n`); +// Matches note context at the END of prompt (current placement) +const NOTE_CONTEXT_SUFFIX_REGEX = new RegExp(`\\n\\n<${NOTE_CONTEXT_TAG_PATTERN}>\\n[\\s\\S]*?<\\/\\1>$`); + +/** + * Pattern to match XML context tags appended to prompts. + * These tags are always preceded by \n\n separator. + * Matches: linked_note/current_note, editor_selection (with attributes), editor_cursor (with attributes), + * context_files, canvas_selection, browser_selection + */ +export const XML_CONTEXT_PATTERN = /\n\n<(?:linked_note|current_note|editor_selection|editor_cursor|context_files|canvas_selection|browser_selection)[\s>]/; +const BRACKET_CONTEXT_PATTERN = /\n\[(?:Current note|Editor selection from|Browser selection from|Canvas selection from)\b/; + +export function formatCurrentNote(notePath: string): string { + return `<${LINKED_NOTE_TAG}>\n${notePath}\n`; +} + +export function appendCurrentNote(prompt: string, notePath: string): string { + return `${prompt}\n\n${formatCurrentNote(notePath)}`; +} + +/** + * Strips note context from a prompt. + * Handles legacy tags and canonical tags. + */ +export function stripCurrentNoteContext(prompt: string): string { + const strippedPrefix = prompt.replace(NOTE_CONTEXT_PREFIX_REGEX, ''); + if (strippedPrefix !== prompt) { + return strippedPrefix; + } + return prompt.replace(NOTE_CONTEXT_SUFFIX_REGEX, ''); +} + +/** + * Extracts user content that appears before XML context tags. + * Handles two formats: + * 1. Legacy: content inside tags + * 2. Current: user content first, context XML appended after + */ +export function extractContentBeforeXmlContext(text: string): string | undefined { + if (!text) return undefined; + + // Legacy format: content inside tags + const queryMatch = text.match(/\n?([\s\S]*?)\n?<\/query>/); + if (queryMatch) { + return queryMatch[1].trim(); + } + + // Current format: user content before any XML context tags + // Context tags are always appended with \n\n separator + const xmlMatch = text.match(XML_CONTEXT_PATTERN); + if (xmlMatch?.index !== undefined) { + return text.substring(0, xmlMatch.index).trim(); + } + + return undefined; +} + +export function extractUserDisplayContent(text: string): string | undefined { + if (!text) return undefined; + + const xmlDisplayContent = extractContentBeforeXmlContext(text); + if (xmlDisplayContent !== undefined) { + return xmlDisplayContent; + } + + const bracketMatch = text.match(BRACKET_CONTEXT_PATTERN); + if (bracketMatch?.index !== undefined) { + return text.substring(0, bracketMatch.index).trim(); + } + + return undefined; +} + +/** + * Extracts the actual user query from an XML-wrapped prompt. + * Used for comparing prompts during history deduplication. + * + * Always returns a string - falls back to stripping all XML tags if no + * structured context is found. + */ +export function extractUserQuery(prompt: string): string { + if (!prompt) return ''; + + // Try to extract content before XML context + const extracted = extractContentBeforeXmlContext(prompt); + if (extracted !== undefined) { + return extracted; + } + + // No XML context - return the whole prompt stripped of any remaining tags + return prompt + .replace(/<(linked_note|current_note)>[\s\S]*?<\/\1>\s*/g, '') + .replace(/\s*/g, '') + .replace(/\s*/g, '') + .replace(/[\s\S]*?<\/context_files>\s*/g, '') + .replace(/\s*/g, '') + .replace(/\s*/g, '') + .trim(); +} + +function formatContextFilesLine(files: string[]): string { + return `\n${files.join(', ')}\n`; +} + +export function appendContextFiles(prompt: string, files: string[]): string { + return `${prompt}\n\n${formatContextFilesLine(files)}`; +} diff --git a/src/utils/contextMentionResolver.ts b/src/utils/contextMentionResolver.ts new file mode 100644 index 0000000..9939d7b --- /dev/null +++ b/src/utils/contextMentionResolver.ts @@ -0,0 +1,154 @@ +import type { ExternalContextDisplayEntry } from './externalContext'; +import type { ExternalContextFile } from './externalContextScanner'; + +export interface MentionLookupMatch { + resolvedPath: string; + endIndex: number; + trailingPunctuation: string; +} + +const TRAILING_PUNCTUATION_REGEX = /[),.!?:;]+$/; +const BOUNDARY_PUNCTUATION = new Set([',', ')', '!', '?', ':', ';']); + +function isWhitespace(char: string): boolean { + return /\s/.test(char); +} + +function collectMentionEndCandidates(text: string, pathStart: number): number[] { + const candidates = new Set(); + + for (let index = pathStart; index < text.length; index++) { + const char = text[index]; + if (isWhitespace(char)) { + candidates.add(index); + continue; + } + + if (BOUNDARY_PUNCTUATION.has(char)) { + candidates.add(index + 1); + } + } + + candidates.add(text.length); + return Array.from(candidates).sort((a, b) => b - a); +} + +export function isMentionStart(text: string, index: number): boolean { + if (text[index] !== '@') return false; + if (index === 0) return true; + return isWhitespace(text[index - 1]); +} + +export function normalizeMentionPath(pathText: string): string { + return pathText + .replace(/\\/g, '/') + .replace(/^\.?\//, '') + .replace(/\/+/g, '/') + .replace(/\/+$/, ''); +} + +export function normalizeForPlatformLookup(value: string): string { + return process.platform === 'win32' ? value.toLowerCase() : value; +} + +export function buildExternalContextLookup( + files: ExternalContextFile[] +): Map { + const lookup = new Map(); + for (const file of files) { + const normalized = normalizeMentionPath(file.relativePath); + if (!normalized) continue; + const key = normalizeForPlatformLookup(normalized); + if (!lookup.has(key)) { + lookup.set(key, file.path); + } + } + return lookup; +} + +export function resolveExternalMentionAtIndex( + text: string, + mentionStart: number, + contextEntries: ExternalContextDisplayEntry[], + getContextLookup: (contextRoot: string) => Map +): MentionLookupMatch | null { + const mentionBodyStart = mentionStart + 1; + let bestMatch: MentionLookupMatch | null = null; + + for (const entry of contextEntries) { + const displayNameEnd = mentionBodyStart + entry.displayName.length; + if (displayNameEnd >= text.length) continue; + + const mentionDisplayName = text.slice(mentionBodyStart, displayNameEnd).toLowerCase(); + if (mentionDisplayName !== entry.displayNameLower) continue; + + const separator = text[displayNameEnd]; + if (separator !== '/' && separator !== '\\') continue; + + const lookup = getContextLookup(entry.contextRoot); + const match = findBestMentionLookupMatch( + text, + displayNameEnd + 1, + lookup, + normalizeMentionPath, + normalizeForPlatformLookup + ); + if (!match) continue; + + if (!bestMatch || match.endIndex > bestMatch.endIndex) { + bestMatch = match; + } + } + + return bestMatch; +} + +export function findBestMentionLookupMatch( + text: string, + pathStart: number, + pathLookup: Map, + normalizePath: (pathText: string) => string, + normalizeLookupKey: (value: string) => string +): MentionLookupMatch | null { + if (pathLookup.size === 0 || pathStart >= text.length) return null; + + const endCandidates = collectMentionEndCandidates(text, pathStart); + for (const endIndex of endCandidates) { + if (endIndex <= pathStart) continue; + + const rawPath = text.slice(pathStart, endIndex); + const trailingPunctuation = rawPath.match(TRAILING_PUNCTUATION_REGEX)?.[0] ?? ''; + const rawPathWithoutPunctuation = trailingPunctuation + ? rawPath.slice(0, -trailingPunctuation.length) + : rawPath; + + const normalizedPath = normalizePath(rawPathWithoutPunctuation); + if (!normalizedPath) continue; + + const resolvedPath = pathLookup.get(normalizeLookupKey(normalizedPath)); + if (resolvedPath) { + return { + resolvedPath, + endIndex, + trailingPunctuation, + }; + } + } + + return null; +} + +export function createExternalContextLookupGetter( + getContextFiles: (contextRoot: string) => ExternalContextFile[] +): (contextRoot: string) => Map { + const lookupCache = new Map>(); + + return (contextRoot: string): Map => { + const cached = lookupCache.get(contextRoot); + if (cached) return cached; + + const lookup = buildExternalContextLookup(getContextFiles(contextRoot)); + lookupCache.set(contextRoot, lookup); + return lookup; + }; +} diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..91d22a3 --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,31 @@ +/** + * Claudian - Date Utilities + * + * Date formatting helpers for system prompts. + */ + +/** Returns today's date in readable and ISO format for the system prompt. */ +export function getTodayDate(): string { + const now = new Date(); + const readable = now.toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + }); + const iso = now.toISOString().split('T')[0]; + return `${readable} (${iso})`; +} + +/** Formats a duration in seconds as "1m 23s" or "45s". */ +export function formatDurationMmSs(seconds: number): string { + if (!Number.isFinite(seconds) || seconds < 0) { + return '0s'; + } + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + if (mins === 0) { + return `${secs}s`; + } + return `${mins}m ${secs}s`; +} diff --git a/src/utils/diff.ts b/src/utils/diff.ts new file mode 100644 index 0000000..bf8f6fa --- /dev/null +++ b/src/utils/diff.ts @@ -0,0 +1,384 @@ +import type { DiffLine, DiffStats, StructuredPatchHunk } from '../core/types/diff'; +import type { ToolCallInfo, ToolDiffData } from '../core/types/tools'; + +export interface ApplyPatchFileDiff extends ToolDiffData { + operation: 'add' | 'update' | 'delete'; + movedTo?: string; +} + +export function structuredPatchToDiffLines(hunks: StructuredPatchHunk[]): DiffLine[] { + const result: DiffLine[] = []; + + for (const hunk of hunks) { + let oldLineNum = hunk.oldStart; + let newLineNum = hunk.newStart; + + for (const line of hunk.lines) { + const prefix = line[0]; + const text = line.slice(1); + + if (prefix === '+') { + result.push({ type: 'insert', text, newLineNum: newLineNum++ }); + } else if (prefix === '-') { + result.push({ type: 'delete', text, oldLineNum: oldLineNum++ }); + } else { + result.push({ type: 'equal', text, oldLineNum: oldLineNum++, newLineNum: newLineNum++ }); + } + } + } + + return result; +} + +export function countLineChanges(diffLines: DiffLine[]): DiffStats { + let added = 0; + let removed = 0; + + for (const line of diffLines) { + if (line.type === 'insert') added++; + else if (line.type === 'delete') removed++; + } + + return { added, removed }; +} + +export function parseApplyPatchDiffs(patchText: string): ApplyPatchFileDiff[] { + if (!patchText.trim()) return []; + + const fileDiffs: ApplyPatchFileDiff[] = []; + const lines = patchText.split(/\r?\n/); + let current: + | { + filePath: string; + operation: ApplyPatchFileDiff['operation']; + movedTo?: string; + rawLines: string[]; + } + | null = null; + + const flushCurrent = () => { + if (!current) return; + fileDiffs.push(buildApplyPatchFileDiff(current)); + current = null; + }; + + for (const line of lines) { + if (line.startsWith('*** Begin Patch') || line.startsWith('*** End Patch')) { + continue; + } + + if (line.startsWith('*** Add File: ')) { + flushCurrent(); + current = { + filePath: line.slice('*** Add File: '.length).trim(), + operation: 'add', + rawLines: [], + }; + continue; + } + + if (line.startsWith('*** Update File: ')) { + flushCurrent(); + current = { + filePath: line.slice('*** Update File: '.length).trim(), + operation: 'update', + rawLines: [], + }; + continue; + } + + if (line.startsWith('*** Delete File: ')) { + flushCurrent(); + fileDiffs.push({ + filePath: line.slice('*** Delete File: '.length).trim(), + operation: 'delete', + diffLines: [], + stats: { added: 0, removed: 0 }, + }); + continue; + } + + if (!current) continue; + + if (line.startsWith('*** Move to: ')) { + current.movedTo = line.slice('*** Move to: '.length).trim(); + continue; + } + + if (line === '*** End of File' || line.startsWith('@@') || line.startsWith('--- ') || line.startsWith('+++ ')) { + continue; + } + + const prefix = line[0]; + if (prefix === '+' || prefix === '-' || prefix === ' ') { + current.rawLines.push(line); + } + } + + flushCurrent(); + return fileDiffs; +} + +export function parseFileUpdateChangeDiffs(changes: unknown): ApplyPatchFileDiff[] { + if (!Array.isArray(changes)) return []; + + return changes + .map(parseFileUpdateChangeDiff) + .filter((diff): diff is ApplyPatchFileDiff => diff !== null); +} + +export function extractDiffData(toolUseResult: unknown, toolCall: ToolCallInfo): ToolDiffData | undefined { + const filePath = getNonEmptyStringValue(toolCall.input.file_path) + ?? getNonEmptyStringValue(toolCall.input.path) + ?? 'file'; + + if (toolUseResult && typeof toolUseResult === 'object') { + const result = toolUseResult as Record; + if (Array.isArray(result.structuredPatch) && result.structuredPatch.length > 0) { + const resultFilePath = (typeof result.filePath === 'string' ? result.filePath : null) || filePath; + const hunks = result.structuredPatch as StructuredPatchHunk[]; + const diffLines = structuredPatchToDiffLines(hunks); + const stats = countLineChanges(diffLines); + return { filePath: resultFilePath, diffLines, stats }; + } + + const unifiedDiff = getUnifiedDiffText(result); + if (unifiedDiff) { + const diffLines = parseUnifiedDiffLines(unifiedDiff); + if (diffLines.length > 0) { + const resultFilePath = (typeof result.filePath === 'string' ? result.filePath : null) + || (typeof result.path === 'string' ? result.path : null) + || filePath; + return { filePath: resultFilePath, diffLines, stats: countLineChanges(diffLines) }; + } + } + } + + return diffFromToolInput(toolCall, filePath); +} + +export function diffFromToolInput(toolCall: ToolCallInfo, filePath: string): ToolDiffData | undefined { + if (toolCall.name === 'Edit') { + const oldStr = toolCall.input.old_string; + const newStr = toolCall.input.new_string; + if (typeof oldStr === 'string' && typeof newStr === 'string') { + const diffLines = buildReplacementDiffLines([{ oldText: oldStr, newText: newStr }]); + return { filePath, diffLines, stats: countLineChanges(diffLines) }; + } + + const editPairs = getEditPairs(toolCall.input); + if (editPairs.length > 0) { + const diffLines = buildReplacementDiffLines(editPairs); + return { filePath, diffLines, stats: countLineChanges(diffLines) }; + } + } + + if (toolCall.name === 'Write') { + const content = toolCall.input.content; + if (typeof content === 'string') { + const newLines = content.split('\n'); + const diffLines: DiffLine[] = newLines.map((text, i) => ({ + type: 'insert', + text, + newLineNum: i + 1, + })); + return { filePath, diffLines, stats: { added: newLines.length, removed: 0 } }; + } + } + + return undefined; +} + +function getUnifiedDiffText(result: Record): string | null { + if (typeof result.diff === 'string' && result.diff.trim()) { + return result.diff; + } + + const details = result.details; + if (details && typeof details === 'object' && !Array.isArray(details)) { + const diff = (details as Record).diff; + if (typeof diff === 'string' && diff.trim()) { + return diff; + } + } + + return null; +} + +interface ReplacementPair { + oldText: string; + newText: string; +} + +function getEditPairs(input: Record): ReplacementPair[] { + const topLevelPair = getReplacementPair(input); + if (topLevelPair) { + return [topLevelPair]; + } + + const edits = input.edits; + if (!Array.isArray(edits)) { + return []; + } + + return edits + .map(getReplacementPair) + .filter((pair): pair is ReplacementPair => pair !== null); +} + +function getReplacementPair(value: unknown): ReplacementPair | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + + const record = value as Record; + const oldText = getStringValue(record.oldText ?? record.old_text ?? record.old_string); + const newText = getStringValue(record.newText ?? record.new_text ?? record.new_string); + return oldText !== null && newText !== null ? { oldText, newText } : null; +} + +function getStringValue(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function getNonEmptyStringValue(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value : null; +} + +function buildReplacementDiffLines(pairs: ReplacementPair[]): DiffLine[] { + const diffLines: DiffLine[] = []; + let oldLineNum = 1; + let newLineNum = 1; + + for (const pair of pairs) { + for (const line of pair.oldText.split('\n')) { + diffLines.push({ type: 'delete', text: line, oldLineNum: oldLineNum++ }); + } + for (const line of pair.newText.split('\n')) { + diffLines.push({ type: 'insert', text: line, newLineNum: newLineNum++ }); + } + } + + return diffLines; +} + +function buildApplyPatchFileDiff(current: { + filePath: string; + operation: ApplyPatchFileDiff['operation']; + movedTo?: string; + rawLines: string[]; +}): ApplyPatchFileDiff { + const diffLines: DiffLine[] = []; + let oldLineNum = 1; + let newLineNum = 1; + + for (const line of current.rawLines) { + const prefix = line[0]; + const text = line.slice(1); + + if (prefix === '+') { + diffLines.push({ type: 'insert', text, newLineNum: newLineNum++ }); + continue; + } + + if (prefix === '-') { + diffLines.push({ type: 'delete', text, oldLineNum: oldLineNum++ }); + continue; + } + + diffLines.push({ type: 'equal', text, oldLineNum: oldLineNum++, newLineNum: newLineNum++ }); + } + + const result: ApplyPatchFileDiff = { + filePath: current.filePath, + operation: current.operation, + diffLines, + stats: countLineChanges(diffLines), + }; + if (current.movedTo) result.movedTo = current.movedTo; + return result; +} + +function parseFileUpdateChangeDiff(change: unknown): ApplyPatchFileDiff | null { + if (!change || typeof change !== 'object' || Array.isArray(change)) { + return null; + } + + const record = change as Record; + const filePath = typeof record.path === 'string' ? record.path : ''; + const diff = typeof record.diff === 'string' ? record.diff : ''; + if (!filePath || !diff.trim()) { + return null; + } + + const kindInfo = parseFileUpdateKind(record.kind ?? record.type); + const diffLines = parseUnifiedDiffLines(diff); + return { + filePath, + operation: kindInfo.operation, + ...(kindInfo.movedTo ? { movedTo: kindInfo.movedTo } : {}), + diffLines, + stats: countLineChanges(diffLines), + }; +} + +function parseFileUpdateKind(value: unknown): { + operation: ApplyPatchFileDiff['operation']; + movedTo?: string; +} { + if (typeof value === 'string') { + return { operation: normalizePatchOperation(value) }; + } + + if (value && typeof value === 'object' && !Array.isArray(value)) { + const record = value as Record; + const type = typeof record.type === 'string' ? record.type : ''; + const movedTo = typeof record.move_path === 'string' ? record.move_path : undefined; + return { + operation: normalizePatchOperation(type), + ...(movedTo ? { movedTo } : {}), + }; + } + + return { operation: 'update' }; +} + +function normalizePatchOperation(value: string): ApplyPatchFileDiff['operation'] { + if (value === 'add' || value === 'delete' || value === 'update') { + return value; + } + + return 'update'; +} + +function parseUnifiedDiffLines(diffText: string): DiffLine[] { + const diffLines: DiffLine[] = []; + let oldLineNum = 1; + let newLineNum = 1; + + for (const line of diffText.split(/\r?\n/)) { + if (!line) continue; + if (line.startsWith('--- ') || line.startsWith('+++ ')) continue; + + if (line.startsWith('@@')) { + const match = line.match(/^@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/); + if (match) { + oldLineNum = Number(match[1]); + newLineNum = Number(match[2]); + } + continue; + } + + const prefix = line[0]; + const text = line.slice(1); + if (prefix === '+') { + diffLines.push({ type: 'insert', text, newLineNum: newLineNum++ }); + } else if (prefix === '-') { + diffLines.push({ type: 'delete', text, oldLineNum: oldLineNum++ }); + } else if (prefix === ' ') { + diffLines.push({ type: 'equal', text, oldLineNum: oldLineNum++, newLineNum: newLineNum++ }); + } + } + + return diffLines; +} diff --git a/src/utils/editor.ts b/src/utils/editor.ts new file mode 100644 index 0000000..fcd0d81 --- /dev/null +++ b/src/utils/editor.ts @@ -0,0 +1,104 @@ +/** + * Claudian - Editor Context Utilities + * + * Editor cursor and selection context for inline editing. + */ + +import type { EditorView } from '@codemirror/view'; +import type { Editor } from 'obsidian'; + +/** + * Gets the CodeMirror EditorView from an Obsidian Editor. + * Obsidian's Editor type doesn't expose the internal `.cm` property. + */ +export function getEditorView(editor: Editor): EditorView | undefined { + return (editor as unknown as { cm?: EditorView }).cm; +} + +export interface CursorContext { + beforeCursor: string; + afterCursor: string; + isInbetween: boolean; + line: number; + column: number; +} + +export interface EditorSelectionContext { + notePath: string; + mode: 'selection' | 'cursor' | 'none'; + selectedText?: string; + cursorContext?: CursorContext; + lineCount?: number; // Number of lines in selection (for UI indicator) + startLine?: number; // 1-indexed starting line number +} + +export function findNearestNonEmptyLine( + getLine: (line: number) => string, + lineCount: number, + startLine: number, + direction: 'before' | 'after' +): string { + const step = direction === 'before' ? -1 : 1; + for (let i = startLine + step; i >= 0 && i < lineCount; i += step) { + const content = getLine(i); + if (content.trim().length > 0) { + return content; + } + } + return ''; +} + +/** All line/column params are 0-indexed. */ +export function buildCursorContext( + getLine: (line: number) => string, + lineCount: number, + line: number, + column: number +): CursorContext { + const lineContent = getLine(line); + const beforeCursor = lineContent.substring(0, column); + const afterCursor = lineContent.substring(column); + + const lineIsEmpty = lineContent.trim().length === 0; + const nothingBefore = beforeCursor.trim().length === 0; + const nothingAfter = afterCursor.trim().length === 0; + const isInbetween = lineIsEmpty || (nothingBefore && nothingAfter); + + let contextBefore = beforeCursor; + let contextAfter = afterCursor; + + if (isInbetween) { + contextBefore = findNearestNonEmptyLine(getLine, lineCount, line, 'before'); + contextAfter = findNearestNonEmptyLine(getLine, lineCount, line, 'after'); + } + + return { beforeCursor: contextBefore, afterCursor: contextAfter, isInbetween, line, column }; +} + +export function formatEditorContext(context: EditorSelectionContext): string { + if (context.mode === 'selection' && context.selectedText) { + const lineAttr = context.startLine && context.lineCount + ? ` lines="${context.startLine}-${context.startLine + context.lineCount - 1}"` + : ''; + return `\n${context.selectedText}\n`; + } else if (context.mode === 'cursor' && context.cursorContext) { + const ctx = context.cursorContext; + let content: string; + if (ctx.isInbetween) { + const parts = []; + if (ctx.beforeCursor) parts.push(ctx.beforeCursor); + parts.push('| #inbetween'); + if (ctx.afterCursor) parts.push(ctx.afterCursor); + content = parts.join('\n'); + } else { + content = `${ctx.beforeCursor}|${ctx.afterCursor} #inline`; + } + return `\n${content}\n`; + } + return ''; +} + +export function appendEditorContext(prompt: string, context: EditorSelectionContext): string { + const formatted = formatEditorContext(context); + return formatted ? `${prompt}\n\n${formatted}` : prompt; +} diff --git a/src/utils/electronCompat.ts b/src/utils/electronCompat.ts new file mode 100644 index 0000000..ec9132a --- /dev/null +++ b/src/utils/electronCompat.ts @@ -0,0 +1,53 @@ +function isAbortSignalLike(target: unknown): boolean { + if (!target || typeof target !== 'object') return false; + const t = target as Record; + + return typeof t.aborted === 'boolean' && + typeof t.addEventListener === 'function' && + typeof t.removeEventListener === 'function'; +} + +type PatchableSetMaxListeners = ((...args: unknown[]) => unknown) & { + __electronPatched?: boolean; +}; + +type EventsModule = { + setMaxListeners: PatchableSetMaxListeners; +}; + +/** + * In Obsidian's Electron renderer, `new AbortController()` creates a browser-realm + * AbortSignal that lacks Node.js's internal `kIsEventTarget` symbol. The SDK calls + * `events.setMaxListeners(n, signal)` which throws because Node.js doesn't recognize + * the browser AbortSignal as a valid EventTarget. + * + * Since setMaxListeners on AbortSignal only suppresses MaxListenersExceededWarning, + * silently catching the error is safe. + * + * See: #143, #239, #284, #339, #342, #370, #374, #387 + */ +export function patchSetMaxListenersForElectron(): void { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- Patch the shared CommonJS events module before SDK imports run. + const events = require('events') as EventsModule; + + if (events.setMaxListeners.__electronPatched) return; + + const original = events.setMaxListeners; + + const patched: PatchableSetMaxListeners = function patchedSetMaxListeners(this: unknown, ...args: unknown[]): unknown { + try { + return Reflect.apply(original, this, args); + } catch (error) { + // Only swallow the Electron cross-realm AbortSignal error. + // Duck-type check avoids depending on Node.js internal error message text. + const eventTargets = args.slice(1); + if (eventTargets.length > 0 && eventTargets.every(isAbortSignalLike)) { + return; + } + throw error; + } + }; + patched.__electronPatched = true; + + events.setMaxListeners = patched; +} diff --git a/src/utils/env.ts b/src/utils/env.ts new file mode 100644 index 0000000..ce35c45 --- /dev/null +++ b/src/utils/env.ts @@ -0,0 +1,465 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { parsePathEntries, resolveNvmDefaultBin } from './path'; + +const isWindows = process.platform === 'win32'; +const PATH_SEPARATOR = isWindows ? ';' : ':'; +const NODE_EXECUTABLE = isWindows ? 'node.exe' : 'node'; +const DEVICE_SETTINGS_STORAGE_KEY = 'claudian.deviceSettingsKey'; +let cachedDeviceSettingsKey: string | null = null; + +function getHomeDir(): string { + return process.env.HOME || process.env.USERPROFILE || ''; +} + +// Linux excluded: Obsidian registers the CLI through stable symlinks (/usr/local/bin, +// ~/.local/bin), while process.execPath may point to a transient AppImage mount. +function getAppProvidedCliPaths(): string[] { + if (process.platform === 'darwin') { + const appBundleMatch = process.execPath.match(/^(.+?\.app)\//); + if (appBundleMatch) { + return [path.join(appBundleMatch[1], 'Contents', 'MacOS')]; + } + return [path.dirname(process.execPath)]; + } + + if (process.platform === 'win32') { + return [path.dirname(process.execPath)]; + } + + return []; +} + +/** GUI apps like Obsidian have minimal PATH, so we add common binary locations. */ +function getExtraBinaryPaths(): string[] { + const home = getHomeDir(); + + if (isWindows) { + const paths: string[] = []; + const localAppData = process.env.LOCALAPPDATA; + const appData = process.env.APPDATA; + const programFiles = process.env.ProgramFiles || 'C:\\Program Files'; + const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + const programData = process.env.ProgramData || 'C:\\ProgramData'; + + // Node.js / npm locations + if (appData) { + paths.push(path.join(appData, 'npm')); + } + if (localAppData) { + paths.push(path.join(localAppData, 'Programs', 'nodejs')); + paths.push(path.join(localAppData, 'Programs', 'node')); + } + + // Common program locations (official Node.js installer) + paths.push(path.join(programFiles, 'nodejs')); + paths.push(path.join(programFilesX86, 'nodejs')); + + // nvm-windows: active Node.js is usually under %NVM_SYMLINK% + const nvmSymlink = process.env.NVM_SYMLINK; + if (nvmSymlink) { + paths.push(nvmSymlink); + } + + // nvm-windows: stores Node.js versions in %NVM_HOME% or %APPDATA%\nvm + const nvmHome = process.env.NVM_HOME; + if (nvmHome) { + paths.push(nvmHome); + } else if (appData) { + paths.push(path.join(appData, 'nvm')); + } + + // volta: installs to %VOLTA_HOME%\bin or %USERPROFILE%\.volta\bin + const voltaHome = process.env.VOLTA_HOME; + if (voltaHome) { + paths.push(path.join(voltaHome, 'bin')); + } else if (home) { + paths.push(path.join(home, '.volta', 'bin')); + } + + // fnm (Fast Node Manager): %FNM_MULTISHELL_PATH% is the active Node.js bin + const fnmMultishell = process.env.FNM_MULTISHELL_PATH; + if (fnmMultishell) { + paths.push(fnmMultishell); + } + + // fnm (Fast Node Manager): %FNM_DIR% or %LOCALAPPDATA%\fnm + const fnmDir = process.env.FNM_DIR; + if (fnmDir) { + paths.push(fnmDir); + } else if (localAppData) { + paths.push(path.join(localAppData, 'fnm')); + } + + // Chocolatey: %ChocolateyInstall%\bin or C:\ProgramData\chocolatey\bin + const chocolateyInstall = process.env.ChocolateyInstall; + if (chocolateyInstall) { + paths.push(path.join(chocolateyInstall, 'bin')); + } else { + paths.push(path.join(programData, 'chocolatey', 'bin')); + } + + // scoop: %SCOOP%\shims or %USERPROFILE%\scoop\shims + const scoopDir = process.env.SCOOP; + if (scoopDir) { + paths.push(path.join(scoopDir, 'shims')); + paths.push(path.join(scoopDir, 'apps', 'nodejs', 'current', 'bin')); + paths.push(path.join(scoopDir, 'apps', 'nodejs', 'current')); + } else if (home) { + paths.push(path.join(home, 'scoop', 'shims')); + paths.push(path.join(home, 'scoop', 'apps', 'nodejs', 'current', 'bin')); + paths.push(path.join(home, 'scoop', 'apps', 'nodejs', 'current')); + } + + // Docker + paths.push(path.join(programFiles, 'Docker', 'Docker', 'resources', 'bin')); + + // User bin (if exists) + if (home) { + paths.push(path.join(home, '.local', 'bin')); + paths.push(path.join(home, '.bun', 'bin')); + paths.push(path.join(home, '.opencode', 'bin')); + } + + paths.push(...getAppProvidedCliPaths()); + + return paths; + } else { + // Unix paths + const paths = [ + '/usr/local/bin', + '/opt/homebrew/bin', // macOS ARM Homebrew + '/usr/bin', + '/bin', + ]; + + const voltaHome = process.env.VOLTA_HOME; + if (voltaHome) { + paths.push(path.join(voltaHome, 'bin')); + } + + const asdfRoot = process.env.ASDF_DATA_DIR || process.env.ASDF_DIR; + if (asdfRoot) { + paths.push(path.join(asdfRoot, 'shims')); + paths.push(path.join(asdfRoot, 'bin')); + } + + const fnmMultishell = process.env.FNM_MULTISHELL_PATH; + if (fnmMultishell) { + paths.push(fnmMultishell); + } + + const fnmDir = process.env.FNM_DIR; + if (fnmDir) { + paths.push(fnmDir); + } + + if (home) { + paths.push(path.join(home, '.local', 'bin')); + paths.push(path.join(home, '.bun', 'bin')); + paths.push(path.join(home, '.opencode', 'bin')); + paths.push(path.join(home, '.docker', 'bin')); + paths.push(path.join(home, '.volta', 'bin')); + paths.push(path.join(home, '.asdf', 'shims')); + paths.push(path.join(home, '.asdf', 'bin')); + paths.push(path.join(home, '.fnm')); + + // NVM: use NVM_BIN if set, otherwise resolve default version from filesystem + const nvmBin = process.env.NVM_BIN; + if (nvmBin) { + paths.push(nvmBin); + } else { + const nvmDefault = resolveNvmDefaultBin(home); + if (nvmDefault) { + paths.push(nvmDefault); + } + } + } + + paths.push(...getAppProvidedCliPaths()); + + return paths; + } +} + +export function findNodeDirectory(additionalPaths?: string): string | null { + const searchPaths = getExtraBinaryPaths(); + + const currentPath = process.env.PATH || ''; + const pathDirs = parsePathEntries(currentPath); + const additionalDirs = additionalPaths ? parsePathEntries(additionalPaths) : []; + const allPaths = [...additionalDirs, ...searchPaths, ...pathDirs]; + + for (const dir of allPaths) { + if (!dir) continue; + try { + const nodePath = path.join(dir, NODE_EXECUTABLE); + if (fs.existsSync(nodePath)) { + const stat = fs.statSync(nodePath); + if (stat.isFile()) { + return dir; + } + } + } catch { + // Inaccessible directory + } + } + + return null; +} + +export function findNodeExecutable(additionalPaths?: string): string | null { + const nodeDir = findNodeDirectory(additionalPaths); + if (nodeDir) { + return path.join(nodeDir, NODE_EXECUTABLE); + } + return null; +} + +export function cliPathRequiresNode(cliPath: string): boolean { + const jsExtensions = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx']; + const lower = cliPath.toLowerCase(); + if (jsExtensions.some(ext => lower.endsWith(ext))) { + return true; + } + + try { + if (!fs.existsSync(cliPath)) { + return false; + } + + const stat = fs.statSync(cliPath); + if (!stat.isFile()) { + return false; + } + + let fd: number | null = null; + try { + fd = fs.openSync(cliPath, 'r'); + const buffer = Buffer.alloc(200); + const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0); + const header = buffer.subarray(0, bytesRead).toString('utf8'); + if (!header.startsWith('#!')) return false; + const shebangLine = header.split(/\r?\n/)[0].toLowerCase(); + return shebangLine.includes('node'); + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // Ignore close errors + } + } + } + } catch { + return false; + } +} + +export function getMissingNodeError(cliPath: string, enhancedPath?: string): string | null { + if (!cliPathRequiresNode(cliPath)) { + return null; + } + + const nodePath = findNodeExecutable(enhancedPath); + if (nodePath) { + return null; + } + + return 'Claude Code CLI requires Node.js, but Node was not found on PATH. Install Node.js or use the native Claude Code binary, then restart Obsidian.'; +} + +export function getEnhancedPath(additionalPaths?: string, cliPath?: string): string { + const extraPaths = getExtraBinaryPaths().filter(p => p); + const currentPath = process.env.PATH || ''; + + const segments: string[] = []; + + if (additionalPaths) { + segments.push(...parsePathEntries(additionalPaths)); + } + + let cliDirHasNode = false; + if (cliPath) { + try { + const cliDir = path.dirname(cliPath); + const nodeInCliDir = path.join(cliDir, NODE_EXECUTABLE); + if (fs.existsSync(nodeInCliDir)) { + const stat = fs.statSync(nodeInCliDir); + if (stat.isFile()) { + segments.push(cliDir); + cliDirHasNode = true; + } + } + } catch { + // Ignore errors checking CLI directory + } + } + + if (cliPath && cliPathRequiresNode(cliPath) && !cliDirHasNode) { + const nodeDir = findNodeDirectory(); + if (nodeDir) { + segments.push(nodeDir); + } + } + + segments.push(...extraPaths); + + if (currentPath) { + segments.push(...parsePathEntries(currentPath)); + } + + const seen = new Set(); + const unique = segments.filter(p => { + const normalized = isWindows ? p.toLowerCase() : p; + if (seen.has(normalized)) return false; + seen.add(normalized); + return true; + }); + + return unique.join(PATH_SEPARATOR); +} + +export function parseEnvironmentVariables(input: string): Record { + const result: Record = {}; + for (const line of input.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const normalized = trimmed.startsWith('export ') ? trimmed.slice(7) : trimmed; + const eqIndex = normalized.indexOf('='); + if (eqIndex > 0) { + const key = normalized.substring(0, eqIndex).trim(); + let value = normalized.substring(eqIndex + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (key) { + result[key] = value; + } + } + } + return result; +} + +function getDeviceSettingsStorage(): Storage | null { + try { + return typeof window === 'undefined' ? null : window.localStorage; + } catch { + return null; + } +} + +function createOpaqueDeviceSettingsKey(): string { + const cryptoApi = typeof window === 'undefined' ? null : window.crypto; + const randomUUID = cryptoApi?.randomUUID?.(); + if (randomUUID) { + return `device:${randomUUID}`; + } + + if (cryptoApi?.getRandomValues) { + const randomBytes = new Uint8Array(16); + cryptoApi.getRandomValues(randomBytes); + const entropy = Array.from(randomBytes, byte => byte.toString(16).padStart(2, '0')).join(''); + return `device:${Date.now().toString(36)}:${entropy}`; + } + + const entropy = Math.random().toString(36).slice(2); + return `device:${Date.now().toString(36)}:${entropy}`; +} + +// Backward-compatible name: provider settings still store legacy `cliPathsByHost` +// maps, but new keys are opaque per-install identifiers rather than hostnames. +export function getHostnameKey(): string { + if (cachedDeviceSettingsKey) { + return cachedDeviceSettingsKey; + } + + const storage = getDeviceSettingsStorage(); + const stored = storage?.getItem(DEVICE_SETTINGS_STORAGE_KEY)?.trim(); + if (stored) { + cachedDeviceSettingsKey = stored; + return cachedDeviceSettingsKey; + } + + cachedDeviceSettingsKey = createOpaqueDeviceSettingsKey(); + try { + storage?.setItem(DEVICE_SETTINGS_STORAGE_KEY, cachedDeviceSettingsKey); + } catch { + // Local storage can be unavailable in restricted renderer contexts. + } + + return cachedDeviceSettingsKey; +} + +export function getLegacyHostnameKey(): string { + try { + return os.hostname(); + } catch { + return ''; + } +} + +export function migrateLegacyHostnameKeyedMap( + entries: Record, + currentKey: string, + legacyHostnameKey: string, +): Record { + if (!currentKey || !legacyHostnameKey || currentKey === legacyHostnameKey) { + return entries; + } + + const hasCurrentEntry = hasOwnEntry(entries, currentKey); + const hasLegacyEntry = hasOwnEntry(entries, legacyHostnameKey); + if (!hasLegacyEntry) { + return entries; + } + + const migrated = { ...entries }; + if (!hasCurrentEntry) { + migrated[currentKey] = entries[legacyHostnameKey]; + } + delete migrated[legacyHostnameKey]; + return migrated; +} + +function hasOwnEntry(entries: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(entries, key) === true; +} + +export const MIN_CONTEXT_LIMIT = 1_000; +export const MAX_CONTEXT_LIMIT = 10_000_000; + +export function parseContextLimit(input: string): number | null { + const trimmed = input.trim().toLowerCase().replace(/,/g, ''); + if (!trimmed) return null; + + // Match number with optional suffix (k, m) + const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*(k|m)?$/); + if (!match) return null; + + const value = parseFloat(match[1]); + const suffix = match[2]; + + if (isNaN(value) || value <= 0) return null; + + const MULTIPLIERS: Record = { k: 1_000, m: 1_000_000 }; + const multiplier = suffix ? MULTIPLIERS[suffix] ?? 1 : 1; + const result = Math.round(value * multiplier); + + if (result < MIN_CONTEXT_LIMIT || result > MAX_CONTEXT_LIMIT) return null; + + return result; +} + +export function formatContextLimit(tokens: number): string { + if (tokens >= 1_000_000 && tokens % 1_000_000 === 0) { + return `${tokens / 1_000_000}m`; + } + if (tokens >= 1000 && tokens % 1000 === 0) { + return `${tokens / 1000}k`; + } + return tokens.toLocaleString(); +} diff --git a/src/utils/externalContext.ts b/src/utils/externalContext.ts new file mode 100644 index 0000000..05f2db8 --- /dev/null +++ b/src/utils/externalContext.ts @@ -0,0 +1,143 @@ +/** + * Claudian - External Context Utilities + * + * Utilities for external context validation, normalization, and conflict detection. + */ + +import * as fs from 'fs'; + +import { normalizePathForComparison as normalizePathForComparisonImpl } from './path'; + +export interface PathConflict { + path: string; + type: 'parent' | 'child'; +} + +/** + * Normalizes a path for comparison. + * Re-exports the unified implementation from path.ts for consistency. + * - Handles MSYS paths, home/env expansions + * - Case-insensitive on Windows + * - Trailing slash removed + */ +export function normalizePathForComparison(p: string): string { + return normalizePathForComparisonImpl(p); +} + +function normalizePathForDisplay(p: string): string { + if (!p) return ''; + return p.replace(/\\/g, '/').replace(/\/+$/, ''); +} + +export function findConflictingPath( + newPath: string, + existingPaths: string[] +): PathConflict | null { + const normalizedNew = normalizePathForComparison(newPath); + + for (const existing of existingPaths) { + const normalizedExisting = normalizePathForComparison(existing); + + if (normalizedNew.startsWith(normalizedExisting + '/')) { + return { path: existing, type: 'parent' }; + } + + if (normalizedExisting.startsWith(normalizedNew + '/')) { + return { path: existing, type: 'child' }; + } + } + + return null; +} + +export function getFolderName(p: string): string { + const normalized = normalizePathForDisplay(p); + const segments = normalized.split('/'); + return segments[segments.length - 1] || normalized; +} + +export interface ExternalContextDisplayEntry { + contextRoot: string; + displayName: string; + displayNameLower: string; +} + +function getContextDisplayName( + normalizedPath: string, + folderName: string, + needsDisambiguation: boolean +): string { + if (!needsDisambiguation) return folderName; + + const segments = normalizedPath.split('/').filter(Boolean); + if (segments.length < 2) return folderName; + + const parent = segments[segments.length - 2]; + if (!parent) return folderName; + + return `${parent}/${folderName}`; +} + +export function buildExternalContextDisplayEntries( + externalContexts: string[] +): ExternalContextDisplayEntry[] { + const counts = new Map(); + const normalizedPaths = new Map(); + + for (const contextPath of externalContexts) { + const normalized = normalizePathForComparison(contextPath); + normalizedPaths.set(contextPath, normalized); + const folderName = getFolderName(normalized); + counts.set(folderName, (counts.get(folderName) ?? 0) + 1); + } + + return externalContexts.map(contextRoot => { + const normalized = normalizedPaths.get(contextRoot) ?? normalizePathForComparison(contextRoot); + const folderName = getFolderName(contextRoot); + const needsDisambiguation = (counts.get(folderName) ?? 0) > 1; + const displayName = getContextDisplayName(normalized, folderName, needsDisambiguation); + + return { + contextRoot, + displayName, + displayNameLower: displayName.toLowerCase(), + }; + }); +} + +export interface DirectoryValidationResult { + valid: boolean; + error?: string; +} + +export function validateDirectoryPath(p: string): DirectoryValidationResult { + try { + const stats = fs.statSync(p); + if (!stats.isDirectory()) { + return { valid: false, error: 'Path exists but is not a directory' }; + } + return { valid: true }; + } catch (err) { + const error = err as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + return { valid: false, error: 'Path does not exist' }; + } + if (error.code === 'EACCES') { + return { valid: false, error: 'Permission denied' }; + } + return { valid: false, error: `Cannot access path: ${error.message}` }; + } +} + +export function isValidDirectoryPath(p: string): boolean { + return validateDirectoryPath(p).valid; +} + +export function filterValidPaths(paths: string[]): string[] { + return paths.filter(isValidDirectoryPath); +} + +export function isDuplicatePath(newPath: string, existingPaths: string[]): boolean { + const normalizedNew = normalizePathForComparison(newPath); + return existingPaths.some(existing => normalizePathForComparison(existing) === normalizedNew); +} diff --git a/src/utils/externalContextScanner.ts b/src/utils/externalContextScanner.ts new file mode 100644 index 0000000..c630734 --- /dev/null +++ b/src/utils/externalContextScanner.ts @@ -0,0 +1,135 @@ +/** + * Claudian - External Context Scanner + * + * Scans configured external context paths for files to include in @-mention dropdown. + * Features: recursive scanning, caching, and error handling. + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { normalizePathForFilesystem } from './path'; + +export interface ExternalContextFile { + path: string; + name: string; + relativePath: string; + contextRoot: string; + /** In milliseconds */ + mtime: number; +} + +interface ScanCache { + files: ExternalContextFile[]; + timestamp: number; +} + +const CACHE_TTL_MS = 30000; +const MAX_FILES_PER_PATH = 1000; +const MAX_DEPTH = 10; + +const SKIP_DIRECTORIES = new Set([ + 'node_modules', + '__pycache__', + 'venv', + '.venv', + '.git', + '.svn', + '.hg', + 'dist', + 'build', + 'out', + '.next', + '.nuxt', + 'target', + 'vendor', + 'Pods', +]); + +class ExternalContextScanner { + private cache = new Map(); + + scanPaths(externalContextPaths: string[]): ExternalContextFile[] { + const allFiles: ExternalContextFile[] = []; + const now = Date.now(); + + for (const contextPath of externalContextPaths) { + const expandedPath = normalizePathForFilesystem(contextPath); + + const cached = this.cache.get(expandedPath); + if (cached && now - cached.timestamp < CACHE_TTL_MS) { + allFiles.push(...cached.files); + continue; + } + + const files = this.scanDirectory(expandedPath, expandedPath, 0); + this.cache.set(expandedPath, { files, timestamp: now }); + allFiles.push(...files); + } + + return allFiles; + } + + private scanDirectory( + dir: string, + contextRoot: string, + depth: number + ): ExternalContextFile[] { + if (depth > MAX_DEPTH) return []; + + const files: ExternalContextFile[] = []; + + try { + if (!fs.existsSync(dir)) return []; + + const stat = fs.statSync(dir); + if (!stat.isDirectory()) return []; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + if (SKIP_DIRECTORIES.has(entry.name)) continue; + // Symlinks can cause infinite recursion and directory escape + if (entry.isSymbolicLink()) continue; + + const fullPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + const subFiles = this.scanDirectory(fullPath, contextRoot, depth + 1); + files.push(...subFiles); + } else if (entry.isFile()) { + try { + const fileStat = fs.statSync(fullPath); + files.push({ + path: fullPath, + name: entry.name, + relativePath: path.relative(contextRoot, fullPath), + contextRoot, + mtime: fileStat.mtimeMs, + }); + } catch { + // Inaccessible file + } + } + + if (files.length >= MAX_FILES_PER_PATH) break; + } + } catch { + // Inaccessible directory + } + + return files; + } + + invalidateCache(): void { + this.cache.clear(); + } + + invalidatePath(contextPath: string): void { + const expandedPath = normalizePathForFilesystem(contextPath); + this.cache.delete(expandedPath); + } +} + +export const externalContextScanner = new ExternalContextScanner(); diff --git a/src/utils/fileLink.ts b/src/utils/fileLink.ts new file mode 100644 index 0000000..fd7d29c --- /dev/null +++ b/src/utils/fileLink.ts @@ -0,0 +1,263 @@ +/** + * Claudian - File Link Utilities + * + * Detects Obsidian wikilinks [[path/to/file]] in rendered content and makes + * them clickable to open the file in Obsidian. + */ + +import type { App, Component } from 'obsidian'; + +import { getVaultFileByPath } from './obsidianCompat'; + +/** + * Regex pattern to match Obsidian wikilinks in text content. + * + * Matches: + * - Standard wikilinks: [[note]] or [[folder/note]] + * - Wikilinks with display text: [[note|display text]] + * - Wikilinks with headings: [[note#heading]] + * - Wikilinks with block references: [[note^block]] + * + * Does NOT match image embeds ![[image.png]] (those are handled separately). + */ +const WIKILINK_PATTERN_SOURCE = '(? 0 ? fullMatch.slice(pipeIndex + 1, -2) : linkPath; + + return { + index, + fullMatch, + linkPath, + linkTarget: extractLinkTarget(fullMatch), + displayText, + }; +} + +export function extractLinkTarget(fullMatch: string): string { + const inner = fullMatch.slice(2, -2); + const pipeIndex = inner.indexOf('|'); + return pipeIndex >= 0 ? inner.slice(0, pipeIndex) : inner; +} + +/** + * Finds all wikilinks in text that exist in the vault. + * Sorted by index descending for end-to-start processing. + */ +function findWikilinks(app: App, text: string): WikilinkMatch[] { + const pattern = createWikilinkPattern(); + const matches: WikilinkMatch[] = []; + + let match: RegExpExecArray | null; + while ((match = pattern.exec(text)) !== null) { + const fullMatch = match[0]; + const linkPath = match[1]; + + if (!fileExistsInVault(app, linkPath)) continue; + + matches.push(buildWikilinkMatch(fullMatch, linkPath, match.index)); + } + + return matches.sort((a, b) => b.index - a.index); +} + +function fileExistsInVault(app: App, linkPath: string): boolean { + const file = app.metadataCache.getFirstLinkpathDest(linkPath, ''); + if (file) { + return true; + } + + const directFile = getVaultFileByPath(app, linkPath); + if (directFile) { + return true; + } + + if (!linkPath.endsWith('.md')) { + const withExt = getVaultFileByPath(app, linkPath + '.md'); + if (withExt) { + return true; + } + } + + return false; +} + +function extractLinkPathFromTarget(linkTarget: string): string { + const subpathIndex = linkTarget.search(/[#^]/); + return subpathIndex >= 0 ? linkTarget.slice(0, subpathIndex) : linkTarget; +} + +/** + * Creates a link element for a wikilink. + * Click handling is done via event delegation in registerFileLinkHandler. + */ +function createWikilink( + ownerDocument: Document, + linkTarget: string, + displayText: string +): HTMLElement { + const link = ownerDocument.createElement('a'); + link.className = 'claudian-file-link internal-link'; + link.textContent = displayText; + link.setAttribute('data-href', linkTarget); + link.setAttribute('href', linkTarget); + return link; +} + +function repairEmptyInternalLink(app: App, link: HTMLAnchorElement): void { + if ((link.textContent || '').trim()) return; + + const linkTarget = link.dataset.href || link.getAttribute('data-href') || link.getAttribute('href'); + if (!linkTarget) return; + + const linkPath = extractLinkPathFromTarget(linkTarget); + if (!linkPath || !fileExistsInVault(app, linkPath)) return; + + link.classList.add('claudian-file-link'); + if (!link.dataset.href) { + link.setAttribute('data-href', linkTarget); + } + link.textContent = linkTarget; +} + +/** + * Registers a delegated click handler for file links on a container. + * Should be called once on the messages container. + * Handles both our custom .claudian-file-link and Obsidian's .internal-link. + */ +export function registerFileLinkHandler( + app: App, + container: HTMLElement, + component: Component +): void { + component.registerDomEvent(container, 'click', (event: MouseEvent) => { + const target = event.target as HTMLElement; + // Handle both our links and Obsidian's internal links + const link = target.closest('.claudian-file-link, .internal-link') as HTMLAnchorElement; + + if (link) { + event.preventDefault(); + const linkTarget = link.dataset.href || link.getAttribute('href'); + if (linkTarget) { + void app.workspace.openLinkText(linkTarget, '', 'tab'); + } + } + }); +} + +function buildFragmentWithLinks(ownerDocument: Document, text: string, matches: WikilinkMatch[]): DocumentFragment { + const fragment = ownerDocument.createDocumentFragment(); + let currentIndex = text.length; + + for (const { index, fullMatch, linkTarget, displayText } of matches) { + const endIndex = index + fullMatch.length; + + if (endIndex < currentIndex) { + fragment.insertBefore( + ownerDocument.createTextNode(text.slice(endIndex, currentIndex)), + fragment.firstChild + ); + } + + fragment.insertBefore(createWikilink(ownerDocument, linkTarget, displayText), fragment.firstChild); + currentIndex = index; + } + + if (currentIndex > 0) { + fragment.insertBefore( + ownerDocument.createTextNode(text.slice(0, currentIndex)), + fragment.firstChild + ); + } + + return fragment; +} + +function processTextNode(app: App, node: Text): boolean { + const text = node.textContent; + if (!text || !text.includes('[[')) return false; + + const matches = findWikilinks(app, text); + if (matches.length === 0) return false; + + node.parentNode?.replaceChild(buildFragmentWithLinks(node.ownerDocument, text, matches), node); + return true; +} + +/** + * Call after MarkdownRenderer.renderMarkdown(). + * Catches wikilinks that remain as raw text after rendering, especially inline code spans. + */ +export function processFileLinks(app: App, container: HTMLElement): void { + if (!app || !container) return; + + // Repair resolved internal links that rendered as empty anchors. + container.querySelectorAll('a.internal-link').forEach((linkEl) => { + repairEmptyInternalLink(app, linkEl as HTMLAnchorElement); + }); + + // Wikilinks in inline code aren't rendered by Obsidian's MarkdownRenderer + container.querySelectorAll('code').forEach((codeEl) => { + if (codeEl.parentElement?.tagName === 'PRE') return; + + const text = codeEl.textContent; + if (!text || !text.includes('[[')) return; + + const matches = findWikilinks(app, text); + if (matches.length === 0) return; + + codeEl.textContent = ''; + codeEl.appendChild(buildFragmentWithLinks(container.ownerDocument, text, matches)); + }); + + const walker = container.ownerDocument.createTreeWalker( + container, + NodeFilter.SHOW_TEXT, + { + acceptNode(node) { + const parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + + const tagName = parent.tagName.toUpperCase(); + if (tagName === 'PRE' || tagName === 'CODE' || tagName === 'A') { + return NodeFilter.FILTER_REJECT; + } + + if (parent.closest('pre, code, a, .claudian-file-link, .internal-link')) { + return NodeFilter.FILTER_REJECT; + } + + return NodeFilter.FILTER_ACCEPT; + }, + } + ); + + // Modifying DOM while walking causes issues, so collect first + const textNodes: Text[] = []; + let node: Node | null; + while ((node = walker.nextNode())) { + textNodes.push(node as Text); + } + + for (const textNode of textNodes) { + processTextNode(app, textNode); + } +} diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts new file mode 100644 index 0000000..5e9a691 --- /dev/null +++ b/src/utils/frontmatter.ts @@ -0,0 +1,194 @@ +import { parseYaml } from 'obsidian'; + +const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/; +const VALID_KEY_PATTERN = /^[\w-]+$/; + +function isValidKey(key: string): boolean { + return key.length > 0 && VALID_KEY_PATTERN.test(key); +} + +function unquote(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; +} + +function parseScalarValue(rawValue: string): unknown { + const value = rawValue.trim(); + if (value === 'true') return true; + if (value === 'false') return false; + if (value === 'null' || value === '') return null; + if (!Number.isNaN(Number(value))) return Number(value); + if (value.startsWith('[') && value.endsWith(']')) { + return value + .slice(1, -1) + .split(',') + .map(item => item.trim()) + .filter(Boolean) + .map(item => unquote(item)); + } + return unquote(value); +} + +/** Handles malformed YAML (e.g. unquoted values with colons) by line-by-line key:value extraction. */ +function parseFrontmatterFallback(yamlContent: string): Record { + const result: Record = {}; + const lines = yamlContent.split(/\r?\n/); + let currentListKey: string | null = null; + let currentList: unknown[] = []; + + function flushList(): void { + if (!currentListKey) return; + result[currentListKey] = currentList; + currentListKey = null; + currentList = []; + } + + let pendingBareKey: string | null = null; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + if (currentListKey) { + if (trimmed.startsWith('- ')) { + currentList.push(parseScalarValue(trimmed.slice(2))); + continue; + } + flushList(); + } + + if (pendingBareKey) { + if (trimmed.startsWith('- ')) { + currentListKey = pendingBareKey; + currentList = []; + pendingBareKey = null; + currentList.push(parseScalarValue(trimmed.slice(2))); + continue; + } + result[pendingBareKey] = ''; + pendingBareKey = null; + } + + const colonIndex = trimmed.indexOf(': '); + if (colonIndex === -1) { + if (trimmed.endsWith(':')) { + const key = trimmed.slice(0, -1).trim(); + if (isValidKey(key)) { + pendingBareKey = key; + } + } + continue; + } + + const key = trimmed.slice(0, colonIndex).trim(); + if (!isValidKey(key)) continue; + result[key] = parseScalarValue(trimmed.slice(colonIndex + 2)); + } + + if (pendingBareKey) { + result[pendingBareKey] = ''; + } + + flushList(); + return result; +} + +export function parseFrontmatter( + content: string +): { frontmatter: Record; body: string } | null { + const match = content.match(FRONTMATTER_PATTERN); + if (!match) return null; + + try { + const parsed: unknown = parseYaml(match[1]); + if (parsed !== null && parsed !== undefined && typeof parsed !== 'object') { + return null; + } + return { + frontmatter: (parsed as Record) ?? {}, + body: match[2], + }; + } catch { + const fallbackParsed = parseFrontmatterFallback(match[1]); + if (Object.keys(fallbackParsed).length > 0) { + return { + frontmatter: fallbackParsed, + body: match[2], + }; + } + return null; + } +} + +export function extractString( + fm: Record, + key: string +): string | undefined { + const val = fm[key]; + if (typeof val === 'string' && val.length > 0) return val; + if (Array.isArray(val) && val.length > 0 && val.every(v => typeof v === 'string')) { + return val.map(v => `[${v}]`).join(' '); + } + return undefined; +} + +export function normalizeStringArray(val: unknown): string[] | undefined { + if (val === undefined || val === null) return undefined; + + if (Array.isArray(val)) { + return val.map(v => String(v).trim()).filter(Boolean); + } + + if (typeof val === 'string') { + const trimmed = val.trim(); + if (!trimmed) return undefined; + return trimmed.split(',').map(s => s.trim()).filter(Boolean); + } + + return undefined; +} + +export function extractStringArray( + fm: Record, + key: string +): string[] | undefined { + return normalizeStringArray(fm[key]); +} + +export function extractBoolean( + fm: Record, + key: string +): boolean | undefined { + const val = fm[key]; + if (typeof val === 'boolean') return val; + return undefined; +} + +export function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +const MAX_SLUG_LENGTH = 64; +const SLUG_PATTERN = /^[a-z0-9-]+$/; +const YAML_RESERVED_WORDS = new Set(['true', 'false', 'null', 'yes', 'no', 'on', 'off']); + +export function validateSlugName(name: string, label: string): string | null { + if (!name) { + return `${label} name is required`; + } + if (name.length > MAX_SLUG_LENGTH) { + return `${label} name must be ${MAX_SLUG_LENGTH} characters or fewer`; + } + if (!SLUG_PATTERN.test(name)) { + return `${label} name can only contain lowercase letters, numbers, and hyphens`; + } + if (YAML_RESERVED_WORDS.has(name)) { + return `${label} name cannot be a YAML reserved word (true, false, null, yes, no, on, off)`; + } + return null; +} diff --git a/src/utils/imageAttachment.ts b/src/utils/imageAttachment.ts new file mode 100644 index 0000000..9c4fd7f --- /dev/null +++ b/src/utils/imageAttachment.ts @@ -0,0 +1,80 @@ +import type { ImageAttachment, ImageMediaType } from '../core/types'; + +const IMAGE_MEDIA_TYPES: Record = { + 'image/gif': 'image/gif', + 'image/jpeg': 'image/jpeg', + 'image/jpg': 'image/jpeg', + 'image/png': 'image/png', + 'image/webp': 'image/webp', +}; + +const IMAGE_EXTENSIONS: Record = { + 'image/gif': 'gif', + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/webp': 'webp', +}; + +export interface ParsedImageDataUri { + data: string; + mediaType: ImageMediaType; +} + +export interface BuildImageAttachmentOptions { + data: string; + id: string; + mediaType: string; + name?: string; +} + +export function normalizeImageMediaType(value: unknown): ImageMediaType | null { + if (typeof value !== 'string') { + return null; + } + + return IMAGE_MEDIA_TYPES[value.trim().toLowerCase()] ?? null; +} + +export function parseImageDataUri(value: unknown): ParsedImageDataUri | null { + if (typeof value !== 'string') { + return null; + } + + const match = value.trim().match(/^data:([^;,]+);base64,(.+)$/i); + if (!match) { + return null; + } + + const mediaType = normalizeImageMediaType(match[1]); + const data = match[2]?.trim(); + if (!mediaType || !data) { + return null; + } + + return { data, mediaType }; +} + +export function buildImageAttachmentFromBase64( + options: BuildImageAttachmentOptions, +): ImageAttachment | null { + const mediaType = normalizeImageMediaType(options.mediaType); + const data = options.data.trim(); + if (!mediaType || !data) { + return null; + } + + return { + data, + id: options.id, + mediaType, + name: options.name?.trim() || `image.${IMAGE_EXTENSIONS[mediaType]}`, + size: estimateBase64ByteLength(data), + source: 'paste', + }; +} + +function estimateBase64ByteLength(value: string): number { + const data = value.replace(/\s/g, ''); + const padding = data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0; + return Math.max(0, Math.floor((data.length * 3) / 4) - padding); +} diff --git a/src/utils/imageEmbed.ts b/src/utils/imageEmbed.ts new file mode 100644 index 0000000..1eed4c2 --- /dev/null +++ b/src/utils/imageEmbed.ts @@ -0,0 +1,139 @@ +/** + * Claudian - Image Embed Utilities + * + * Replaces Obsidian image embeds ![[image.png]] with HTML tags + * before MarkdownRenderer processes the content. + * + * Note: This is display-only - the agent still receives the wikilink text. + */ + +import type { App, TFile } from 'obsidian'; + +import { escapeHtml } from './inlineEdit'; +import { getVaultFileByPath } from './obsidianCompat'; + +const IMAGE_EXTENSIONS = new Set([ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'svg', + 'bmp', + 'ico', +]); + +const IMAGE_EMBED_PATTERN = /!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; + +interface ReplaceImageEmbedsOptions { + mediaFolder?: string; + sourcePath?: string; +} + +function isImagePath(path: string): boolean { + const ext = path.split('.').pop()?.toLowerCase(); + return ext ? IMAGE_EXTENSIONS.has(ext) : false; +} + +function resolveImageFile( + app: App, + imagePath: string, + options: Required +): TFile | null { + let file = getVaultFileByPath(app, imagePath); + if (file) return file; + + if (options.mediaFolder) { + const withFolder = `${options.mediaFolder}/${imagePath}`; + file = getVaultFileByPath(app, withFolder); + if (file) return file; + } + + const resolved = app.metadataCache.getFirstLinkpathDest(imagePath, options.sourcePath); + if (resolved) return resolved; + + return null; +} + + +/** Supports formats: "100" (width only) or "100x200" (width x height) */ +function buildStyleAttribute(altText: string | undefined): string { + if (!altText) return ''; + + const dimMatch = altText.match(/^(\d+)(?:x(\d+))?$/); + if (!dimMatch) return ''; + + const width = dimMatch[1]; + const height = dimMatch[2]; + + if (height) { + return ` style="width: ${width}px; height: ${height}px;"`; + } + return ` style="width: ${width}px;"`; +} + +function createImageHtml( + app: App, + file: TFile, + altText: string | undefined +): string { + const src = app.vault.getResourcePath(file); + const alt = escapeHtml(altText || file.basename); + const style = buildStyleAttribute(altText); + + return `${alt}`; +} + +function createFallbackHtml(wikilink: string): string { + return `${escapeHtml(wikilink)}`; +} + +function normalizeOptions(options?: string | ReplaceImageEmbedsOptions): Required { + if (typeof options === 'string') { + return { mediaFolder: options, sourcePath: '' }; + } + + return { + mediaFolder: options?.mediaFolder ?? '', + sourcePath: options?.sourcePath ?? '', + }; +} + +/** + * Call before MarkdownRenderer.render(). + * Non-image embeds (e.g., ![[note.md]]) pass through unchanged. + */ +export function replaceImageEmbedsWithHtml( + markdown: string, + app: App, + options?: string | ReplaceImageEmbedsOptions +): string { + if (!app?.vault || !app?.metadataCache) { + return markdown; + } + + const normalizedOptions = normalizeOptions(options); + + // Reset lastIndex to avoid issues with global regex + IMAGE_EMBED_PATTERN.lastIndex = 0; + + return markdown.replace( + IMAGE_EMBED_PATTERN, + (match, imagePath: string, altText: string | undefined) => { + try { + if (!isImagePath(imagePath)) { + return match; + } + + const file = resolveImageFile(app, imagePath, normalizedOptions); + if (!file) { + return createFallbackHtml(match); + } + + return createImageHtml(app, file, altText); + } catch { + return createFallbackHtml(match); + } + } + ); +} diff --git a/src/utils/inlineEdit.ts b/src/utils/inlineEdit.ts new file mode 100644 index 0000000..39161c0 --- /dev/null +++ b/src/utils/inlineEdit.ts @@ -0,0 +1,22 @@ +/** + * Utilities for inline edit UI. + * Kept dependency-free so tests can import directly. + */ + +/** + * Trims leading and trailing blank lines from insertion text. + * Matches the behavior expected by cursor insertion preview. + */ +export function normalizeInsertionText(text: string): string { + return text.replace(/^(?:\r?\n)+|(?:\r?\n)+$/g, ''); +} + +/** Escapes HTML special characters to prevent injection in rendered content. */ +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + diff --git a/src/utils/interrupt.ts b/src/utils/interrupt.ts new file mode 100644 index 0000000..630cbf4 --- /dev/null +++ b/src/utils/interrupt.ts @@ -0,0 +1,23 @@ +const INTERRUPT_MARKERS = new Set([ + '[Request interrupted by user]', + '[Request interrupted by user for tool use]', +]); + +const COMPACTION_CANCELED_STDERR_PATTERN = + /^\s*Error:\s*Compaction canceled\.?\s*<\/local-command-stderr>$/i; + +function normalize(text: string): string { + return text.trim(); +} + +export function isBracketInterruptText(text: string): boolean { + return INTERRUPT_MARKERS.has(normalize(text)); +} + +export function isCompactionCanceledStderr(text: string): boolean { + return COMPACTION_CANCELED_STDERR_PATTERN.test(normalize(text)); +} + +export function isInterruptSignalText(text: string): boolean { + return isBracketInterruptText(text) || isCompactionCanceledStderr(text); +} diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts new file mode 100644 index 0000000..6f8d134 --- /dev/null +++ b/src/utils/markdown.ts @@ -0,0 +1,25 @@ +/** + * Claudian - Markdown Utilities + * + * Markdown manipulation helpers. + */ + +/** Appends a Markdown snippet to an existing prompt with sensible spacing. */ +export function appendMarkdownSnippet(existingPrompt: string, snippet: string): string { + const trimmedSnippet = snippet.trim(); + if (!trimmedSnippet) { + return existingPrompt; + } + + if (!existingPrompt.trim()) { + return trimmedSnippet; + } + + const separator = existingPrompt.endsWith('\n\n') + ? '' + : existingPrompt.endsWith('\n') + ? '\n' + : '\n\n'; + + return existingPrompt + separator + trimmedSnippet; +} diff --git a/src/utils/markdownMath.ts b/src/utils/markdownMath.ts new file mode 100644 index 0000000..4a1996f --- /dev/null +++ b/src/utils/markdownMath.ts @@ -0,0 +1,130 @@ +interface FenceState { + marker: '`' | '~'; + length: number; +} + +function getFenceRun(line: string): string | null { + const match = line.match(/^ {0,3}(`{3,}|~{3,})/); + return match?.[1] ?? null; +} + +function isClosingFence(line: string, fence: FenceState): boolean { + const run = getFenceRun(line); + return !!run && run[0] === fence.marker && run.length >= fence.length; +} + +function isHtmlTagStart(line: string, index: number): boolean { + const next = line[index + 1]; + return !!next && /[A-Za-z/!?]/.test(next); +} + +function readBacktickRun(line: string, index: number): number { + let length = 0; + while (line[index + length] === '`') { + length += 1; + } + return length; +} + +function escapeMathDelimitersInLine(line: string): string { + let escaped = ''; + let inlineCodeRunLength = 0; + let inHtmlTag = false; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + + if (char === '`') { + const runLength = readBacktickRun(line, index); + escaped += line.slice(index, index + runLength); + index += runLength - 1; + if (inlineCodeRunLength === 0) { + inlineCodeRunLength = runLength; + } else if (runLength === inlineCodeRunLength) { + inlineCodeRunLength = 0; + } + continue; + } + + if (inlineCodeRunLength > 0) { + escaped += char; + continue; + } + + if (inHtmlTag) { + escaped += char; + if (char === '>') { + inHtmlTag = false; + } + continue; + } + + if (char === '<' && isHtmlTagStart(line, index)) { + inHtmlTag = true; + escaped += char; + continue; + } + + if (char === '\\' && line[index + 1] === '$') { + escaped += '\\$'; + index += 1; + continue; + } + + escaped += char === '$' ? '\\$' : char; + } + + return escaped; +} + +/** + * Escapes dollar math delimiters outside code spans and fenced code blocks. + * Used only for transient streaming renders so MarkdownRenderer does not hand + * incomplete math to Obsidian's math renderer on every frame. + */ +export function escapeMathDelimitersForStreaming(markdown: string): string { + if (!markdown.includes('$')) { + return markdown; + } + + let result = ''; + let fence: FenceState | null = null; + let lineStart = 0; + + while (lineStart < markdown.length) { + const newlineIndex = markdown.indexOf('\n', lineStart); + const lineEnd = newlineIndex === -1 ? markdown.length : newlineIndex + 1; + const line = markdown.slice(lineStart, lineEnd); + const lineWithoutNewline = line.endsWith('\n') ? line.slice(0, -1) : line; + + if (fence) { + result += line; + if (isClosingFence(lineWithoutNewline, fence)) { + fence = null; + } + } else { + const fenceRun = getFenceRun(lineWithoutNewline); + if (fenceRun) { + result += line; + fence = { + marker: fenceRun[0] as '`' | '~', + length: fenceRun.length, + }; + } else { + result += escapeMathDelimitersInLine(line); + } + } + + lineStart = lineEnd; + } + + return result; +} + +export function hasStreamingMathDelimiters(markdown: string): boolean { + if (!markdown.includes('$')) { + return false; + } + + return escapeMathDelimitersForStreaming(markdown) !== markdown; +} diff --git a/src/utils/mcp.ts b/src/utils/mcp.ts new file mode 100644 index 0000000..8127f50 --- /dev/null +++ b/src/utils/mcp.ts @@ -0,0 +1,96 @@ +export function extractMcpMentions(text: string, validNames: Set): Set { + const mentions = new Set(); + const regex = /@([a-zA-Z0-9._-]+)(?!\/)/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(text)) !== null) { + const name = match[1]; + if (validNames.has(name)) { + mentions.add(name); + } + } + + return mentions; +} + +/** + * Transform MCP mentions in text by appending " MCP" after each valid @mention. + * This is applied to the API request only, not shown in the input. + */ +export function transformMcpMentions(text: string, validNames: Set): string { + if (validNames.size === 0) return text; + + // Sort names by length (longest first) to avoid partial matches + const sortedNames = Array.from(validNames).sort((a, b) => b.length - a.length); + + // Build single pattern with alternation (more efficient than N passes) + const escapedNames = sortedNames.map(escapeRegExp).join('|'); + // Match @name that: + // - is not already followed by " MCP" + // - is not followed by "/" (context folder) + // - is not followed by alphanumeric/underscore/hyphen (partial match) + // - is not followed by "." + word char (e.g., @test in @test.server) + // This allows @server. (period as punctuation) while preventing @test.foo matches + const pattern = new RegExp( + `@(${escapedNames})(?! MCP)(?!/)(?![a-zA-Z0-9_-])(?!\\.[a-zA-Z0-9_-])`, + 'g' + ); + + return text.replace(pattern, '@$1 MCP'); +} + +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function parseCommand(command: string, providedArgs?: string[]): { cmd: string; args: string[] } { + if (providedArgs && providedArgs.length > 0) { + return { cmd: command, args: providedArgs }; + } + + const parts = splitCommandString(command); + if (parts.length === 0) { + return { cmd: '', args: [] }; + } + + return { cmd: parts[0], args: parts.slice(1) }; +} + +export function splitCommandString(cmdStr: string): string[] { + const parts: string[] = []; + let current = ''; + let inQuote = false; + let quoteChar = ''; + + for (let i = 0; i < cmdStr.length; i++) { + const char = cmdStr[i]; + + if ((char === '"' || char === "'") && !inQuote) { + inQuote = true; + quoteChar = char; + continue; + } + + if (char === quoteChar && inQuote) { + inQuote = false; + quoteChar = ''; + continue; + } + + if (/\s/.test(char) && !inQuote) { + if (current) { + parts.push(current); + current = ''; + } + continue; + } + + current += char; + } + + if (current) { + parts.push(current); + } + + return parts; +} diff --git a/src/utils/obsidianCompat.ts b/src/utils/obsidianCompat.ts new file mode 100644 index 0000000..19fde6f --- /dev/null +++ b/src/utils/obsidianCompat.ts @@ -0,0 +1,23 @@ +import type { App, TFile, Workspace, WorkspaceLeaf } from 'obsidian'; + +export function getVaultFileByPath(app: App, filePath: string): TFile | null { + const file = app.vault.getAbstractFileByPath(filePath); + if (isVaultFile(file)) { + return file; + } + return null; +} + +export async function revealWorkspaceLeaf(workspace: Workspace, leaf: WorkspaceLeaf): Promise { + await workspace.revealLeaf(leaf); +} + +function isVaultFile(value: unknown): value is TFile { + if (!value || typeof value !== 'object') { + return false; + } + + const candidate = value as Partial; + return typeof candidate.path === 'string' + && typeof candidate.basename === 'string'; +} diff --git a/src/utils/path.ts b/src/utils/path.ts new file mode 100644 index 0000000..4cdce35 --- /dev/null +++ b/src/utils/path.ts @@ -0,0 +1,342 @@ +import * as fs from 'fs'; +import type { App } from 'obsidian'; +import * as os from 'os'; +import * as path from 'path'; + +export function getVaultPath(app: App): string | null { + const basePath = (app.vault.adapter as { basePath?: unknown } | undefined)?.basePath; + return typeof basePath === 'string' ? basePath : null; +} + +function getEnvValue(key: string): string | undefined { + const hasKey = (name: string): boolean => name in process.env && process.env[name] !== undefined; + + if (hasKey(key)) { + return process.env[key]; + } + + if (process.platform !== 'win32') { + return undefined; + } + + const upper = key.toUpperCase(); + if (hasKey(upper)) { + return process.env[upper]; + } + + const lower = key.toLowerCase(); + if (hasKey(lower)) { + return process.env[lower]; + } + + const matchKey = Object.keys(process.env).find((name) => name.toLowerCase() === key.toLowerCase()); + return matchKey ? process.env[matchKey] : undefined; +} + +function expandEnvironmentVariables(value: string): string { + if (!value.includes('%') && !value.includes('$') && !value.includes('!')) { + return value; + } + + const isWindows = process.platform === 'win32'; + let expanded = value; + + // Windows %VAR% format - allow parentheses for vars like %ProgramFiles(x86)% + expanded = expanded.replace(/%([A-Za-z_][A-Za-z0-9_]*(?:\([A-Za-z0-9_]+\))?[A-Za-z0-9_]*)%/g, (match: string, name: string): string => { + const envValue = getEnvValue(name); + return envValue !== undefined ? envValue : match; + }); + + if (isWindows) { + expanded = expanded.replace(/!([A-Za-z_][A-Za-z0-9_]*)!/g, (match: string, name: string): string => { + const envValue = getEnvValue(name); + return envValue !== undefined ? envValue : match; + }); + + expanded = expanded.replace(/\$env:([A-Za-z_][A-Za-z0-9_]*)/gi, (match: string, name: string): string => { + const envValue = getEnvValue(name); + return envValue !== undefined ? envValue : match; + }); + } + + expanded = expanded.replace(/\$([A-Za-z_][A-Za-z0-9_]*)|\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (match: string, name1: string | undefined, name2: string | undefined): string => { + const key = name1 ?? name2; + if (!key) return match; + const envValue = getEnvValue(key); + return envValue !== undefined ? envValue : match; + }); + + return expanded; +} + +export function expandHomePath(p: string): string { + const expanded = expandEnvironmentVariables(p); + if (expanded === '~') { + return os.homedir(); + } + if (expanded.startsWith('~/')) { + return path.join(os.homedir(), expanded.slice(2)); + } + if (expanded.startsWith('~\\')) { + return path.join(os.homedir(), expanded.slice(2)); + } + return expanded; +} + +function stripSurroundingQuotes(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; +} + +export function parsePathEntries(pathValue?: string): string[] { + if (!pathValue) { + return []; + } + + const delimiter = process.platform === 'win32' ? ';' : ':'; + + return pathValue + .split(delimiter) + .map(segment => stripSurroundingQuotes(segment.trim())) + .filter(segment => { + if (!segment) return false; + const upper = segment.toUpperCase(); + return upper !== '$PATH' && upper !== '${PATH}' && upper !== '%PATH%'; + }) + .map(segment => translateMsysPath(expandHomePath(segment))); +} + + +const NVM_LATEST_INSTALLED_ALIASES = new Set(['node', 'stable']); + +function isNvmBuiltInLatestAlias(alias: string): boolean { + return NVM_LATEST_INSTALLED_ALIASES.has(alias); +} + +function findMatchingNvmVersion(entries: string[], resolvedAlias: string): string | undefined { + if (isNvmBuiltInLatestAlias(resolvedAlias)) { + return entries[0]; + } + + const version = resolvedAlias.replace(/^v/, ''); + return entries.find(entry => { + const entryVersion = entry.slice(1); // strip 'v' + return entryVersion === version || entryVersion.startsWith(version + '.'); + }); +} + +function resolveNvmAlias(nvmDir: string, alias: string, depth = 0): string | null { + if (depth > 5) return null; + + if (/^\d/.test(alias) || alias.startsWith('v')) return alias; + if (isNvmBuiltInLatestAlias(alias)) return alias; + + try { + const aliasFile = path.join(nvmDir, 'alias', ...alias.split('/')); + const target = fs.readFileSync(aliasFile, 'utf8').trim(); + if (!target) return null; + return resolveNvmAlias(nvmDir, target, depth + 1); + } catch { + return null; + } +} + +// GUI apps don't have NVM_BIN set, so we resolve nvm's default alias +// from the filesystem and match against installed versions. +export function resolveNvmDefaultBin(home: string): string | null { + const nvmDir = process.env.NVM_DIR || path.join(home, '.nvm'); + + try { + const alias = fs.readFileSync(path.join(nvmDir, 'alias', 'default'), 'utf8').trim(); + if (!alias) return null; + + const resolved = resolveNvmAlias(nvmDir, alias); + if (!resolved) return null; + + const versionsDir = path.join(nvmDir, 'versions', 'node'); + const entries = fs.readdirSync(versionsDir) + .filter(entry => entry.startsWith('v')) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + + const matched = findMatchingNvmVersion(entries, resolved); + + if (matched) { + const binDir = path.join(versionsDir, matched, 'bin'); + if (fs.existsSync(binDir)) return binDir; + } + } catch { + // nvm not installed + } + + return null; +} + +// Best-effort realpath: if the full path doesn't exist, resolve the nearest +// existing ancestor and re-append the remaining segments. +function resolveRealPath(p: string): string { + const realpathFn = (fs.realpathSync.native ?? fs.realpathSync) as (path: fs.PathLike) => string; + + try { + return realpathFn(p); + } catch { + const absolute = path.resolve(p); + let current = absolute; + const suffix: string[] = []; + + for (;;) { + try { + if (fs.existsSync(current)) { + const resolvedExisting = realpathFn(current); + return suffix.length > 0 + ? path.join(resolvedExisting, ...suffix.reverse()) + : resolvedExisting; + } + } catch { + // Keep walking up + } + + const parent = path.dirname(current); + if (parent === current) { + return absolute; + } + + suffix.push(path.basename(current)); + current = parent; + } + } +} + +// Translates MSYS/Git Bash paths (/c/Users/...) to Windows paths (C:\Users\...). +// Must be called before path.resolve() or path.isAbsolute(). +export function translateMsysPath(value: string): string { + if (process.platform !== 'win32') { + return value; + } + + const msysMatch = value.match(/^\/([a-zA-Z])(\/.*)?$/); + if (msysMatch) { + const driveLetter = msysMatch[1].toUpperCase(); + const restOfPath = msysMatch[2] ?? ''; + return `${driveLetter}:${restOfPath.replace(/\//g, '\\')}`; + } + + return value; +} + +function normalizePathBeforeResolution(p: string): string { + const expanded = expandHomePath(p); + return translateMsysPath(expanded); +} + +function normalizeWindowsPathPrefix(value: string): string { + if (process.platform !== 'win32') { + return value; + } + + const normalized = translateMsysPath(value); + + if (normalized.startsWith('\\\\?\\UNC\\')) { + return `\\\\${normalized.slice('\\\\?\\UNC\\'.length)}`; + } + + if (normalized.startsWith('\\\\?\\')) { + return normalized.slice('\\\\?\\'.length); + } + + return normalized; +} + +export function normalizePathForFilesystem(value: string): string { + if (!value || typeof value !== 'string') { + return ''; + } + const expanded = normalizePathBeforeResolution(value); + const normalized = (() => { + try { + return process.platform === 'win32' + ? path.win32.normalize(expanded) + : path.normalize(expanded); + } catch { + return expanded; + } + })(); + + return normalizeWindowsPathPrefix(normalized); +} + +export function normalizePathForComparison(value: string): string { + if (!value || typeof value !== 'string') { + return ''; + } + + const expanded = normalizePathBeforeResolution(value); + const normalized = (() => { + try { + return process.platform === 'win32' + ? path.win32.normalize(expanded) + : path.normalize(expanded); + } catch { + return expanded; + } + })(); + + const normalizedWithPrefix = normalizeWindowsPathPrefix(normalized) + .replace(/\\/g, '/') + .replace(/\/+$/, ''); + + return process.platform === 'win32' + ? normalizedWithPrefix.toLowerCase() + : normalizedWithPrefix; +} + +export function isPathWithinDirectory( + candidatePath: string, + directoryPath: string, + relativeBasePath?: string, +): boolean { + if (!candidatePath || !directoryPath) { + return false; + } + + const directoryReal = normalizePathForComparison(resolveRealPath(directoryPath)); + const normalizedCandidate = normalizePathForFilesystem(candidatePath); + if (!normalizedCandidate) { + return false; + } + + const absCandidate = path.isAbsolute(normalizedCandidate) + ? normalizedCandidate + : path.resolve(relativeBasePath ?? directoryPath, normalizedCandidate); + + const resolvedCandidate = normalizePathForComparison(resolveRealPath(absCandidate)); + return resolvedCandidate === directoryReal || resolvedCandidate.startsWith(directoryReal + '/'); +} + +export function isPathWithinVault(candidatePath: string, vaultPath: string): boolean { + return isPathWithinDirectory(candidatePath, vaultPath, vaultPath); +} + +export function normalizePathForVault( + rawPath: string | undefined | null, + vaultPath: string | null | undefined +): string | null { + if (!rawPath) return null; + + const normalizedRaw = normalizePathForFilesystem(rawPath); + if (!normalizedRaw) return null; + + if (vaultPath && isPathWithinVault(normalizedRaw, vaultPath)) { + const absolute = path.isAbsolute(normalizedRaw) + ? normalizedRaw + : path.resolve(vaultPath, normalizedRaw); + const relative = path.relative(vaultPath, absolute); + return relative ? relative.replace(/\\/g, '/') : null; + } + + return normalizedRaw.replace(/\\/g, '/'); +} diff --git a/src/utils/session.ts b/src/utils/session.ts new file mode 100644 index 0000000..70eff72 --- /dev/null +++ b/src/utils/session.ts @@ -0,0 +1,253 @@ +/** + * Claudian - Session Utilities + * + * Session recovery and history reconstruction. + */ + +import type { ChatMessage, ToolCallInfo } from '../core/types'; +import { extractUserQuery, formatCurrentNote } from './context'; + +// ============================================ +// Session Recovery +// ============================================ + +const SESSION_ERROR_PATTERNS = [ + 'session expired', + 'session not found', + 'no conversation found with session id', + 'invalid session', + 'session invalid', + 'process exited with code', +] as const; + +export function getMissingSessionId(error: unknown): string | null { + const message = error instanceof Error ? error.message : ''; + const match = message.match(/no conversation found with session id:\s*([a-z0-9_-]+)/i); + return match?.[1] ?? null; +} + +export function isSessionMissingError(error: unknown, expectedSessionId?: string): boolean { + const missingSessionId = getMissingSessionId(error); + return !!missingSessionId + && (!expectedSessionId || missingSessionId.toLowerCase() === expectedSessionId.toLowerCase()); +} + +const SESSION_ERROR_COMPOUND_PATTERNS = [ + { includes: ['session', 'expired'] }, + { includes: ['resume', 'failed'] }, + { includes: ['resume', 'error'] }, +] as const; + +export function isSessionExpiredError(error: unknown): boolean { + const msg = error instanceof Error ? error.message.toLowerCase() : ''; + + for (const pattern of SESSION_ERROR_PATTERNS) { + if (msg.includes(pattern)) { + return true; + } + } + + for (const { includes } of SESSION_ERROR_COMPOUND_PATTERNS) { + if (includes.every(part => msg.includes(part))) { + return true; + } + } + + return false; +} + +// ============================================ +// History Reconstruction +// ============================================ + +/** + * Formats tool input for inclusion in rebuilt context. + * Includes all non-null parameters, truncates long string values. + */ +function formatToolInput(input: Record, maxLength = 200): string { + if (!input || Object.keys(input).length === 0) return ''; + + try { + const parts: string[] = []; + for (const [key, value] of Object.entries(input)) { + if (value === undefined || value === null) continue; + + let valueStr: string; + if (typeof value === 'string') { + valueStr = value.length > 100 ? `${value.slice(0, 100)}...` : value; + } else if (typeof value === 'object') { + valueStr = '[object]'; + } else if (typeof value === 'function') { + valueStr = '[function]'; + } else if (typeof value === 'symbol') { + valueStr = value.description ? `[symbol:${value.description}]` : '[symbol]'; + } else if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + valueStr = `${value}`; + } else { + valueStr = '[unknown]'; + } + parts.push(`${key}=${valueStr}`); + } + + const result = parts.join(', '); + return result.length > maxLength ? `${result.slice(0, maxLength)}...` : result; + } catch { + return '[input formatting error]'; + } +} + +/** + * Formats a tool call for inclusion in rebuilt context. + * + * Strategy: + * - Always include tool name and input (so Claude knows what was attempted) + * - Only include results for failed tools (errors are important to remember) + * - Successful tools can be re-executed if needed + */ +export function formatToolCallForContext(toolCall: ToolCallInfo, maxErrorLength = 500): string { + const status = toolCall.status ?? 'completed'; + const isFailed = status === 'error' || status === 'blocked'; + const inputStr = formatToolInput(toolCall.input); + const inputPart = inputStr ? ` input: ${inputStr}` : ''; + + if (!isFailed) { + return `[Tool ${toolCall.name}${inputPart} status=${status}]`; + } + + const hasResult = typeof toolCall.result === 'string' && toolCall.result.trim().length > 0; + if (!hasResult) { + return `[Tool ${toolCall.name}${inputPart} status=${status}]`; + } + + const errorMsg = truncateToolResult(toolCall.result as string, maxErrorLength); + return `[Tool ${toolCall.name}${inputPart} status=${status}] error: ${errorMsg}`; +} + +export function truncateToolResult(result: string, maxLength = 500): string { + if (result.length > maxLength) { + return `${result.slice(0, maxLength)}... (truncated)`; + } + return result; +} + +export function formatContextLine(message: ChatMessage): string | null { + if (!message.currentNote) { + return null; + } + return formatCurrentNote(message.currentNote); +} + +/** + * Formats thinking blocks for inclusion in rebuilt context. + * Just indicates that thinking occurred (content not included - Claude will think anew). + */ +function formatThinkingBlocks(message: ChatMessage): string[] { + if (!message.contentBlocks) return []; + + const thinkingBlocks = message.contentBlocks.filter( + (block): block is { type: 'thinking'; content: string; durationSeconds?: number } => + block.type === 'thinking' + ); + + if (thinkingBlocks.length === 0) return []; + + const totalDuration = thinkingBlocks.reduce( + (sum, block) => sum + (block.durationSeconds ?? 0), + 0 + ); + + const durationPart = totalDuration > 0 ? `, ${totalDuration.toFixed(1)}s total` : ''; + return [`[Thinking: ${thinkingBlocks.length} block(s)${durationPart}]`]; +} + +export function buildContextFromHistory(messages: ChatMessage[]): string { + const parts: string[] = []; + + for (const message of messages) { + if (message.role !== 'user' && message.role !== 'assistant') { + continue; + } + + if (message.isInterrupt) { + continue; + } + + if (message.role === 'assistant') { + const hasContent = message.content && message.content.trim().length > 0; + const hasToolCalls = message.toolCalls && message.toolCalls.length > 0; + const hasThinking = message.contentBlocks?.some(b => b.type === 'thinking'); + if (!hasContent && !hasToolCalls && !hasThinking) { + continue; + } + } + + const role = message.role === 'user' ? 'User' : 'Assistant'; + const lines: string[] = []; + const content = message.content?.trim(); + const contextLine = formatContextLine(message); + + const userPayload = contextLine + ? content + ? `${contextLine}\n\n${content}` + : contextLine + : content; + + lines.push(userPayload ? `${role}: ${userPayload}` : `${role}:`); + + if (message.role === 'assistant') { + const thinkingLines = formatThinkingBlocks(message); + if (thinkingLines.length > 0) { + lines.push(...thinkingLines); + } + } + + if (message.role === 'assistant' && message.toolCalls?.length) { + const toolLines = message.toolCalls + .map(tc => formatToolCallForContext(tc)) + .filter(Boolean); + if (toolLines.length > 0) { + lines.push(...toolLines); + } + } + + parts.push(lines.join('\n')); + } + + return parts.join('\n\n'); +} + +export function getLastUserMessage(messages: ChatMessage[]): ChatMessage | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + if (messages[i].role === 'user') { + return messages[i]; + } + } + return undefined; +} + +/** + * Builds a prompt with history context for session recovery. + * Avoids duplicating the current prompt if it's already the last user message. + */ +export function buildPromptWithHistoryContext( + historyContext: string | null, + prompt: string, + actualPrompt: string, + conversationHistory: ChatMessage[] +): string { + if (!historyContext) return prompt; + + const lastUserMessage = getLastUserMessage(conversationHistory); + + // Compare actual user queries, not XML-wrapped versions + const lastUserQuery = lastUserMessage?.displayContent + ?? extractUserQuery(lastUserMessage?.content ?? ''); + const currentUserQuery = extractUserQuery(actualPrompt); + + const shouldAppendPrompt = !lastUserMessage || + lastUserQuery.trim() !== currentUserQuery.trim(); + + return shouldAppendPrompt + ? `${historyContext}\n\nUser: ${prompt}` + : historyContext; +} diff --git a/src/utils/slashCommand.ts b/src/utils/slashCommand.ts new file mode 100644 index 0000000..a679d4c --- /dev/null +++ b/src/utils/slashCommand.ts @@ -0,0 +1,152 @@ +import type { SlashCommand } from '../core/types'; +import { + extractBoolean, + extractString, + extractStringArray, + isRecord, + parseFrontmatter, + validateSlugName, +} from './frontmatter'; + +export interface ParsedSlashCommandContent { + description?: string; + argumentHint?: string; + allowedTools?: string[]; + model?: string; + promptContent: string; + // Skill fields + disableModelInvocation?: boolean; + userInvocable?: boolean; + context?: 'fork'; + agent?: string; + hooks?: Record; +} + +export function extractFirstParagraph(content: string): string | undefined { + const paragraph = content.split(/\n\s*\n/).find(p => p.trim()); + if (!paragraph) return undefined; + return paragraph.trim().replace(/\n/g, ' '); +} + +export function validateCommandName(name: string): string | null { + return validateSlugName(name, 'Command'); +} + +export function isSkill(cmd: SlashCommand): boolean { + if (cmd.kind) return cmd.kind === 'skill'; + return cmd.id.startsWith('skill-'); +} + +export function parsedToSlashCommand( + parsed: ParsedSlashCommandContent, + identity: Pick & { source?: SlashCommand['source'] }, +): SlashCommand { + return { + ...identity, + description: parsed.description, + argumentHint: parsed.argumentHint, + allowedTools: parsed.allowedTools, + model: parsed.model, + content: parsed.promptContent, + disableModelInvocation: parsed.disableModelInvocation, + userInvocable: parsed.userInvocable, + context: parsed.context, + agent: parsed.agent, + hooks: parsed.hooks, + }; +} + +export function parseSlashCommandContent(content: string): ParsedSlashCommandContent { + const parsed = parseFrontmatter(content); + + if (!parsed) { + return { promptContent: content }; + } + + const fm = parsed.frontmatter; + + return { + // Existing fields — support both kebab-case (file format) and camelCase + description: extractString(fm, 'description'), + argumentHint: extractString(fm, 'argument-hint') ?? extractString(fm, 'argumentHint'), + allowedTools: extractStringArray(fm, 'allowed-tools') ?? extractStringArray(fm, 'allowedTools'), + model: extractString(fm, 'model'), + promptContent: parsed.body, + // Skill fields — kebab-case preferred (CC file format), camelCase for backwards compat + disableModelInvocation: + extractBoolean(fm, 'disable-model-invocation') ?? extractBoolean(fm, 'disableModelInvocation'), + userInvocable: + extractBoolean(fm, 'user-invocable') ?? extractBoolean(fm, 'userInvocable'), + context: extractString(fm, 'context') === 'fork' ? 'fork' : undefined, + agent: extractString(fm, 'agent'), + hooks: isRecord(fm.hooks) ? fm.hooks : undefined, + }; +} + +export function normalizeArgumentHint(hint: string): string { + if (!hint) return hint; + if (hint.includes('[') || hint.includes('<')) return hint; + return `[${hint}]`; +} + +export function yamlString(value: string): string { + if (value.includes(':') || value.includes('#') || value.includes('\n') || + value.startsWith(' ') || value.endsWith(' ') || + value.startsWith('[') || value.startsWith('{')) { + return `"${value.replace(/"/g, '\\"')}"`; + } + return value; +} + +export function serializeCommand(cmd: SlashCommand): string { + const parsed = parseSlashCommandContent(cmd.content); + return serializeSlashCommandMarkdown(cmd, parsed.promptContent); +} + +export function serializeSlashCommandMarkdown(cmd: Partial, body: string): string { + const lines: string[] = ['---']; + + if (cmd.name) { + lines.push(`name: ${cmd.name}`); + } + if (cmd.description) { + lines.push(`description: ${yamlString(cmd.description)}`); + } + if (cmd.argumentHint) { + lines.push(`argument-hint: ${yamlString(cmd.argumentHint)}`); + } + if (cmd.allowedTools && cmd.allowedTools.length > 0) { + lines.push('allowed-tools:'); + for (const tool of cmd.allowedTools) { + lines.push(` - ${yamlString(tool)}`); + } + } + if (cmd.model) { + lines.push(`model: ${cmd.model}`); + } + if (cmd.disableModelInvocation !== undefined) { + lines.push(`disable-model-invocation: ${cmd.disableModelInvocation}`); + } + if (cmd.userInvocable !== undefined) { + lines.push(`user-invocable: ${cmd.userInvocable}`); + } + if (cmd.context) { + lines.push(`context: ${cmd.context}`); + } + if (cmd.agent) { + lines.push(`agent: ${cmd.agent}`); + } + if (cmd.hooks !== undefined) { + lines.push(`hooks: ${JSON.stringify(cmd.hooks)}`); + } + // Ensure at least one blank line between --- markers when no metadata exists + // (the frontmatter regex requires \n before the closing ---) + if (lines.length === 1) { + lines.push(''); + } + + lines.push('---'); + lines.push(body); + + return lines.join('\n'); +} diff --git a/src/utils/subagentJsonl.ts b/src/utils/subagentJsonl.ts new file mode 100644 index 0000000..aaa4a4e --- /dev/null +++ b/src/utils/subagentJsonl.ts @@ -0,0 +1,52 @@ +/** + * Extracts the final textual result from subagent JSONL output. + * Prefers the latest assistant text block and falls back to top-level result. + */ +export function extractFinalResultFromSubagentJsonl(content: string): string | null { + const lines = content + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && line.startsWith('{')); + + let lastAssistantText: string | null = null; + let lastResultText: string | null = null; + + for (const line of lines) { + let raw: unknown; + try { + raw = JSON.parse(line); + } catch { + continue; + } + + if (!raw || typeof raw !== 'object') { + continue; + } + + const record = raw as { + result?: unknown; + message?: { role?: unknown; content?: unknown }; + }; + + if (typeof record.result === 'string' && record.result.trim().length > 0) { + lastResultText = record.result.trim(); + } + + if (record.message?.role !== 'assistant' || !Array.isArray(record.message.content)) { + continue; + } + + for (const blockRaw of record.message.content) { + if (!blockRaw || typeof blockRaw !== 'object') { + continue; + } + + const block = blockRaw as { type?: unknown; text?: unknown }; + if (block.type === 'text' && typeof block.text === 'string' && block.text.trim().length > 0) { + lastAssistantText = block.text.trim(); + } + } + } + + return lastAssistantText ?? lastResultText; +} diff --git a/src/utils/windowsCmdShim.ts b/src/utils/windowsCmdShim.ts new file mode 100644 index 0000000..d2465ea --- /dev/null +++ b/src/utils/windowsCmdShim.ts @@ -0,0 +1,98 @@ +const WINDOWS_CMD_ARGUMENT_CHARS = /[\s"&<>|{}^=;!'+,`~()%@]/u; + +export interface WindowsCmdShimSpawnSpec { + args: string[]; + command: string; + killProcessTree?: boolean; + windowsVerbatimArguments?: boolean; +} + +interface KillableProcess { + kill(signal?: NodeJS.Signals | number): boolean; + pid?: number; +} + +interface ErrorEmitterLike { + on(event: 'error', listener: (error: Error) => void): unknown; +} + +type SpawnProcess = ( + command: string, + args: string[], + options: { stdio: 'ignore'; windowsHide: true }, +) => unknown; + +export function resolveWindowsCmdShimSpawnSpec( + spec: Pick, +): WindowsCmdShimSpawnSpec { + const command = spec.command.trim(); + if (!command || process.platform !== 'win32' || !command.toLowerCase().endsWith('.cmd')) { + return { + args: spec.args, + command: spec.command, + }; + } + + const shellCommand = [command, ...spec.args] + .map(value => quoteWindowsShellArgument(value)) + .join(' '); + + return { + args: ['/d', '/s', '/c', `"${shellCommand}"`], + command: process.env.ComSpec || process.env.comspec || 'cmd.exe', + killProcessTree: true, + windowsVerbatimArguments: true, + }; +} + +export function terminateSpawnedProcess( + proc: KillableProcess, + signal: NodeJS.Signals | number | undefined, + spawnProcess: SpawnProcess, + spawnSpec?: WindowsCmdShimSpawnSpec | null, +): boolean { + if ( + process.platform !== 'win32' + || !spawnSpec?.killProcessTree + || typeof proc.pid !== 'number' + ) { + return proc.kill(signal); + } + + try { + const taskkill = spawnProcess('taskkill.exe', ['/pid', String(proc.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true, + }); + if (isErrorEmitterLike(taskkill)) { + taskkill.on('error', () => {}); + } + return true; + } catch { + return proc.kill(signal); + } +} + +function isErrorEmitterLike(value: unknown): value is ErrorEmitterLike { + return value !== null + && typeof value === 'object' + && typeof (value as { on?: unknown }).on === 'function'; +} + +function requiresWindowsShellQuoting(value: string): boolean { + return WINDOWS_CMD_ARGUMENT_CHARS.test(value) + || value.includes('[') + || value.includes(']'); +} + +function quoteWindowsShellArgument(value: string): string { + if (!value.length) { + return '""'; + } + + if (!requiresWindowsShellQuoting(value)) { + return value; + } + + return `"${value.replace(/"/g, '""')}"`; +} diff --git a/tests/__mocks__/claude-agent-sdk.ts b/tests/__mocks__/claude-agent-sdk.ts new file mode 100644 index 0000000..1b440f5 --- /dev/null +++ b/tests/__mocks__/claude-agent-sdk.ts @@ -0,0 +1,317 @@ +// Mock for @anthropic-ai/claude-agent-sdk + +export interface HookCallbackMatcher { + matcher?: string; + hooks: Array<(hookInput: any, toolUseID: string, options: any) => Promise<{ continue: boolean; hookSpecificOutput?: any }>>; +} + +export interface SpawnOptions { + command: string; + args: string[]; + cwd?: string; + env: { + [envVar: string]: string | undefined; + }; + signal: AbortSignal; +} + +export interface SpawnedProcess { + stdin: NodeJS.WritableStream; + stdout: NodeJS.ReadableStream; + stderr?: NodeJS.ReadableStream | null; + killed: boolean; + exitCode: number | null; + kill: (signal?: NodeJS.Signals) => void; + on: (event: 'exit' | 'error', listener: (...args: any[]) => void) => void; + once: (event: 'exit' | 'error', listener: (...args: any[]) => void) => void; + off: (event: 'exit' | 'error', listener: (...args: any[]) => void) => void; +} + +export interface Options { + cwd?: string; + permissionMode?: string; + allowDangerouslySkipPermissions?: boolean; + model?: string; + tools?: string[]; + allowedTools?: string[]; + disallowedTools?: string[]; + abortController?: AbortController; + pathToClaudeCodeExecutable?: string; + resume?: string; + maxThinkingTokens?: number; + thinking?: { type: string; budgetTokens?: number }; + effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; + canUseTool?: CanUseTool; + systemPrompt?: string | { content: string; cacheControl?: { type: string } }; + mcpServers?: Record; + settingSources?: ('user' | 'project' | 'local')[]; + spawnClaudeCodeProcess?: (options: SpawnOptions) => SpawnedProcess; + hooks?: { + PreToolUse?: HookCallbackMatcher[]; + PostToolUse?: HookCallbackMatcher[]; + Stop?: HookCallbackMatcher[]; + }; + agents?: Record; + persistSession?: boolean; +} + +export interface Settings { + effortLevel?: 'low' | 'medium' | 'high' | 'xhigh' | 'max'; +} + +// Type exports that match the real SDK +export type AgentDefinition = { + description: string; + tools?: string[]; + disallowedTools?: string[]; + prompt: string; + model?: 'sonnet' | 'opus' | 'haiku' | 'inherit'; + mcpServers?: unknown[]; + skills?: string[]; + maxTurns?: number; + hooks?: Record; +}; + +export type AgentMcpServerSpec = string | Record; + +export type McpServerConfig = Record; + +export type PermissionBehavior = 'allow' | 'deny' | 'ask'; + +export type PermissionRuleValue = { + toolName: string; + ruleContent?: string; +}; + +export type PermissionUpdateDestination = 'userSettings' | 'projectSettings' | 'localSettings' | 'session' | 'cliArg'; + +export type PermissionMode = 'acceptEdits' | 'auto' | 'bypassPermissions' | 'default' | 'delegate' | 'dontAsk' | 'plan'; + +export type PermissionUpdate = + | { type: 'addRules'; rules: PermissionRuleValue[]; behavior: PermissionBehavior; destination: PermissionUpdateDestination } + | { type: 'replaceRules'; rules: PermissionRuleValue[]; behavior: PermissionBehavior; destination: PermissionUpdateDestination } + | { type: 'removeRules'; rules: PermissionRuleValue[]; behavior: PermissionBehavior; destination: PermissionUpdateDestination } + | { type: 'setMode'; mode: PermissionMode; destination: PermissionUpdateDestination } + | { type: 'addDirectories'; directories: string[]; destination: PermissionUpdateDestination } + | { type: 'removeDirectories'; directories: string[]; destination: PermissionUpdateDestination }; + +export type CanUseTool = (toolName: string, input: Record, options: { + signal: AbortSignal; + suggestions?: PermissionUpdate[]; + blockedPath?: string; + decisionReason?: string; + toolUseID: string; + agentID?: string; +}) => Promise; + +export type PermissionResult = + | { behavior: 'allow'; updatedInput?: Record; updatedPermissions?: PermissionUpdate[]; toolUseID?: string } + | { behavior: 'deny'; message: string; interrupt?: boolean; toolUseID?: string }; + +// Default mock messages for testing +const mockMessages = [ + { type: 'system', subtype: 'init', session_id: 'test-session-123' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello, I am Claude!' }] } }, + { type: 'result', subtype: 'success', result: 'completed' }, +]; + +let customMockMessages: any[] | null = null; +let appendResultMessage = true; +let lastOptions: Options | undefined; +let mockSupportedCommands: Array<{ name: string; description: string; argumentHint?: string }> = []; +let lastResponse: (AsyncGenerator & { + interrupt: jest.Mock; + setModel: jest.Mock; + setMaxThinkingTokens: jest.Mock; + setPermissionMode: jest.Mock; + applyFlagSettings: jest.Mock; + setMcpServers: jest.Mock; + supportedCommands: jest.Mock; +}) | null = null; + +// Crash simulation control +let shouldThrowOnIteration = false; +let throwAfterChunks = 0; +let queryCallCount = 0; + +// Allow tests to set custom mock messages +export function setMockMessages(messages: any[], options?: { appendResult?: boolean }) { + customMockMessages = messages; + appendResultMessage = options?.appendResult ?? true; +} + +export function resetMockMessages() { + customMockMessages = null; + appendResultMessage = true; + lastOptions = undefined; + mockSupportedCommands = []; + lastResponse = null; + shouldThrowOnIteration = false; + throwAfterChunks = 0; + queryCallCount = 0; +} + +export function setMockSupportedCommands( + commands: Array<{ name: string; description: string; argumentHint?: string }> +) { + mockSupportedCommands = commands; +} + +/** + * Configure the mock to throw an error during iteration. + * @param afterChunks - Number of chunks to emit before throwing (0 = throw immediately) + */ +export function simulateCrash(afterChunks = 0) { + shouldThrowOnIteration = true; + throwAfterChunks = afterChunks; +} + +/** + * Get the number of times query() was called (useful for verifying restart behavior). + */ +export function getQueryCallCount(): number { + return queryCallCount; +} + +export function getLastOptions(): Options | undefined { + return lastOptions; +} + +export function getLastResponse(): typeof lastResponse { + return lastResponse; +} + +// Helper to run PreToolUse hooks +async function runPreToolUseHooks( + hooks: HookCallbackMatcher[] | undefined, + toolName: string, + toolInput: Record, + toolId: string +): Promise<{ blocked: boolean; reason?: string }> { + if (!hooks) return { blocked: false }; + + for (const hookMatcher of hooks) { + // Check if matcher matches the tool (no matcher = match all) + if (hookMatcher.matcher && hookMatcher.matcher !== toolName) { + continue; + } + + for (const hookFn of hookMatcher.hooks) { + const hookInput = { tool_name: toolName, tool_input: toolInput }; + const result = await hookFn(hookInput, toolId, {}); + + if (!result.continue) { + const reason = result.hookSpecificOutput?.permissionDecisionReason || 'Blocked by hook'; + return { blocked: true, reason }; + } + } + } + + return { blocked: false }; +} + +// Mock query function that returns an async generator +function isAsyncIterable(value: any): value is AsyncIterable { + return !!value && typeof value[Symbol.asyncIterator] === 'function'; +} + +function getMessagesForPrompt(): any[] { + const baseMessages = customMockMessages || mockMessages; + const messages = [...baseMessages]; + if (appendResultMessage && !messages.some((msg) => msg.type === 'result')) { + messages.push({ type: 'result', subtype: 'success' }); + } + return messages; +} + +async function* emitMessages(messages: any[], options: Options) { + let chunksEmitted = 0; + + for (const msg of messages) { + // Check if we should throw (crash simulation) + if (shouldThrowOnIteration && chunksEmitted >= throwAfterChunks) { + // Reset for next query (allows recovery to work) + shouldThrowOnIteration = false; + throw new Error('Simulated consumer crash'); + } + + // Check for tool_use in assistant messages and run hooks + if (msg.type === 'assistant' && msg.message?.content) { + let wasBlocked = false; + for (const block of msg.message.content) { + if (block.type === 'tool_use') { + const hookResult = await runPreToolUseHooks( + options.hooks?.PreToolUse, + block.name, + block.input, + block.id || `tool-${Date.now()}` + ); + + if (hookResult.blocked) { + // Yield the assistant message first (with tool_use) + yield msg; + chunksEmitted++; + // Then yield a blocked indicator as a user message with error + yield { + type: 'user', + parent_tool_use_id: block.id, + tool_use_result: `BLOCKED: ${hookResult.reason}`, + message: { content: [] }, + _blocked: true, + _blockReason: hookResult.reason, + }; + chunksEmitted++; + wasBlocked = true; + break; // Exit inner loop since we already handled this message + } + } + } + // If the message was blocked, don't yield it again + if (wasBlocked) { + continue; + } + } + yield msg; + chunksEmitted++; + } +} + +export function query({ prompt, options }: { prompt: any; options: Options }): AsyncGenerator & { interrupt: () => Promise } { + lastOptions = options; + queryCallCount++; + + const generator = async function* () { + if (isAsyncIterable(prompt)) { + for await (const _ of prompt) { + void _; // Consume async iterable input + const messages = getMessagesForPrompt(); + yield* emitMessages(messages, options); + } + return; + } + + const messages = getMessagesForPrompt(); + yield* emitMessages(messages, options); + }; + + const gen = generator() as AsyncGenerator & { + interrupt: jest.Mock; + setModel: jest.Mock; + setMaxThinkingTokens: jest.Mock; + setPermissionMode: jest.Mock; + applyFlagSettings: jest.Mock; + setMcpServers: jest.Mock; + supportedCommands: jest.Mock; + }; + gen.interrupt = jest.fn().mockResolvedValue(undefined); + // Dynamic update methods for persistent queries + gen.setModel = jest.fn().mockResolvedValue(undefined); + gen.setMaxThinkingTokens = jest.fn().mockResolvedValue(undefined); + gen.setPermissionMode = jest.fn().mockResolvedValue(undefined); + gen.applyFlagSettings = jest.fn().mockResolvedValue(undefined); + gen.setMcpServers = jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }); + gen.supportedCommands = jest.fn().mockResolvedValue(mockSupportedCommands); + lastResponse = gen; + + return gen; +} diff --git a/tests/__mocks__/codex-sdk.ts b/tests/__mocks__/codex-sdk.ts new file mode 100644 index 0000000..175887c --- /dev/null +++ b/tests/__mocks__/codex-sdk.ts @@ -0,0 +1,88 @@ +/** + * Jest mock for @openai/codex-sdk. + * + * The unit tests need a configurable SDK surface because Jest maps every + * `@openai/codex-sdk` import to this file. Export the mock fns so tests can + * control thread creation and streamed events explicitly. + */ + +export const mockRunStreamed = jest.fn(); +export const mockRun = jest.fn(); +export const mockStartThread = jest.fn(); +export const mockResumeThread = jest.fn(); +export const mockCodexConstructor = jest.fn(); + +function defaultStreamedResult() { + return { + events: (async function* () { + yield { type: 'thread.started', thread_id: 'mock-thread-id' }; + yield { type: 'turn.started' }; + yield { type: 'turn.completed', usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 } }; + })(), + }; +} + +export function resetCodexSdkMocks(): void { + mockCodexConstructor.mockReset(); + mockRunStreamed.mockReset(); + mockRun.mockReset(); + mockStartThread.mockReset(); + mockResumeThread.mockReset(); + + mockRunStreamed.mockResolvedValue(defaultStreamedResult()); + mockRun.mockResolvedValue({ + items: [], + finalResponse: '', + usage: { input_tokens: 0, cached_input_tokens: 0, output_tokens: 0 }, + }); + mockStartThread.mockImplementation((_options?: any) => new Thread()); + mockResumeThread.mockImplementation((_id: string, _options?: any) => new Thread()); +} + +export class Codex { + constructor(options?: any) { + mockCodexConstructor(options); + } + + startThread(options?: any) { + return mockStartThread(options); + } + + resumeThread(id: string, options?: any) { + return mockResumeThread(id, options); + } +} + +export class Thread { + private _id: string | null = 'mock-thread-id'; + + get id() { + return this._id; + } + + async runStreamed(input: any, turnOptions?: any) { + return mockRunStreamed(input, turnOptions); + } + + async run(input: any, turnOptions?: any) { + return mockRun(input, turnOptions); + } +} + +resetCodexSdkMocks(); + +// Type stubs (values don't matter for type-only imports) +export type ThreadEvent = any; +export type ThreadOptions = any; +export type ModelReasoningEffort = string; +export type ThreadItem = any; +export type Usage = any; +export type Input = any; +export type UserInput = any; +export type TurnOptions = any; +export type StreamedTurn = any; +export type Turn = any; +export type CodexOptions = any; +export type SandboxMode = string; +export type ApprovalMode = string; +export type WebSearchMode = string; diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts new file mode 100644 index 0000000..405d845 --- /dev/null +++ b/tests/__mocks__/obsidian.ts @@ -0,0 +1,434 @@ +// Mock for Obsidian API + +export class Plugin { + app: any; + manifest: any; + + constructor(app?: any, manifest?: any) { + this.app = app; + this.manifest = manifest; + } + + addRibbonIcon = jest.fn(); + addCommand = jest.fn(); + addSettingTab = jest.fn(); + registerView = jest.fn(); + loadData = jest.fn().mockResolvedValue({}); + saveData = jest.fn().mockResolvedValue(undefined); +} + +export class PluginSettingTab { + app: any; + plugin: any; + containerEl: any = { + empty: jest.fn(), + createEl: jest.fn().mockReturnValue({ createEl: jest.fn(), createDiv: jest.fn() }), + createDiv: jest.fn().mockReturnValue({ createEl: jest.fn(), createDiv: jest.fn() }), + }; + + constructor(app: any, plugin: any) { + this.app = app; + this.plugin = plugin; + } + + display() {} +} + +export class ItemView { + app: any; + leaf: any; + containerEl: any = { + children: [{}, { empty: jest.fn(), addClass: jest.fn(), createDiv: jest.fn().mockReturnValue({ + createEl: jest.fn().mockReturnValue({ addEventListener: jest.fn(), setAttribute: jest.fn() }), + createDiv: jest.fn().mockReturnValue({ createEl: jest.fn().mockReturnValue({ addEventListener: jest.fn() }) }), + }) }], + }; + + constructor(leaf: any) { + this.leaf = leaf; + } + + getViewType(): string { + return ''; + } + + getDisplayText(): string { + return ''; + } + + getIcon(): string { + return ''; + } +} + +export class WorkspaceLeaf {} + +export class Scope { + static instances: Scope[] = []; + + parent?: Scope; + handlers: Array<{ + modifiers: string[] | null; + key: string | null; + func: (evt: KeyboardEvent, ctx?: unknown) => false | unknown; + }> = []; + + constructor(parent?: Scope) { + this.parent = parent; + Scope.instances.push(this); + } + + register = jest.fn(( + modifiers: string[] | null, + key: string | null, + func: (evt: KeyboardEvent, ctx?: unknown) => false | unknown + ) => { + const handler = { modifiers, key, func }; + this.handlers.push(handler); + return handler; + }); + + unregister = jest.fn((handler: unknown) => { + this.handlers = this.handlers.filter((entry) => entry !== handler); + }); +} + +export const Platform = { + isMacOS: true, +}; + +export class App { + vault: any = { + adapter: { + basePath: '/mock/vault/path', + }, + }; + workspace: any = { + getLeavesOfType: jest.fn().mockReturnValue([]), + getRightLeaf: jest.fn().mockReturnValue({ + setViewState: jest.fn().mockResolvedValue(undefined), + }), + getLeftLeaf: jest.fn().mockReturnValue({ + setViewState: jest.fn().mockResolvedValue(undefined), + }), + getLeaf: jest.fn().mockReturnValue({ + setViewState: jest.fn().mockResolvedValue(undefined), + }), + setActiveLeaf: jest.fn(), + revealLeaf: jest.fn(), + }; +} + +export class MarkdownView { + editor: any; + file?: any; + + constructor(editor?: any, file?: any) { + this.editor = editor; + this.file = file; + } +} + +export class Setting { + constructor(containerEl: any) {} + setName = jest.fn().mockReturnThis(); + setDesc = jest.fn().mockReturnThis(); + addToggle = jest.fn().mockReturnThis(); + addTextArea = jest.fn().mockReturnThis(); +} + +export class TextAreaComponent { + inputEl: any; + private _value = ''; + + constructor(_container?: any) { + this.inputEl = { + addClass: jest.fn(), + rows: 0, + placeholder: '', + focus: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + } + + setValue(value: string): this { + this._value = value; + return this; + } + + getValue(): string { + return this._value; + } +} + +export class Modal { + app: any; + containerEl: any = { + createDiv: jest.fn().mockReturnValue({ + createEl: jest.fn().mockReturnValue({ addEventListener: jest.fn() }), + createDiv: jest.fn().mockReturnValue({ + createEl: jest.fn().mockReturnValue({ addEventListener: jest.fn() }), + createDiv: jest.fn().mockReturnValue({ + createEl: jest.fn(), + }), + setText: jest.fn(), + }), + addClass: jest.fn(), + setText: jest.fn(), + }), + empty: jest.fn(), + addClass: jest.fn(), + }; + contentEl: any = { + createDiv: jest.fn().mockReturnValue({ + createEl: jest.fn().mockReturnValue({ addEventListener: jest.fn() }), + createDiv: jest.fn().mockReturnValue({ + createEl: jest.fn().mockReturnValue({ addEventListener: jest.fn() }), + createDiv: jest.fn().mockReturnValue({ + createEl: jest.fn(), + }), + setText: jest.fn(), + }), + addClass: jest.fn(), + setText: jest.fn(), + }), + empty: jest.fn(), + addClass: jest.fn(), + }; + + constructor(app: any) { + this.app = app; + } + + open = jest.fn(); + close = jest.fn(); + onOpen = jest.fn(); + onClose = jest.fn(); +} + +class MockMenuItem { + title = ''; + icon = ''; + disabled = false; + clickHandler: (() => void) | null = null; + + setTitle = jest.fn((title: string) => { + this.title = title; + return this; + }); + + setIcon = jest.fn((icon: string) => { + this.icon = icon; + return this; + }); + + setDisabled = jest.fn((disabled: boolean) => { + this.disabled = disabled; + return this; + }); + + onClick = jest.fn((handler: () => void) => { + this.clickHandler = handler; + return this; + }); +} + +export class Menu { + static instances: Menu[] = []; + + items: MockMenuItem[] = []; + showAtMouseEvent = jest.fn(); + + constructor() { + Menu.instances.push(this); + } + + addItem(callback: (item: MockMenuItem) => MockMenuItem | void): this { + const item = new MockMenuItem(); + callback(item); + this.items.push(item); + return this; + } +} + +const renderMarkdownMock = jest.fn, [string, unknown, string, unknown]>().mockResolvedValue(undefined); + +export const MarkdownRenderer = { + render: jest.fn, [unknown, string, unknown, string, unknown]>( + (_app, markdown, el, sourcePath, component) => renderMarkdownMock(markdown, el, sourcePath, component), + ), + renderMarkdown: renderMarkdownMock, +}; + +export const setIcon = jest.fn(); + +// Notice mock that tracks constructor calls +export const Notice = jest.fn().mockImplementation((_message: string, _timeout?: number) => {}); + +function unquoteYaml(value: string): string { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; +} + +function parseYamlValue(rawValue: string): unknown { + if (!rawValue) return null; + + if (rawValue.startsWith('{') && rawValue.endsWith('}')) { + try { return JSON.parse(rawValue); } catch { /* fall through */ } + } + + if (rawValue.startsWith('[') && rawValue.endsWith(']')) { + return rawValue.slice(1, -1).split(',').map(item => unquoteYaml(item.trim())).filter(Boolean); + } + + if (rawValue === 'true' || rawValue === 'false') { + return rawValue === 'true'; + } + + const numberValue = Number(rawValue); + if (!Number.isNaN(numberValue) && rawValue !== '') { + return numberValue; + } + + return unquoteYaml(rawValue); +} + +export function parseYaml(content: string): Record { + const result: Record = {}; + const lines = content.split(/\r?\n/); + let currentArrayKey: string | null = null; + let currentArray: string[] = []; + let blockScalarKey: string | null = null; + let blockScalarStyle: 'literal' | 'folded' | null = null; + let blockScalarLines: string[] = []; + let blockScalarIndent: number | null = null; + + const flushArray = () => { + if (currentArrayKey) { + result[currentArrayKey] = currentArray; + currentArrayKey = null; + currentArray = []; + } + }; + + const flushBlockScalar = () => { + if (!blockScalarKey) return; + let value: string; + if (blockScalarStyle === 'literal') { + value = blockScalarLines.join('\n'); + } else { + value = blockScalarLines.join('\n').replace(/(?= blockScalarIndent) { + blockScalarLines.push(line.slice(blockScalarIndent)); + continue; + } else { + flushBlockScalar(); + // fall through + } + } + + // Handle YAML list items (- value) + if (currentArrayKey && trimmed.startsWith('- ')) { + currentArray.push(unquoteYaml(trimmed.slice(2).trim())); + continue; + } + + // Not a list item — flush any pending array + if (currentArrayKey && trimmed !== '') { + flushArray(); + } + + if (!trimmed) continue; + + const match = trimmed.match(/^([^:]+):\s*(.*)$/); + if (!match) continue; + + const key = match[1].trim(); + const rawValue = match[2].trim(); + if (!key) continue; + + // Check for block scalar indicator (| or >) with optional chomping + const blockMatch = rawValue.match(/^([|>])([+-])?$/); + if (blockMatch) { + blockScalarKey = key; + blockScalarStyle = blockMatch[1] === '|' ? 'literal' : 'folded'; + blockScalarLines = []; + blockScalarIndent = null; + continue; + } + + if (!rawValue) { + // Could be start of a YAML list or a null value — peek ahead + currentArrayKey = key; + currentArray = []; + continue; + } + + result[key] = parseYamlValue(rawValue); + } + + if (blockScalarKey) flushBlockScalar(); + flushArray(); + + return result; +} + +// TFile class for instanceof checks +export class TFile { + path: string; + name: string; + basename: string; + extension: string; + + constructor(path: string = '') { + this.path = path; + this.name = path.split('/').pop() || ''; + this.basename = this.name.replace(/\.[^.]+$/, ''); + this.extension = this.name.split('.').pop() || ''; + } +} + +export class TFolder { + path: string; + name: string; + children: any[] = []; + + constructor(path: string = '') { + this.path = path; + this.name = path.split('/').pop() || ''; + } +} diff --git a/tests/fixtures/provider-protocol-child.mjs b/tests/fixtures/provider-protocol-child.mjs new file mode 100644 index 0000000..99daba1 --- /dev/null +++ b/tests/fixtures/provider-protocol-child.mjs @@ -0,0 +1,41 @@ +import * as readline from 'node:readline'; + +const mode = process.argv[2]; +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +for await (const line of rl) { + let message; + try { + message = JSON.parse(line); + } catch { + continue; + } + + const command = mode === 'pi' ? message.type : message.method; + if (command === 'fixture/exit' || command === 'fixture_exit') { + process.stderr.write('fixture requested process exit\n'); + process.exit(17); + } + if (command === 'fixture/hang' || command === 'fixture_hang') { + continue; + } + if (command === 'fixture/primitive') { + process.stdout.write('null\n42\n"ignored"\n'); + } + + if (mode === 'pi') { + process.stdout.write(`${JSON.stringify({ + id: message.id, + result: { command: message.type, payload: message.payload ?? null }, + success: true, + type: 'response', + })}\n`); + continue; + } + + process.stdout.write(`${JSON.stringify({ + id: message.id, + jsonrpc: '2.0', + result: { method: message.method, params: message.params ?? null }, + })}\n`); +} diff --git a/tests/helpers/codexModels.ts b/tests/helpers/codexModels.ts new file mode 100644 index 0000000..37911f4 --- /dev/null +++ b/tests/helpers/codexModels.ts @@ -0,0 +1,34 @@ +export { CODEX_SPARK_MODEL } from '@/providers/codex/types/models'; + +export const TEST_CODEX_MODEL = 'gpt-5.5'; +export const TEST_CODEX_MODEL_LABEL = 'GPT-5.5'; + +export const TEST_CODEX_CATALOG = [ + { + model: TEST_CODEX_MODEL, + displayName: TEST_CODEX_MODEL_LABEL, + description: 'Test default Codex model', + supportedReasoningEfforts: [ + { value: 'low', description: 'Fast' }, + { value: 'medium', description: 'Balanced' }, + { value: 'high', description: 'Deep' }, + { value: 'xhigh', description: 'Extra deep' }, + ], + defaultReasoningEffort: 'medium', + serviceTiers: [{ id: 'priority', name: 'Fast', description: '1.5x speed' }], + defaultServiceTier: null, + inputModalities: ['text', 'image'], + isDefault: true, + }, + { + model: 'gpt-5.4-mini', + displayName: 'GPT-5.4 Mini', + description: 'Test smaller Codex model', + supportedReasoningEfforts: [{ value: 'medium', description: 'Balanced' }], + defaultReasoningEffort: 'medium', + serviceTiers: [], + defaultServiceTier: null, + inputModalities: ['text', 'image'], + isDefault: false, + }, +]; diff --git a/tests/helpers/mockElement.ts b/tests/helpers/mockElement.ts new file mode 100644 index 0000000..56edfa8 --- /dev/null +++ b/tests/helpers/mockElement.ts @@ -0,0 +1,398 @@ +export interface MockElement { + tagName: string; + children: MockElement[]; + style: Record; + dataset: Record; + scrollTop: number; + scrollHeight: number; + innerHTML: string; + textContent: string; + className: string; + classList: { + add: (cls: string) => void; + remove: (cls: string) => void; + contains: (cls: string) => boolean; + toggle: (cls: string, force?: boolean) => boolean; + }; + addClass: (cls: string) => MockElement; + removeClass: (cls: string) => MockElement; + hasClass: (cls: string) => boolean; + getClasses: () => string[]; + createDiv: (opts?: { cls?: string; text?: string }) => MockElement; + createSpan: (opts?: { cls?: string; text?: string }) => MockElement; + createEl: (tag: string, opts?: { cls?: string; text?: string; attr?: Record }) => MockElement; + appendChild: (child: any) => any; + insertBefore: (el: MockElement, ref: MockElement | null) => void; + firstChild: MockElement | null; + remove: () => void; + empty: () => void; + contains: (node: any) => boolean; + scrollIntoView: () => void; + setAttribute: (name: string, value: string) => void; + getAttribute: (name: string) => string | undefined | null; + removeAttribute: (name: string) => void; + addEventListener: (event: string, handler: (...args: any[]) => void) => void; + removeEventListener: (event: string, handler: (...args: any[]) => void) => void; + dispatchEvent: (eventOrType: string | { type: string; [key: string]: any }, extraArg?: any) => void; + click: () => void; + getEventListenerCount: (event: string) => number; + querySelector: (selector: string) => MockElement | null; + querySelectorAll: (selector: string) => MockElement[]; + getBoundingClientRect: () => { top: number; left: number; width: number; height: number; right: number; bottom: number; x: number; y: number; toJSON: () => void }; + setText: (text: string) => void; + appendText: (text: string) => void; + setCssProps: (props: Record) => void; + ownerDocument: { + defaultView: { + requestAnimationFrame: (callback: FrameRequestCallback) => number; + cancelAnimationFrame: (handle: number) => void; + setTimeout: (callback: () => void, timeout: number) => number; + clearTimeout: (handle: number) => void; + setInterval: (callback: () => void, timeout: number) => number; + clearInterval: (handle: number) => void; + }; + activeElement?: any; + body?: any; + addEventListener?: (event: string, handler: (...args: any[]) => void) => void; + removeEventListener?: (event: string, handler: (...args: any[]) => void) => void; + createElement: (tagName: string) => MockElement; + createElementNS: (namespace: string, tagName: string) => MockElement; + getSelection?: () => Selection | null; + }; + _classes: Set; + _classList: Set; + _attributes: Map; + _eventListeners: Map void>>; + _children: MockElement[]; + [key: string]: any; +} + +const CLASS_DISPLAY: Record = { + 'claudian-context-meter': 'flex', + 'claudian-mcp-selector': 'flex', + 'claudian-mode-selector': 'flex', + 'claudian-permission-toggle': 'flex', + 'claudian-service-tier-toggle': 'flex', + 'claudian-status-panel-bash': 'block', + 'claudian-status-panel-bash-content': 'block', + 'claudian-status-panel-bash-entry-content': 'block', + 'claudian-status-panel-content': 'block', + 'claudian-status-panel-todos': 'block', + 'claudian-tab-content': 'flex', + 'claudian-thinking-budget': 'flex', + 'claudian-thinking-effort': 'flex', +}; + +const DISPLAY_CLASSES = new Set([ + 'claudian-hidden', + 'claudian-visible-block', + 'claudian-visible-flex', + ...Object.keys(CLASS_DISPLAY), +]); + +export function createMockEl(tag = 'div'): any { + const children: MockElement[] = []; + const classes = new Set(); + const attributes = new Map(); + const eventListeners = new Map void>>(); + const dataset: Record = {}; + const style: Record = {}; + let textContent = ''; + + const resolveDisplay = (): string | null => { + if (classes.has('claudian-hidden')) return 'none'; + if (classes.has('claudian-visible-flex')) return 'flex'; + if (classes.has('claudian-visible-block')) return 'block'; + + for (const [cls, display] of Object.entries(CLASS_DISPLAY)) { + if (classes.has(cls)) return display; + } + + return null; + }; + + const syncDisplay = () => { + const display = resolveDisplay(); + if (display === null) { + style.display = ''; + return; + } + style.display = display; + }; + + const updateClass = (cls: string, enabled: boolean) => { + if (enabled) { + classes.add(cls); + } else { + classes.delete(cls); + } + if (DISPLAY_CLASSES.has(cls)) { + syncDisplay(); + } + }; + + const defaultView = { + requestAnimationFrame: (callback: FrameRequestCallback): number => { + const requestFrame = + (globalThis as { window?: Window }).window?.requestAnimationFrame + ?? (globalThis as { requestAnimationFrame?: typeof requestAnimationFrame }).requestAnimationFrame; + if (typeof requestFrame === 'function') { + return requestFrame(callback); + } + return globalThis.setTimeout(() => callback(performance.now()), 16) as unknown as number; + }, + cancelAnimationFrame: (handle: number): void => { + const cancelFrame = + (globalThis as { window?: Window }).window?.cancelAnimationFrame + ?? (globalThis as { cancelAnimationFrame?: typeof cancelAnimationFrame }).cancelAnimationFrame; + if (typeof cancelFrame === 'function') { + cancelFrame(handle); + return; + } + globalThis.clearTimeout(handle as unknown as ReturnType); + }, + setTimeout: (callback: () => void, timeout: number): number => + globalThis.setTimeout(callback, timeout) as unknown as number, + clearTimeout: (handle: number): void => { + globalThis.clearTimeout(handle as unknown as ReturnType); + }, + setInterval: (callback: () => void, timeout: number): number => + globalThis.setInterval(callback, timeout) as unknown as number, + clearInterval: (handle: number): void => { + globalThis.clearInterval(handle as unknown as ReturnType); + }, + }; + + const currentDocument = (): any => (globalThis as any).document; + const ownerDocument = { + defaultView, + get activeElement() { + return currentDocument()?.activeElement ?? null; + }, + get body() { + return currentDocument()?.body; + }, + addEventListener(event: string, handler: (...args: any[]) => void) { + currentDocument()?.addEventListener?.(event, handler); + }, + removeEventListener(event: string, handler: (...args: any[]) => void) { + currentDocument()?.removeEventListener?.(event, handler); + }, + createElement: (tagName: string) => currentDocument()?.createElement?.(tagName) ?? createMockEl(tagName), + createElementNS: (namespace: string, tagName: string) => + currentDocument()?.createElementNS?.(namespace, tagName) ?? createMockEl(tagName), + getSelection: () => currentDocument()?.getSelection?.() ?? null, + }; + + const element: MockElement = { + tagName: tag.toUpperCase(), + children, + style, + dataset, + scrollTop: 0, + scrollHeight: 0, + innerHTML: '', + + get textContent() { + return textContent; + }, + set textContent(value: string) { + textContent = value; + }, + + get className() { + return Array.from(classes).join(' '); + }, + set className(value: string) { + classes.clear(); + if (value) { + value.split(' ').filter(Boolean).forEach(c => classes.add(c)); + } + syncDisplay(); + }, + + classList: { + add: (cls: string) => updateClass(cls, true), + remove: (cls: string) => updateClass(cls, false), + contains: (cls: string) => classes.has(cls), + toggle: (cls: string, force?: boolean) => { + if (force === undefined) { + if (classes.has(cls)) { + updateClass(cls, false); + return false; + } + updateClass(cls, true); + return true; + } + updateClass(cls, force); + return force; + }, + }, + + addClass(cls: string) { + cls.split(/\s+/).filter(Boolean).forEach(c => updateClass(c, true)); + return element; + }, + removeClass(cls: string) { + cls.split(/\s+/).filter(Boolean).forEach(c => updateClass(c, false)); + return element; + }, + hasClass: (cls: string) => classes.has(cls), + getClasses: () => Array.from(classes), + + createDiv(opts?: { cls?: string; text?: string }) { + const child = createMockEl('div'); + if (opts?.cls) child.addClass(opts.cls); + if (opts?.text) child.textContent = opts.text; + children.push(child); + return child; + }, + createSpan(opts?: { cls?: string; text?: string }) { + const child = createMockEl('span'); + if (opts?.cls) child.addClass(opts.cls); + if (opts?.text) child.textContent = opts.text; + children.push(child); + return child; + }, + createEl(tagName: string, opts?: { cls?: string; text?: string; attr?: Record }) { + const child = createMockEl(tagName); + if (opts?.cls) child.addClass(opts.cls); + if (opts?.text) child.textContent = opts.text; + if (opts?.attr) { + for (const [name, value] of Object.entries(opts.attr)) { + child.setAttribute(name, value); + } + } + children.push(child); + return child; + }, + + appendChild(child: any) { children.push(child); return child; }, + insertBefore(el: MockElement, _ref: MockElement | null) { children.unshift(el); }, + get firstChild() { return children[0] || null; }, + remove() {}, + empty() { + children.length = 0; + element.innerHTML = ''; + textContent = ''; + }, + contains(node: any) { + if (node === element) return true; + return children.some(child => (child as any).contains?.(node)); + }, + scrollIntoView() {}, + focus() { + const handlers = eventListeners.get('focus') || []; + handlers.forEach(h => h({ type: 'focus', target: element })); + }, + blur() { + const handlers = eventListeners.get('blur') || []; + handlers.forEach(h => h({ type: 'blur', target: element })); + }, + select() {}, + + setAttribute(name: string, value: string) { + if (name === 'class') { + element.className = value; + } else { + attributes.set(name, value); + } + if (name.startsWith('data-')) { + dataset[name.slice(5).replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())] = value; + } + }, + getAttribute(name: string) { + if (name === 'class') return element.className; + return attributes.get(name) ?? null; + }, + removeAttribute(name: string) { + if (name === 'class') { + element.className = ''; + } else { + attributes.delete(name); + } + if (name.startsWith('data-')) { + delete dataset[name.slice(5).replace(/-([a-z])/g, (_, char: string) => char.toUpperCase())]; + } + }, + + addEventListener(event: string, handler: (...args: any[]) => void) { + if (!eventListeners.has(event)) eventListeners.set(event, []); + eventListeners.get(event)!.push(handler); + }, + removeEventListener(event: string, handler: (...args: any[]) => void) { + const handlers = eventListeners.get(event); + if (handlers) { + const idx = handlers.indexOf(handler); + if (idx !== -1) handlers.splice(idx, 1); + } + }, + dispatchEvent(eventOrType: string | { type: string; [key: string]: any }, extraArg?: any) { + if (typeof eventOrType === 'string') { + const handlers = eventListeners.get(eventOrType) || []; + handlers.forEach(h => h(extraArg)); + } else { + const handlers = eventListeners.get(eventOrType.type) || []; + handlers.forEach(h => h(eventOrType)); + } + }, + click() { + const handlers = eventListeners.get('click') || []; + handlers.forEach(h => h({ type: 'click', target: element, stopPropagation: () => {} })); + }, + getEventListenerCount(event: string) { + return eventListeners.get(event)?.length ?? 0; + }, + + querySelector(selector: string) { + const cls = selector.replace('.', ''); + const find = (el: any): MockElement | null => { + if (el.hasClass?.(cls)) return el; + for (const child of el.children || []) { + const found = find(child); + if (found) return found; + } + return null; + }; + return find(element); + }, + querySelectorAll(selector: string) { + const cls = selector.replace('.', ''); + const results: MockElement[] = []; + const collect = (el: any) => { + if (el.hasClass?.(cls)) results.push(el); + for (const child of el.children || []) collect(child); + }; + for (const child of children) collect(child); + return results; + }, + + getBoundingClientRect() { + return { top: 0, left: 0, width: 0, height: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {} }; + }, + + setText(text: string) { textContent = text; }, + appendText(text: string) { textContent += text; }, + setCssProps(props: Record) { + for (const [name, value] of Object.entries(props)) { + style[name] = value; + } + }, + setAttr(name: string, value: string) { element.setAttribute(name, value); }, + toggleClass(cls: string, force: boolean) { + updateClass(cls, force); + }, + value: '', + closest() { return { clientHeight: 600 }; }, + getEventListeners() { return eventListeners; }, + ownerDocument, + + _classes: classes, + _classList: classes, + _attributes: attributes, + _eventListeners: eventListeners, + _children: children, + }; + + return element; +} diff --git a/tests/helpers/sdkMessages.ts b/tests/helpers/sdkMessages.ts new file mode 100644 index 0000000..551f9eb --- /dev/null +++ b/tests/helpers/sdkMessages.ts @@ -0,0 +1,291 @@ +import type { + SDKAssistantMessage, + SDKAuthStatusMessage, + SDKCompactBoundaryMessage, + SDKMessage, + SDKPartialAssistantMessage, + SDKResultError, + SDKResultSuccess, + SDKStatusMessage, + SDKSystemMessage, + SDKToolProgressMessage, + SDKUserMessage, +} from '@anthropic-ai/claude-agent-sdk'; + +const TEST_UUID = '00000000-0000-4000-8000-000000000001'; +const TEST_SESSION_ID = 'test-session'; + +const DEFAULT_RESULT_USAGE = ({ + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, +} as unknown) as SDKResultSuccess['usage']; + +const DEFAULT_MODEL_USAGE: SDKResultSuccess['modelUsage'] = { + 'claude-sonnet-test': { + inputTokens: 0, + outputTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0, + contextWindow: 200000, + maxOutputTokens: 8192, + }, +}; + +export type SystemInitMessageInput = { + type: 'system'; + subtype: 'init'; +} & Partial>; + +export type SystemStatusMessageInput = { + type: 'system'; + subtype: 'status'; +} & Partial>; + +export type CompactBoundaryMessageInput = { + type: 'system'; + subtype: 'compact_boundary'; +} & Partial>; + +export type AssistantMessageInput = { + type: 'assistant'; +} & Record; + +export type UserMessageInput = { + type: 'user'; + _blocked?: boolean; + _blockReason?: string; +} & Record; + +export type StreamEventMessageInput = { + type: 'stream_event'; +} & Record; + +export type ResultSuccessMessageInput = { + type: 'result'; + subtype?: 'success'; +} & Record; + +export type ResultErrorMessageInput = { + type: 'result'; + subtype: SDKResultError['subtype']; +} & Record; + +export type ToolProgressMessageInput = { + type: 'tool_progress'; +} & Partial>; + +export type AuthStatusMessageInput = { + type: 'auth_status'; +} & Partial>; + +export type SDKTestMessageInput = + | SystemInitMessageInput + | SystemStatusMessageInput + | CompactBoundaryMessageInput + | AssistantMessageInput + | UserMessageInput + | StreamEventMessageInput + | ResultSuccessMessageInput + | ResultErrorMessageInput + | ToolProgressMessageInput + | AuthStatusMessageInput; + +export function buildSystemInitMessage(overrides: Partial> = {}): SDKSystemMessage { + return { + type: 'system', + subtype: 'init', + apiKeySource: 'user', + claude_code_version: 'test-version', + cwd: '/test/cwd', + tools: [], + mcp_servers: [], + model: 'claude-sonnet-4-5', + permissionMode: 'default', + slash_commands: [], + output_style: 'default', + skills: [], + plugins: [], + uuid: TEST_UUID, + session_id: TEST_SESSION_ID, + ...overrides, + }; +} + +export function buildSystemStatusMessage(overrides: Partial> = {}): SDKStatusMessage { + return { + type: 'system', + subtype: 'status', + status: null, + uuid: TEST_UUID, + session_id: TEST_SESSION_ID, + ...overrides, + }; +} + +export function buildCompactBoundaryMessage( + overrides: Partial> = {} +): SDKCompactBoundaryMessage { + return { + type: 'system', + subtype: 'compact_boundary', + compact_metadata: { + trigger: 'manual', + pre_tokens: 0, + }, + uuid: TEST_UUID, + session_id: TEST_SESSION_ID, + ...overrides, + }; +} + +export function buildAssistantMessage(overrides: AssistantMessageInput): SDKAssistantMessage { + return { + type: 'assistant', + message: ({ content: [] } as unknown) as SDKAssistantMessage['message'], + parent_tool_use_id: null, + uuid: TEST_UUID, + session_id: TEST_SESSION_ID, + ...(overrides as Partial>), + }; +} + +export function buildUserMessage(overrides: UserMessageInput): SDKUserMessage { + return { + type: 'user', + message: ({ content: [] } as unknown) as SDKUserMessage['message'], + parent_tool_use_id: null, + session_id: TEST_SESSION_ID, + ...(overrides as Partial>), + }; +} + +export function buildStreamEventMessage( + overrides: StreamEventMessageInput +): SDKPartialAssistantMessage { + return { + type: 'stream_event', + event: ({ + type: 'content_block_delta', + delta: { type: 'text_delta', text: '' }, + } as unknown) as SDKPartialAssistantMessage['event'], + parent_tool_use_id: null, + uuid: TEST_UUID, + session_id: TEST_SESSION_ID, + ...(overrides as Partial>), + }; +} + +export function buildResultSuccessMessage( + overrides: Omit = {} +): SDKResultSuccess { + return { + type: 'result', + subtype: 'success', + duration_ms: 0, + duration_api_ms: 0, + is_error: false, + num_turns: 1, + result: 'completed', + stop_reason: null, + total_cost_usd: 0, + usage: DEFAULT_RESULT_USAGE, + modelUsage: DEFAULT_MODEL_USAGE, + permission_denials: [], + uuid: TEST_UUID, + session_id: TEST_SESSION_ID, + ...(overrides as Partial>), + }; +} + +export function buildResultErrorMessage( + overrides: Omit & Pick +): SDKResultError { + const { subtype, ...rest } = overrides; + + return { + type: 'result', + subtype, + duration_ms: 0, + duration_api_ms: 0, + is_error: true, + num_turns: 1, + stop_reason: null, + total_cost_usd: 0, + usage: DEFAULT_RESULT_USAGE, + modelUsage: DEFAULT_MODEL_USAGE, + permission_denials: [], + errors: ['SDK reported an execution error'], + uuid: TEST_UUID, + session_id: TEST_SESSION_ID, + ...(rest as Partial>), + }; +} + +export function buildToolProgressMessage( + overrides: Partial> = {} +): SDKToolProgressMessage { + return { + type: 'tool_progress', + tool_use_id: 'tool-1', + tool_name: 'Bash', + parent_tool_use_id: null, + elapsed_time_seconds: 0, + uuid: TEST_UUID, + session_id: TEST_SESSION_ID, + ...overrides, + }; +} + +export function buildAuthStatusMessage( + overrides: Partial> = {} +): SDKAuthStatusMessage { + return { + type: 'auth_status', + isAuthenticating: false, + output: [], + uuid: TEST_UUID, + session_id: TEST_SESSION_ID, + ...overrides, + }; +} + +function isResultErrorInput(input: ResultSuccessMessageInput | ResultErrorMessageInput): input is ResultErrorMessageInput { + return input.subtype !== undefined && input.subtype !== 'success'; +} + +export function buildSDKMessage(input: SDKTestMessageInput): SDKMessage { + switch (input.type) { + case 'system': + if (input.subtype === 'init') return buildSystemInitMessage(input); + if (input.subtype === 'status') return buildSystemStatusMessage(input); + return buildCompactBoundaryMessage(input); + case 'assistant': + return buildAssistantMessage(input); + case 'user': { + const message = buildUserMessage(input); + if (input._blocked === true) { + return { + ...message, + _blocked: true, + _blockReason: input._blockReason ?? 'Blocked by hook', + } as SDKMessage; + } + return message; + } + case 'stream_event': + return buildStreamEventMessage(input); + case 'result': + if (isResultErrorInput(input)) { + return buildResultErrorMessage(input); + } + return buildResultSuccessMessage(input); + case 'tool_progress': + return buildToolProgressMessage(input); + case 'auth_status': + return buildAuthStatusMessage(input); + } +} diff --git a/tests/integration/core/agent/ClaudianService.test.ts b/tests/integration/core/agent/ClaudianService.test.ts new file mode 100644 index 0000000..972e3a4 --- /dev/null +++ b/tests/integration/core/agent/ClaudianService.test.ts @@ -0,0 +1,1843 @@ +import '@/providers'; + +// eslint-disable-next-line jest/no-mocks-import +import { + getLastOptions, + getLastResponse, + getQueryCallCount, + resetMockMessages, + setMockMessages, + simulateCrash, +} from '@test/__mocks__/claude-agent-sdk'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +// Mock fs module +jest.mock('fs'); + +// Now import after all mocks are set up +import { buildResultErrorMessage } from '@test/helpers/sdkMessages'; + +import { getActionDescription, getActionPattern } from '@/core/security/ApprovalManager'; +import { getPathFromToolInput } from '@/core/tools/toolInput'; +import { ClaudianService } from '@/providers/claude/runtime/ClaudeChatRuntime'; +import { resolveClaudeCliPath } from '@/providers/claude/runtime/ClaudeCliResolver'; +import { transformSDKMessage } from '@/providers/claude/stream/transformClaudeMessage'; +import { + buildContextFromHistory, + formatToolCallForContext, + getLastUserMessage, + isSessionExpiredError, + truncateToolResult, +} from '@/utils/session'; + +// Helper to create SDK-format assistant message with tool_use +function createAssistantWithToolUse(toolName: string, toolInput: Record, toolId = 'tool-123') { + return { + type: 'assistant', + message: { + content: [ + { type: 'tool_use', id: toolId, name: toolName, input: toolInput }, + ], + }, + }; +} + +// Helper to create SDK-format user message with tool_result +function createUserWithToolResult(content: string, parentToolUseId = 'tool-123') { + return { + type: 'user', + parent_tool_use_id: parentToolUseId, + tool_use_result: content, + message: { content: [] }, + }; +} + +function createTextUserMessage(content: string) { + return { + type: 'user', + message: { + role: 'user', + content, + }, + parent_tool_use_id: null, + session_id: '', + }; +} + +// Create a mock MCP server manager +function createMockMcpManager() { + return { + loadServers: jest.fn().mockResolvedValue(undefined), + getServers: jest.fn().mockReturnValue([]), + getEnabledCount: jest.fn().mockReturnValue(0), + getActiveServers: jest.fn().mockReturnValue({}), + getDisallowedMcpTools: jest.fn().mockReturnValue([]), + getAllDisallowedMcpTools: jest.fn().mockReturnValue([]), + hasServers: jest.fn().mockReturnValue(false), + extractMentions: jest.fn().mockReturnValue(new Set()), + transformMentions: jest.fn().mockImplementation((text: string) => text), + } as any; +} + +// Create a mock plugin +function createMockPlugin(settings: Record = {}) { + // CC permissions storage (allow/deny/ask arrays) + const ccPermissions = { + allow: [] as string[], + deny: [] as string[], + ask: [] as string[], + }; + + const mockPlugin = { + settings: { + permissions: [], // Legacy field (for backwards compat tests) + permissionMode: 'yolo', + loadUserClaudeSettings: false, + mediaFolder: '', + systemPrompt: '', + model: 'claude-sonnet-4-5', + thinkingBudget: 'off', + ...settings, + }, + app: { + vault: { + adapter: { + basePath: '/test/vault/path', + }, + }, + }, + storage: { + getPermissions: jest.fn().mockImplementation(async () => ccPermissions), + addAllowRule: jest.fn().mockImplementation(async (rule: string) => { + ccPermissions.allow.push(rule); + }), + addDenyRule: jest.fn().mockImplementation(async (rule: string) => { + ccPermissions.deny.push(rule); + }), + }, + // Expose ccPermissions for test assertions + _ccPermissions: ccPermissions, + saveSettings: jest.fn().mockResolvedValue(undefined), + getActiveEnvironmentVariables: jest.fn().mockReturnValue(''), + getResolvedProviderCliPath: jest.fn().mockReturnValue('/mock/claude'), + // Mock getView to return null (tests don't have real view) + // This allows optional chaining to work safely + getView: jest.fn().mockReturnValue(null), + // Mock pluginManager for QueryOptionsBuilder + pluginManager: { + getPluginsKey: jest.fn().mockReturnValue(''), + hasEnabledPlugins: jest.fn().mockReturnValue(false), + }, + } as any; + return mockPlugin; +} + +describe('ClaudianService', () => { + let service: ClaudianService; + let mockPlugin: any; + + beforeEach(() => { + jest.clearAllMocks(); + resetMockMessages(); + mockPlugin = createMockPlugin(); + service = new ClaudianService(mockPlugin, createMockMcpManager()); + }); + + afterEach(() => { + // Clean up persistent query to prevent test hangs + service.cleanup(); + }); + + describe('findClaudeCLI', () => { + beforeEach(() => { + mockPlugin.getResolvedProviderCliPath.mockImplementation(() => + resolveClaudeCliPath( + undefined, // Hostname path (not used in tests) + mockPlugin.settings.claudeCliPath, + mockPlugin.getActiveEnvironmentVariables() + ) + ); + }); + + afterEach(() => { + (fs.existsSync as jest.Mock).mockReset(); + (fs.statSync as jest.Mock).mockReset(); + }); + + it('should find claude CLI in ~/.claude/local/claude', async () => { + const homeDir = os.homedir(); + const expectedPath = path.join(homeDir, '.claude', 'local', 'claude'); + + (fs.existsSync as jest.Mock).mockImplementation((p: string) => { + return p === expectedPath; + }); + (fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true }); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + + const errorChunk = chunks.find( + (c) => c.type === 'error' && c.content.includes('Claude CLI not found') + ); + expect(errorChunk).toBeUndefined(); + }); + + it('should return error when claude CLI not found', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + + const chunks: any[] = []; + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + + const errorChunk = chunks.find((c) => c.type === 'error'); + expect(errorChunk).toBeDefined(); + expect(errorChunk?.content).toContain('Claude CLI not found'); + }); + + it('should use custom CLI path when valid file is specified', async () => { + const customPath = '/custom/path/to/claude'; + mockPlugin = createMockPlugin({ claudeCliPath: customPath }); + mockPlugin.getResolvedProviderCliPath.mockImplementation(() => + resolveClaudeCliPath( + undefined, // Hostname path (not used in tests) + mockPlugin.settings.claudeCliPath, + mockPlugin.getActiveEnvironmentVariables() + ) + ); + service = new ClaudianService(mockPlugin, createMockMcpManager()); + + (fs.existsSync as jest.Mock).mockImplementation((p: string) => p === customPath); + (fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true }); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + + const errorChunk = chunks.find( + (c) => c.type === 'error' && c.content.includes('Claude CLI not found') + ); + expect(errorChunk).toBeUndefined(); + }); + + it('should fall back to auto-detection when custom path is a directory', async () => { + const customPath = '/custom/path/to/directory'; + mockPlugin = createMockPlugin({ claudeCliPath: customPath }); + mockPlugin.getResolvedProviderCliPath.mockImplementation(() => + resolveClaudeCliPath( + undefined, // Hostname path (not used in tests) + mockPlugin.settings.claudeCliPath, + mockPlugin.getActiveEnvironmentVariables() + ) + ); + service = new ClaudianService(mockPlugin, createMockMcpManager()); + + const homeDir = os.homedir(); + const autoDetectedPath = path.join(homeDir, '.claude', 'local', 'claude'); + + (fs.existsSync as jest.Mock).mockImplementation((p: string) => + p === customPath || p === autoDetectedPath + ); + (fs.statSync as jest.Mock).mockImplementation((p: string) => ({ + isFile: () => p !== customPath, // Custom path is a directory + })); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + + // CLI path validation is silent - just verifies fallback works + expect(chunks.length).toBeGreaterThan(0); + }); + + it('should fall back to auto-detection when custom path does not exist', async () => { + const customPath = '/nonexistent/path/claude'; + mockPlugin = createMockPlugin({ claudeCliPath: customPath }); + mockPlugin.getResolvedProviderCliPath.mockImplementation(() => + resolveClaudeCliPath( + undefined, // Hostname path (not used in tests) + mockPlugin.settings.claudeCliPath, + mockPlugin.getActiveEnvironmentVariables() + ) + ); + service = new ClaudianService(mockPlugin, createMockMcpManager()); + + const homeDir = os.homedir(); + const autoDetectedPath = path.join(homeDir, '.claude', 'local', 'claude'); + + (fs.existsSync as jest.Mock).mockImplementation((p: string) => + p === autoDetectedPath // Custom path does not exist + ); + (fs.statSync as jest.Mock).mockImplementation((p: string) => ({ + isFile: () => p === autoDetectedPath, + })); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + + // CLI path validation is silent - just verifies fallback works + expect(chunks.length).toBeGreaterThan(0); + }); + + it('should fall back to auto-detection when custom path stat fails', async () => { + const customPath = '/custom/path/to/claude'; + mockPlugin = createMockPlugin({ claudeCliPath: customPath }); + mockPlugin.getResolvedProviderCliPath.mockImplementation(() => + resolveClaudeCliPath( + undefined, // Hostname path (not used in tests) + mockPlugin.settings.claudeCliPath, + mockPlugin.getActiveEnvironmentVariables() + ) + ); + service = new ClaudianService(mockPlugin, createMockMcpManager()); + + const homeDir = os.homedir(); + const autoDetectedPath = path.join(homeDir, '.claude', 'local', 'claude'); + + (fs.existsSync as jest.Mock).mockImplementation((p: string) => + p === customPath || p === autoDetectedPath + ); + // Custom path stat throws, auto-detected path works + (fs.statSync as jest.Mock).mockImplementation((p: string) => { + if (p === customPath) { + throw new Error('EACCES'); + } + return { isFile: () => p === autoDetectedPath }; + }); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + + const errorChunk = chunks.find( + (c) => c.type === 'error' && c.content.includes('Claude CLI not found') + ); + expect(errorChunk).toBeUndefined(); + + const options = getLastOptions(); + expect(options?.pathToClaudeCodeExecutable).toBe(autoDetectedPath); + }); + + it('should reload CLI path after cleanup', async () => { + const firstPath = '/custom/path/to/claude-1'; + const secondPath = '/custom/path/to/claude-2'; + mockPlugin = createMockPlugin({ claudeCliPath: firstPath }); + mockPlugin.getResolvedProviderCliPath.mockImplementation(() => + resolveClaudeCliPath( + undefined, // Hostname path (not used in tests) + mockPlugin.settings.claudeCliPath, + mockPlugin.getActiveEnvironmentVariables() + ) + ); + service = new ClaudianService(mockPlugin, createMockMcpManager()); + + (fs.existsSync as jest.Mock).mockImplementation((p: string) => p === firstPath); + (fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true }); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, + ]); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _chunk of service.query('hello')) { + // drain + } + + const firstOptions = getLastOptions(); + expect(firstOptions?.pathToClaudeCodeExecutable).toBe(firstPath); + + mockPlugin.settings.claudeCliPath = secondPath; + service.cleanup(); + + (fs.existsSync as jest.Mock).mockImplementation((p: string) => p === secondPath); + (fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true }); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello again' }] } }, + ]); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _chunk of service.query('hello again')) { + // drain + } + + const secondOptions = getLastOptions(); + expect(secondOptions?.pathToClaudeCodeExecutable).toBe(secondPath); + }); + }); + + describe('transformSDKMessage', () => { + beforeEach(() => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + }); + + it('should transform assistant text messages', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'This is a test response' }] }, + }, + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + + const textChunk = chunks.find((c) => c.type === 'text'); + expect(textChunk).toBeDefined(); + expect(textChunk?.content).toBe('This is a test response'); + }); + + it('should transform tool_use from assistant message content', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + createAssistantWithToolUse('Read', { file_path: '/test/file.txt' }, 'read-tool-1'), + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('read file')) { + chunks.push(chunk); + } + + const toolUseChunk = chunks.find((c) => c.type === 'tool_use'); + expect(toolUseChunk).toBeDefined(); + expect(toolUseChunk?.name).toBe('Read'); + expect(toolUseChunk?.input).toEqual({ file_path: '/test/file.txt' }); + expect(toolUseChunk?.id).toBe('read-tool-1'); + }); + + it('should transform tool_result from user message', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + createAssistantWithToolUse('Read', { file_path: '/test/file.txt' }, 'read-tool-1'), + createUserWithToolResult('File contents here', 'read-tool-1'), + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('read file')) { + chunks.push(chunk); + } + + const toolResultChunk = chunks.find((c) => c.type === 'subagent_tool_result'); + expect(toolResultChunk).toBeDefined(); + expect(toolResultChunk?.content).toBe('File contents here'); + expect(toolResultChunk?.id).toBe('read-tool-1'); + }); + + it('should transform assistant error messages', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + error: 'Something went wrong', + message: { content: [] }, + }, + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('do something')) { + chunks.push(chunk); + } + + const errorChunk = chunks.find((c) => c.type === 'error' && c.content === 'Something went wrong'); + expect(errorChunk).toBeDefined(); + }); + + it('should capture session ID from init message', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'my-session-123' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + + expect(chunks.some((c) => c.type === 'text')).toBe(true); + }); + + it('should resume previous session on subsequent queries', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'resume-session' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'First run' }] } }, + { type: 'result' }, + ]); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _chunk of service.query('first')) { + // drain + } + + setMockMessages([ + { type: 'assistant', message: { content: [{ type: 'text', text: 'Second run' }] } }, + { type: 'result' }, + ]); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _chunk of service.query('second', undefined, undefined, { forceColdStart: true })) { + // drain + } + + const options = getLastOptions(); + expect(options?.resume).toBe('resume-session'); + expect(service.getSessionId()).toBe('resume-session'); + }); + + it('should extract multiple content blocks from assistant message', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [ + { type: 'text', text: 'Let me read that file.' }, + { type: 'tool_use', id: 'tool-abc', name: 'Read', input: { file_path: '/foo.txt' } }, + ], + }, + }, + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('read foo.txt')) { + chunks.push(chunk); + } + + const textChunk = chunks.find((c) => c.type === 'text'); + expect(textChunk?.content).toBe('Let me read that file.'); + + const toolUseChunk = chunks.find((c) => c.type === 'tool_use'); + expect(toolUseChunk?.name).toBe('Read'); + expect(toolUseChunk?.id).toBe('tool-abc'); + }); + }); + + describe('cancel', () => { + it('should abort ongoing request', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, + ]); + + const queryGenerator = service.query('hello'); + await queryGenerator.next(); + + expect(() => service.cancel()).not.toThrow(); + }); + + it('should call interrupt on underlying stream when aborted', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'cancel-session' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Chunk 1' }] } }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Chunk 2' }] } }, + { type: 'result' }, + ]); + + const generator = service.query('streaming'); + await generator.next(); + + service.cancel(); + + const chunks: any[] = []; + for await (const chunk of generator) { + chunks.push(chunk); + } + + const response = getLastResponse(); + expect(response?.interrupt).toHaveBeenCalled(); + expect(chunks.some((c) => c.type === 'done')).toBe(true); + }); + + it('should handle cancel when no query is running', () => { + expect(() => service.cancel()).not.toThrow(); + }); + }); + + // MessageChannel tests moved to tests/unit/core/agent/MessageChannel.test.ts + + describe('persistent query updates', () => { + it('uses a query model override when starting the persistent query', async () => { + const chunks: any[] = []; + for await (const chunk of service.query('hello', undefined, undefined, { model: 'claude-opus-4-5' })) { + chunks.push(chunk); + } + + expect(getLastOptions()?.model).toBe('claude-opus-4-5'); + }); + }); + + describe('persistent query error handling', () => { + it('yields error from assistant message with error field', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { type: 'assistant', error: 'server_error', message: { content: [] } }, + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('trigger error')) { + chunks.push(chunk); + } + + expect(chunks.some((c) => c.type === 'error' && c.content === 'server_error')).toBe(true); + expect(chunks.some((c) => c.type === 'done')).toBe(true); + }); + + it('yields error from failed result messages', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + buildResultErrorMessage({ + subtype: 'error_max_turns', + errors: ['Max turns reached'], + }), + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('trigger max turns')) { + chunks.push(chunk); + } + + expect(chunks.some((c) => c.type === 'error' && c.content === 'Max turns reached')).toBe(true); + expect(chunks.some((c) => c.type === 'done')).toBe(true); + }); + + // Note: Session expiration is handled via thrown errors in catch blocks, + // not via message types. The SDK throws on session expiration which is + // caught by isSessionExpiredError() in the query error handlers. + }); + + describe('closePersistentQuery with preserveHandlers', () => { + afterEach(() => { + service.cleanup(); + }); + + it('preserves handlers when preserveHandlers is true', async () => { + // Start a query to create handlers + const queryPromise = (async () => { + const chunks: any[] = []; + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + return chunks; + })(); + + // Let the query start + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Access internal state to verify handlers exist + const handlersBefore = (service as any).responseHandlers?.length ?? 0; + + // Close with preserveHandlers: true + service.closePersistentQuery('test', { preserveHandlers: true }); + + // Handlers should still exist + const handlersAfter = (service as any).responseHandlers?.length ?? 0; + expect(handlersAfter).toBe(handlersBefore); + + // Clean up the promise (it will resolve/reject after close) + await queryPromise.catch(() => { }); + }); + + it('clears handlers when preserveHandlers is false (default)', async () => { + // Start a query to create handlers + const queryPromise = (async () => { + const chunks: any[] = []; + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + return chunks; + })(); + + // Let the query start + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Close without preserveHandlers (default is false) + service.closePersistentQuery('test'); + + // Handlers should be cleared + const handlersAfter = (service as any).responseHandlers?.length ?? 0; + expect(handlersAfter).toBe(0); + + // Clean up the promise + await queryPromise.catch(() => { }); + }); + }); + + describe('crash recovery with simulateCrash', () => { + afterEach(() => { + service.cleanup(); + }); + + it('restarts persistent query on consumer error when no chunks received', async () => { + // Simulate crash before any chunks are emitted + simulateCrash(0); + + const initialCallCount = getQueryCallCount(); + const chunks: any[] = []; + + // The query should recover and eventually succeed + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + + // Query should have been called twice (initial + restart) + expect(getQueryCallCount()).toBe(initialCallCount + 2); + + // Should have received the successful response after recovery + const textChunk = chunks.find((c) => c.type === 'text'); + expect(textChunk).toBeDefined(); + }); + + it('does not replay message when chunks were already received before crash', async () => { + // Simulate crash after 1 chunk is emitted (system init message) + simulateCrash(1); + + const chunks: any[] = []; + + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + + // Should have received the system init chunk before error + // Note: error is propagated via onError handler which ends the generator + expect(chunks.length).toBeGreaterThan(0); + }); + }); + + describe('persistent query recovery after close', () => { + afterEach(() => { + service.cleanup(); + }); + + it('can start new persistent query after closePersistentQuery', async () => { + // First query establishes persistent query + const chunks1: any[] = []; + for await (const chunk of service.query('first')) { + chunks1.push(chunk); + } + expect(chunks1.length).toBeGreaterThan(0); + expect((service as any).persistentQuery).not.toBeNull(); + + // Close the persistent query (simulating session reset) + service.closePersistentQuery('test close'); + expect((service as any).persistentQuery).toBeNull(); + expect((service as any).shuttingDown).toBe(false); // Should be reset + + // Next query should start a NEW persistent query (not fall back to cold-start) + const chunks2: any[] = []; + for await (const chunk of service.query('second')) { + chunks2.push(chunk); + } + expect(chunks2.length).toBeGreaterThan(0); + + // Verify persistent query was recreated + expect((service as any).persistentQuery).not.toBeNull(); + }); + + it('can recover after resetSession closes persistent query', async () => { + // First query + const chunks1: any[] = []; + for await (const chunk of service.query('first')) { + chunks1.push(chunk); + } + expect((service as any).persistentQuery).not.toBeNull(); + + // Reset session (which closes persistent query) + service.resetSession(); + expect((service as any).persistentQuery).toBeNull(); + expect((service as any).shuttingDown).toBe(false); + + // Next query should work + const chunks2: any[] = []; + for await (const chunk of service.query('second')) { + chunks2.push(chunk); + } + expect(chunks2.length).toBeGreaterThan(0); + expect((service as any).persistentQuery).not.toBeNull(); + }); + + it('can recover after session switch closes persistent query', async () => { + // First query + const chunks1: any[] = []; + for await (const chunk of service.query('first')) { + chunks1.push(chunk); + } + expect((service as any).persistentQuery).not.toBeNull(); + + // Switch to a different session (which closes persistent query) + service.setSessionId('new-session-id'); + expect((service as any).persistentQuery).toBeNull(); + expect((service as any).shuttingDown).toBe(false); + + // Next query should work with new session + const chunks2: any[] = []; + for await (const chunk of service.query('second')) { + chunks2.push(chunk); + } + expect(chunks2.length).toBeGreaterThan(0); + expect((service as any).persistentQuery).not.toBeNull(); + }); + }); + + // SessionManager tests (resetSession, getSessionId, setSessionId) moved to: + // tests/unit/core/agent/SessionManager.test.ts + + describe('cleanup', () => { + it('should call cancel and resetSession', () => { + const cancelSpy = jest.spyOn(service, 'cancel'); + const resetSessionSpy = jest.spyOn(service, 'resetSession'); + + service.cleanup(); + + expect(cancelSpy).toHaveBeenCalled(); + expect(resetSessionSpy).toHaveBeenCalled(); + }); + }); + + describe('getVaultPath', () => { + it('should return error when vault path cannot be determined', async () => { + mockPlugin = { + ...mockPlugin, + app: { + vault: { + adapter: {}, + }, + }, + }; + service = new ClaudianService(mockPlugin, createMockMcpManager()); + + const chunks: any[] = []; + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + + const errorChunk = chunks.find( + (c) => c.type === 'error' && c.content.includes('vault path') + ); + expect(errorChunk).toBeDefined(); + }); + }); + + describe('query with conversation history', () => { + beforeEach(() => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + }); + + it('should accept optional conversation history parameter', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello!' }] } }, + { type: 'result' }, + ]); + + const history = [ + { id: 'msg-1', role: 'user' as const, content: 'Previous message', timestamp: Date.now() }, + { id: 'msg-2', role: 'assistant' as const, content: 'Previous response', timestamp: Date.now() }, + ]; + + const chunks: any[] = []; + for await (const chunk of service.query('new message', undefined, history)) { + chunks.push(chunk); + } + + expect(chunks.some((c) => c.type === 'text')).toBe(true); + }); + + it('should work without conversation history', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello!' }] } }, + { type: 'result' }, + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('hello')) { + chunks.push(chunk); + } + + expect(chunks.some((c) => c.type === 'text')).toBe(true); + }); + + it('should rebuild history when session is missing but history exists', async () => { + const prompts: string[] = []; + + jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* (prompt: string) { + prompts.push(prompt); + yield { type: 'text', content: 'ok' }; + }) as any); + + const history = [ + { id: 'msg-1', role: 'user' as const, content: 'Previous message', timestamp: Date.now() }, + { id: 'msg-2', role: 'assistant' as const, content: 'Previous response', timestamp: Date.now() }, + ]; + + const chunks: any[] = []; + for await (const chunk of service.query('New message', undefined, history)) { + chunks.push(chunk); + } + + expect(prompts).toHaveLength(1); + expect(prompts[0]).toContain('User: Previous message'); + expect(prompts[0]).toContain('Assistant: Previous response'); + expect(prompts[0]).toContain('User: New message'); + expect(chunks.some((c) => c.type === 'text')).toBe(true); + }); + }); + + describe('session restoration', () => { + it('should use restored session ID on subsequent queries', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + + // Simulate restoring a session ID from storage + service.setSessionId('restored-session-id'); + + setMockMessages([ + { type: 'assistant', message: { content: [{ type: 'text', text: 'Resumed!' }] } }, + { type: 'result' }, + ]); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _chunk of service.query('continue')) { + // drain + } + + const options = getLastOptions(); + expect(options?.resume).toBe('restored-session-id'); + }); + + it('should capture new session ID from SDK', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'new-captured-session' }, + { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } }, + { type: 'result' }, + ]); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _chunk of service.query('hello')) { + // drain + } + + expect(service.getSessionId()).toBe('new-captured-session'); + }); + }); + + describe('extended thinking', () => { + beforeEach(() => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + }); + + it('should transform thinking blocks from assistant messages', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [ + { type: 'thinking', thinking: 'Let me analyze this problem...' }, + { type: 'text', text: 'Here is my answer.' }, + ], + }, + }, + { type: 'result' }, + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('think about this')) { + chunks.push(chunk); + } + + const thinkingChunk = chunks.find((c) => c.type === 'thinking'); + expect(thinkingChunk).toBeDefined(); + expect(thinkingChunk?.content).toBe('Let me analyze this problem...'); + + const textChunk = chunks.find((c) => c.type === 'text'); + expect(textChunk).toBeDefined(); + expect(textChunk?.content).toBe('Here is my answer.'); + }); + + it('should transform thinking deltas from stream events', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'stream_event', + event: { + type: 'content_block_start', + content_block: { type: 'thinking', thinking: 'Starting thought...' }, + }, + }, + { + type: 'stream_event', + event: { + type: 'content_block_delta', + delta: { type: 'thinking_delta', thinking: ' continuing thought...' }, + }, + }, + { type: 'result' }, + ]); + + const chunks: any[] = []; + for await (const chunk of service.query('think')) { + chunks.push(chunk); + } + + const thinkingChunks = chunks.filter((c) => c.type === 'thinking'); + expect(thinkingChunks.length).toBeGreaterThanOrEqual(1); + expect(thinkingChunks.some((c) => c.content.includes('thought'))).toBe(true); + }); + }); + + describe('permission utility functions', () => { + it('should generate correct action patterns for different tools', () => { + expect(getActionPattern('Bash', { command: 'git status' })).toBe('git status'); + expect(getActionPattern('Read', { file_path: '/test/file.md' })).toBe('/test/file.md'); + expect(getActionPattern('Write', { file_path: '/test/output.md' })).toBe('/test/output.md'); + expect(getActionPattern('Edit', { file_path: '/test/edit.md' })).toBe('/test/edit.md'); + expect(getActionPattern('Glob', { pattern: '**/*.md' })).toBe('**/*.md'); + expect(getActionPattern('Grep', { pattern: 'TODO' })).toBe('TODO'); + }); + + it('should generate correct action descriptions', () => { + expect(getActionDescription('Bash', { command: 'git status' })).toBe('Run command: git status'); + expect(getActionDescription('Read', { file_path: '/test/file.md' })).toBe('Read file: /test/file.md'); + expect(getActionDescription('Write', { file_path: '/test/output.md' })).toBe('Write to file: /test/output.md'); + expect(getActionDescription('Edit', { file_path: '/test/edit.md' })).toBe('Edit file: /test/edit.md'); + expect(getActionDescription('Glob', { pattern: '**/*.md' })).toBe('Search files matching: **/*.md'); + expect(getActionDescription('Grep', { pattern: 'TODO' })).toBe('Search content matching: TODO'); + }); + }); + + // Note: safe mode approvals tests removed - createUnifiedToolCallback was part of plan mode + + describe('session expiration recovery', () => { + beforeEach(() => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + }); + + it('should detect session expired errors', () => { + // Now test the standalone function directly + expect(isSessionExpiredError(new Error('Session expired'))).toBe(true); + expect(isSessionExpiredError(new Error('session not found'))).toBe(true); + expect(isSessionExpiredError(new Error('invalid session'))).toBe(true); + expect(isSessionExpiredError(new Error('Resume failed'))).toBe(true); + }); + + it('should not detect non-session errors as session errors', () => { + // Now test the standalone function directly + expect(isSessionExpiredError(new Error('Network error'))).toBe(false); + expect(isSessionExpiredError(new Error('Rate limited'))).toBe(false); + expect(isSessionExpiredError(new Error('Invalid API key'))).toBe(false); + }); + + it('should build context from conversation history', () => { + const messages = [ + { id: 'msg-1', role: 'user' as const, content: 'Hello', timestamp: Date.now() }, + { id: 'msg-2', role: 'assistant' as const, content: 'Hi there!', timestamp: Date.now() }, + { id: 'msg-3', role: 'user' as const, content: 'How are you?', timestamp: Date.now() }, + ]; + + // Now test the standalone function directly + const context = buildContextFromHistory(messages); + + expect(context).toContain('User: Hello'); + expect(context).toContain('Assistant: Hi there!'); + expect(context).toContain('User: How are you?'); + }); + + it('should include tool call info with input (status only for success)', () => { + const messages = [ + { id: 'msg-1', role: 'user' as const, content: 'Read a file', timestamp: Date.now() }, + { + id: 'msg-2', + role: 'assistant' as const, + content: 'Reading file...', + timestamp: Date.now(), + toolCalls: [ + { id: 'tool-1', name: 'Read', input: { file_path: '/test.md' }, status: 'completed' as const, result: 'File contents' }, + ], + }, + ]; + + // Now test the standalone function directly + const context = buildContextFromHistory(messages); + + // Successful tools show input but no result (Claude can re-read if needed) + expect(context).toContain('[Tool Read input: file_path=/test.md status=completed]'); + expect(context).not.toContain('File contents'); + }); + + it('should include error messages for failed tool calls with input', () => { + const messages = [ + { id: 'msg-1', role: 'user' as const, content: 'Read a file', timestamp: Date.now() }, + { + id: 'msg-2', + role: 'assistant' as const, + content: 'Reading file...', + timestamp: Date.now(), + toolCalls: [ + { id: 'tool-1', name: 'Read', input: { file_path: '/missing.md' }, status: 'error' as const, result: 'File not found' }, + ], + }, + ]; + + const context = buildContextFromHistory(messages); + + // Failed tools include input AND error message so Claude knows what went wrong + expect(context).toContain('[Tool Read input: file_path=/missing.md status=error] error: File not found'); + }); + + it('should include current note in rebuilt history', () => { + const messages = [ + { id: 'msg-1', role: 'user' as const, content: 'Edit this file', timestamp: Date.now(), currentNote: 'notes/file.md' }, + ]; + + // Now test the standalone function directly + const context = buildContextFromHistory(messages); + + expect(context).toContain(''); + expect(context).toContain('notes/file.md'); + }); + + it('should truncate long tool results', () => { + const longResult = 'x'.repeat(1000); + // Now test the standalone function directly + const truncated = truncateToolResult(longResult, 100); + + expect(truncated.length).toBeLessThan(longResult.length); + expect(truncated).toContain('(truncated)'); + }); + + it('should not truncate short tool results', () => { + const shortResult = 'Short result'; + // Now test the standalone function directly + const result = truncateToolResult(shortResult, 100); + + expect(result).toBe(shortResult); + }); + }); + + describe('session expiration recovery flow', () => { + beforeEach(() => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + mockPlugin.getResolvedProviderCliPath.mockReturnValue('/mock/claude'); + }); + + it('should rebuild history and retry without resume on session expiration', async () => { + service.setSessionId('stale-session'); + const prompts: string[] = []; + + jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* (prompt: string) { + prompts.push(prompt); + if (prompts.length === 1) { + throw new Error('Session expired'); + } + yield { type: 'text', content: 'Recovered' }; + }) as any); + + const history = [ + { id: 'msg-1', role: 'user' as const, content: 'First question', timestamp: Date.now() }, + { + id: 'msg-2', + role: 'assistant' as const, + content: 'Answer', + timestamp: Date.now(), + toolCalls: [ + { id: 'tool-1', name: 'Read', input: { file_path: '/test/vault/path/file.md' }, status: 'completed' as const, result: 'file content' }, + ], + }, + { id: 'msg-3', role: 'user' as const, content: 'Follow up', timestamp: Date.now(), currentNote: 'note.md' }, + ]; + + const chunks: any[] = []; + for await (const chunk of service.query('Follow up', undefined, history, { forceColdStart: true })) { + chunks.push(chunk); + } + + expect(prompts[0]).toBe('Follow up'); + expect(prompts[1]).toContain('User: First question'); + expect(prompts[1]).toContain('Assistant: Answer'); + expect(prompts[1]).toContain(''); + expect(prompts[1]).toContain('note.md'); + expect(chunks.some((c) => c.type === 'text' && c.content === 'Recovered')).toBe(true); + expect(service.getSessionId()).toBeNull(); + }); + + it('should rebuild history when persistent query throws session expired', async () => { + service.setSessionId('stale-session'); + const prompts: string[] = []; + + // eslint-disable-next-line require-yield + jest.spyOn(service as any, 'queryViaPersistent').mockImplementation((async function* () { + throw new Error('Session expired'); + }) as any); + + jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* (prompt: string) { + prompts.push(prompt); + yield { type: 'text', content: 'Recovered' }; + }) as any); + + const history = [ + { id: 'msg-1', role: 'user' as const, content: 'First question', timestamp: Date.now() }, + { id: 'msg-2', role: 'assistant' as const, content: 'Answer', timestamp: Date.now() }, + { id: 'msg-3', role: 'user' as const, content: 'Follow up', timestamp: Date.now() }, + ]; + + const chunks: any[] = []; + for await (const chunk of service.query('Follow up', undefined, history)) { + chunks.push(chunk); + } + + expect(prompts).toHaveLength(1); + expect(prompts[0]).toContain('User: First question'); + expect(prompts[0]).toContain('Assistant: Answer'); + expect(prompts[0]).toContain('User: Follow up'); + expect(chunks.some((c) => c.type === 'text' && c.content === 'Recovered')).toBe(true); + expect(service.getSessionId()).toBeNull(); + }); + + it('should preserve current message images when session expired during cold-start', async () => { + service.setSessionId('stale-session'); + let capturedImages: any[] | undefined; + + jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* ( + _prompt: string, + _vaultPath: string, + _cliPath: string, + images: any[] | undefined + ) { + if (!capturedImages) { + // First call throws session expired + capturedImages = images; + throw new Error('Session expired'); + } + // Second call (retry) should have the images + capturedImages = images; + yield { type: 'text', content: 'Recovered' }; + }) as any); + + const history = [ + { id: 'msg-1', role: 'user' as const, content: 'First question', timestamp: Date.now() }, + { id: 'msg-2', role: 'assistant' as const, content: 'Answer', timestamp: Date.now() }, + ]; + + const currentImages = [ + { id: 'img-1', name: 'test.png', mediaType: 'image/png' as const, data: 'base64data', size: 100, source: 'file' as const }, + ]; + + const chunks: any[] = []; + for await (const chunk of service.query('Follow up with image', currentImages, history, { forceColdStart: true })) { + chunks.push(chunk); + } + + expect(capturedImages).toBeDefined(); + expect(capturedImages).toHaveLength(1); + expect(capturedImages![0].id).toBe('img-1'); + expect(chunks.some((c) => c.type === 'text' && c.content === 'Recovered')).toBe(true); + }); + + it('should preserve current message images when session expired during persistent query', async () => { + service.setSessionId('stale-session'); + let capturedImages: any[] | undefined; + + // eslint-disable-next-line require-yield + jest.spyOn(service as any, 'queryViaPersistent').mockImplementation((async function* () { + throw new Error('Session expired'); + }) as any); + + jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* ( + _prompt: string, + _vaultPath: string, + _cliPath: string, + images: any[] | undefined + ) { + capturedImages = images; + yield { type: 'text', content: 'Recovered' }; + }) as any); + + const history = [ + { id: 'msg-1', role: 'user' as const, content: 'First question', timestamp: Date.now() }, + { id: 'msg-2', role: 'assistant' as const, content: 'Answer', timestamp: Date.now() }, + ]; + + const currentImages = [ + { id: 'img-1', name: 'test.png', mediaType: 'image/png' as const, data: 'base64data', size: 100, source: 'file' as const }, + { id: 'img-2', name: 'test2.jpg', mediaType: 'image/jpeg' as const, data: 'base64data2', size: 200, source: 'paste' as const }, + ]; + + const chunks: any[] = []; + for await (const chunk of service.query('Follow up with images', currentImages, history)) { + chunks.push(chunk); + } + + expect(capturedImages).toBeDefined(); + expect(capturedImages).toHaveLength(2); + expect(capturedImages![0].id).toBe('img-1'); + expect(capturedImages![1].id).toBe('img-2'); + expect(chunks.some((c) => c.type === 'text' && c.content === 'Recovered')).toBe(true); + }); + }); + + describe('image prompt and hydration', () => { + beforeEach(() => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + mockPlugin.getResolvedProviderCliPath.mockReturnValue('/mock/claude'); + }); + + it('should return plain prompt when no valid images', () => { + const prompt = (service as any).buildPromptWithImages('hello', []); + expect(prompt).toBe('hello'); + }); + + it('should build async generator with image blocks', async () => { + const images = [ + { id: 'img-1', name: 'a.png', mediaType: 'image/png', data: 'AAA', size: 3, source: 'file' }, + { id: 'img-2', name: 'b.png', mediaType: 'image/png', data: 'BBB', size: 3, source: 'file' }, + ]; + + const gen = (service as any).buildPromptWithImages('hi', images) as AsyncGenerator; + const messages: any[] = []; + for await (const m of gen) messages.push(m); + + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe('user'); + expect(messages[0].message.content[0].type).toBe('image'); + expect(messages[0].message.content[2].type).toBe('text'); + }); + + }); + + // QueryOptionsBuilder tests moved to tests/unit/core/agent/QueryOptionsBuilder.test.ts + + describe('transformSDKMessage additional branches', () => { + it('should transform tool_result blocks inside user content', () => { + const sdkMessage: any = { + type: 'user', + message: { + content: [ + { type: 'tool_result', tool_use_id: 'tool-1', content: 'out', is_error: true }, + ], + }, + }; + + const chunks = Array.from(transformSDKMessage(sdkMessage)); + expect(chunks[0]).toEqual(expect.objectContaining({ type: 'tool_result', id: 'tool-1', isError: true })); + }); + + it('should transform stream_event tool_use and text blocks', () => { + const toolUseMsg: any = { + type: 'stream_event', + event: { type: 'content_block_start', content_block: { type: 'tool_use', id: 't1', name: 'Read', input: {} } }, + }; + const textStartMsg: any = { + type: 'stream_event', + event: { type: 'content_block_start', content_block: { type: 'text', text: 'hello' } }, + }; + const textDeltaMsg: any = { + type: 'stream_event', + event: { type: 'content_block_delta', delta: { type: 'text_delta', text: ' world' } }, + }; + + const toolChunks = Array.from(transformSDKMessage(toolUseMsg)); + const textChunks = [ + ...Array.from(transformSDKMessage(textStartMsg)), + ...Array.from(transformSDKMessage(textDeltaMsg)), + ]; + + expect(toolChunks[0]).toEqual(expect.objectContaining({ type: 'tool_use', id: 't1', name: 'Read' })); + expect(textChunks.map((c: any) => c.content).join('')).toBe('hello world'); + }); + + // Note: Tests for result message usage were removed because transformSDKMessage + // now extracts usage from assistant messages (not result messages) to avoid + // inaccurate spikes from aggregated subagent tokens + + it('should emit no chunks for result messages (usage now comes from assistant messages)', () => { + const sdkMessage: any = { + type: 'result', + modelUsage: { + 'model-a': { + inputTokens: 10, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + contextWindow: 0, + }, + }, + }; + + const chunks = Array.from(transformSDKMessage(sdkMessage)); + expect(chunks).toHaveLength(0); + }); + }); + + describe('remaining business branches', () => { + beforeEach(() => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + mockPlugin.getResolvedProviderCliPath.mockReturnValue('/mock/claude'); + }); + + it('yields error when session retry also fails', async () => { + // eslint-disable-next-line require-yield + jest.spyOn(service as any, 'queryViaSDK').mockImplementation(async function* () { + throw new Error('Session expired'); + }); + + const history = [ + { id: 'u1', role: 'user' as const, content: 'Hi', timestamp: 0 }, + ]; + + const chunks: any[] = []; + for await (const c of service.query('Hi', undefined, history, { forceColdStart: true })) chunks.push(c); + + const errorChunk = chunks.find((c) => c.type === 'error'); + expect(errorChunk).toBeDefined(); + expect(errorChunk.content).toContain('Session expired'); + }); + + it('yields error for non-session failures', async () => { + // eslint-disable-next-line require-yield + jest.spyOn(service as any, 'queryViaSDK').mockImplementation(async function* () { + throw new Error('Network down'); + }); + + const chunks: any[] = []; + for await (const c of service.query('Hi', undefined, undefined, { forceColdStart: true })) chunks.push(c); + + expect(chunks.some((c) => c.type === 'error' && c.content.includes('Network down'))).toBe(true); + }); + + it('skips non-user messages and empty assistants in rebuilt context', () => { + const messages: any[] = [ + { id: 'sys', role: 'system', content: 'ignore', timestamp: 0 }, + { id: 'a1', role: 'assistant', content: '', timestamp: 0 }, + { id: 'u1', role: 'user', content: 'Hello', timestamp: 0 }, + ]; + + // Now test the standalone function directly + const context = buildContextFromHistory(messages); + expect(context).toContain('User: Hello'); + expect(context).not.toContain('system'); + }); + + it('returns undefined when no user message exists', () => { + // Now test the standalone function directly + const last = getLastUserMessage([ + { id: 'a1', role: 'assistant' as const, content: 'Hi', timestamp: 0 }, + ]); + expect(last).toBeUndefined(); + }); + + it('formats tool call without result', () => { + // Now test the standalone function directly + const line = formatToolCallForContext({ id: 't', name: 'Read', input: {}, status: 'completed' as const }); + expect(line).toBe('[Tool Read status=completed]'); + }); + + it('yields error when SDK query throws inside queryViaSDK', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const sdk = require('@anthropic-ai/claude-agent-sdk'); + const spy = jest.spyOn(sdk, 'query').mockImplementation(() => { throw new Error('boom'); }); + + const chunks: any[] = []; + for await (const c of service.query('Hi', undefined, undefined, { forceColdStart: true })) chunks.push(c); + + expect(chunks.some((c) => c.type === 'error' && c.content.includes('boom'))).toBe(true); + spy.mockRestore(); + }); + + // Note: 'allows pre-approved actions' test removed - createUnifiedToolCallback was part of plan mode + + it('returns null for non-file tools in getPathFromToolInput', () => { + expect(getPathFromToolInput('WebSearch', {})).toBeNull(); + }); + + it('does not treat Grep pattern as a path', () => { + expect(getPathFromToolInput('Grep', { pattern: '/etc/passwd' })).toBeNull(); + expect(getPathFromToolInput('Grep', { pattern: 'TODO', path: 'notes' })).toBe('notes'); + }); + + it('covers NotebookEdit and default patterns/descriptions', () => { + // Now test the standalone functions directly + expect(getActionPattern('NotebookEdit', { notebook_path: 'nb.ipynb' })).toBe('nb.ipynb'); + expect(getActionPattern('Other', { foo: 'bar' })).toContain('foo'); + expect(getActionDescription('Other', { foo: 'bar' })).toContain('foo'); + }); + + }); + + describe('persistent query configuration detection', () => { + it('detects system prompt changes requiring restart', async () => { + // First query establishes baseline config + const chunks1: any[] = []; + for await (const c of service.query('first')) chunks1.push(c); + + // Change system prompt which affects systemPromptKey + mockPlugin.settings.systemPrompt = 'new custom prompt'; + + // Second query should detect the change + const chunks2: any[] = []; + for await (const c of service.query('second')) chunks2.push(c); + + // If restart happened, the session would change + // The service should have detected the configuration change + expect(chunks2.some((c) => c.type === 'done')).toBe(true); + }); + + }); + + describe('persistent query dynamic updates', () => { + it('ignores legacy thinking budget changes', async () => { + mockPlugin.settings.model = 'custom-model'; + mockPlugin.settings.thinkingBudget = 'off'; + + const chunks1: any[] = []; + for await (const c of service.query('first')) chunks1.push(c); + + const queryCountBefore = getQueryCallCount(); + mockPlugin.settings.thinkingBudget = 'high'; + + const chunks2: any[] = []; + for await (const c of service.query('second')) chunks2.push(c); + + const response = getLastResponse(); + expect(response?.setMaxThinkingTokens).not.toHaveBeenCalled(); + expect(getQueryCallCount()).toBe(queryCountBefore); + }); + + it('uses effort levels instead of token budgets for built-in models', async () => { + mockPlugin.settings.model = 'sonnet'; + mockPlugin.settings.thinkingBudget = 'off'; + + const chunks1: any[] = []; + for await (const c of service.query('first')) chunks1.push(c); + + // Change thinking budget — should be ignored for adaptive models + mockPlugin.settings.thinkingBudget = 'high'; + + const chunks2: any[] = []; + for await (const c of service.query('second')) chunks2.push(c); + + const response = getLastResponse(); + expect(response?.setMaxThinkingTokens).not.toHaveBeenCalled(); + }); + + it('updates permission mode via setPermissionMode when going from YOLO to normal', async () => { + // Start in YOLO mode + mockPlugin.settings.permissionMode = 'yolo'; + service = new ClaudianService(mockPlugin, createMockMcpManager()); + + const chunks1: any[] = []; + for await (const c of service.query('first')) chunks1.push(c); + + // Switch to normal mode + mockPlugin.settings.permissionMode = 'normal'; + + const chunks2: any[] = []; + for await (const c of service.query('second')) chunks2.push(c); + + const response = getLastResponse(); + // Should call setPermissionMode for YOLO -> normal transition + expect(response?.setPermissionMode).toHaveBeenCalledWith('acceptEdits'); + }); + + it('updates permission mode via setPermissionMode when going from normal to YOLO', async () => { + // Start in normal mode + mockPlugin.settings.permissionMode = 'normal'; + service = new ClaudianService(mockPlugin, createMockMcpManager()); + + const chunks1: any[] = []; + for await (const c of service.query('first')) chunks1.push(c); + + // Switch to YOLO mode + mockPlugin.settings.permissionMode = 'yolo'; + + const chunks2: any[] = []; + for await (const c of service.query('second')) chunks2.push(c); + + const response = getLastResponse(); + // Should call setPermissionMode for normal -> YOLO transition (no restart needed) + expect(response?.setPermissionMode).toHaveBeenCalledWith('bypassPermissions'); + }); + + it('updates effort level via applyFlagSettings without restarting', async () => { + mockPlugin.settings.model = 'sonnet'; + mockPlugin.settings.effortLevel = 'high'; + + const chunks1: any[] = []; + for await (const c of service.query('first')) chunks1.push(c); + + const queryCountBefore = getQueryCallCount(); + mockPlugin.settings.effortLevel = 'max'; + + const chunks2: any[] = []; + for await (const c of service.query('second')) chunks2.push(c); + + const response = getLastResponse(); + expect(response?.applyFlagSettings).toHaveBeenCalledWith({ effortLevel: 'max' }); + expect(getQueryCallCount()).toBe(queryCountBefore); + }); + + it('updates MCP servers on the active persistent query', async () => { + const chunks1: any[] = []; + for await (const c of service.query('first', undefined, undefined, { + mcpMentions: new Set(['server1']), + })) chunks1.push(c); + + const response1 = getLastResponse(); + expect(response1?.setMcpServers).toHaveBeenCalled(); + + // Query with different MCP mentions + const chunks2: any[] = []; + for await (const c of service.query('second', undefined, undefined, { + mcpMentions: new Set(['server2']), + })) chunks2.push(c); + + const response2 = getLastResponse(); + expect(response2?.setMcpServers).toHaveBeenCalled(); + }); + + it('reapplies query overrides after restart triggered by config change', async () => { + const chunks1: any[] = []; + for await (const c of service.query('first')) chunks1.push(c); + + mockPlugin.settings.systemPrompt = 'restart-required'; + + const chunks2: any[] = []; + for await (const c of service.query('second', undefined, undefined, { + model: 'claude-opus-4-5', + })) chunks2.push(c); + + expect(getLastOptions()?.model).toBe('claude-opus-4-5'); + }); + + it('falls back to cold-start when restart fails during dynamic updates', async () => { + const chunks1: any[] = []; + for await (const c of service.query('first')) chunks1.push(c); + expect((service as any).persistentQuery).not.toBeNull(); + + // Force a config change that requires restart + mockPlugin.settings.systemPrompt = 'restart-required'; + + // Allow query + applyDynamicUpdates, then fail restart due to missing CLI path + mockPlugin.getResolvedProviderCliPath.mockReset(); + mockPlugin.getResolvedProviderCliPath + .mockReturnValueOnce('/mock/claude') + .mockReturnValueOnce('/mock/claude') + .mockReturnValueOnce(null); + + const callCountBeforeSecond = getQueryCallCount(); + + const chunks2: any[] = []; + for await (const c of service.query('second')) chunks2.push(c); + + expect(chunks2.some((c) => c.type === 'text')).toBe(true); + expect(getQueryCallCount()).toBe(callCountBeforeSecond + 1); + expect((service as any).persistentQuery).toBeNull(); + expect((service as any).shuttingDown).toBe(false); + }); + }); + + describe('persistent query crash recovery', () => { + it('prevents infinite crash recovery loops via crashRecoveryAttempted flag', async () => { + // Access private state for testing + const serviceAny = service as any; + + // Simulate first crash recovery + serviceAny.crashRecoveryAttempted = false; + serviceAny.lastSentMessage = createTextUserMessage('test'); + + // After first crash, flag should be set + serviceAny.crashRecoveryAttempted = true; + + // Second crash should not attempt recovery + const shouldResend = serviceAny.lastSentMessage && !serviceAny.crashRecoveryAttempted; + expect(shouldResend).toBe(false); + }); + + it('clears lastSentMessage on successful completion', async () => { + const serviceAny = service as any; + + // Before query, lastSentMessage should be null + expect(serviceAny.lastSentMessage).toBeNull(); + + // Run a query + const chunks: any[] = []; + for await (const c of service.query('test')) chunks.push(c); + + // After successful completion, lastSentMessage should be cleared + expect(serviceAny.lastSentMessage).toBeNull(); + }); + }); + + // Note: 'persistent query deferred close', 'tool restriction with allowed tools list', and + // 'persistent query permission mode transitions' tests removed - createUnifiedToolCallback/pendingCloseReason + // were part of plan mode + + + + describe('persistent query crash recovery behavior', () => { + it('restarts persistent query after consumer error to prepare for next query', async () => { + const serviceAny = service as any; + + // Run a query to set up the persistent query + const chunks: any[] = []; + for await (const c of service.query('initial')) chunks.push(c); + + // The persistent query should exist + expect(serviceAny.persistentQuery).not.toBeNull(); + + // Crash recovery should restart the persistent query loop + serviceAny.crashRecoveryAttempted = false; + await service.ensureReady({ force: true }); + + // After restart, persistent query should still be ready + expect(serviceAny.persistentQuery).not.toBeNull(); + }); + + it('re-enqueues pending message after crash recovery restart', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const sdk = require('@anthropic-ai/claude-agent-sdk'); + + let callCount = 0; + let firstPrompt: any = null; + let secondPrompt: any = null; + let resolveSecondPrompt: ((message: any) => void) | null = null; + + const secondPromptPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Timed out waiting for crash recovery re-enqueue')); + }, 2000); + resolveSecondPrompt = (message: any) => { + clearTimeout(timeout); + resolve(message); + }; + }); + + const spy = jest.spyOn(sdk, 'query').mockImplementation((params: any) => { + const { prompt } = params; + callCount += 1; + const callIndex = callCount; + + const generator = async function* () { + if (prompt && typeof prompt[Symbol.asyncIterator] === 'function') { + for await (const message of prompt) { + if (callIndex === 1) { + firstPrompt = message; + throw new Error('boom'); + } + secondPrompt = message; + if (resolveSecondPrompt) resolveSecondPrompt(message); + yield { type: 'system', subtype: 'init', session_id: 'test-session-123' }; + yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Recovered' }] } }; + yield { type: 'result', result: 'completed' }; + } + return; + } + + if (callIndex === 1) { + firstPrompt = prompt; + throw new Error('boom'); + } + secondPrompt = prompt; + if (resolveSecondPrompt) resolveSecondPrompt(prompt); + yield { type: 'system', subtype: 'init', session_id: 'test-session-123' }; + yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Recovered' }] } }; + yield { type: 'result', result: 'completed' }; + }; + + const gen = generator() as AsyncGenerator & { + interrupt: jest.Mock; + setModel: jest.Mock; + setMaxThinkingTokens: jest.Mock; + setPermissionMode: jest.Mock; + setMcpServers: jest.Mock; + }; + gen.interrupt = jest.fn().mockResolvedValue(undefined); + gen.setModel = jest.fn().mockResolvedValue(undefined); + gen.setMaxThinkingTokens = jest.fn().mockResolvedValue(undefined); + gen.setPermissionMode = jest.fn().mockResolvedValue(undefined); + gen.setMcpServers = jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }); + return gen; + }); + + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _chunk of service.query('initial')) { + // drain + } + + await secondPromptPromise; + + expect(callCount).toBeGreaterThanOrEqual(2); + expect(firstPrompt).not.toBeNull(); + expect(secondPrompt).not.toBeNull(); + expect(secondPrompt.message?.content).toEqual(firstPrompt.message?.content); + } finally { + spy.mockRestore(); + } + }); + + it('only attempts crash recovery once via crashRecoveryAttempted flag', async () => { + const serviceAny = service as any; + + // Run a query to set up the persistent query + const chunks: any[] = []; + for await (const c of service.query('initial')) chunks.push(c); + + // First crash - should attempt recovery + expect(serviceAny.crashRecoveryAttempted).toBe(false); + const shouldAttemptFirst = !serviceAny.crashRecoveryAttempted; + expect(shouldAttemptFirst).toBe(true); + + // After first crash, flag is set + serviceAny.crashRecoveryAttempted = true; + + // Second crash - should not attempt recovery + const shouldAttemptSecond = !serviceAny.crashRecoveryAttempted; + expect(shouldAttemptSecond).toBe(false); + }); + }); +}); diff --git a/tests/integration/core/mcp/mcp.test.ts b/tests/integration/core/mcp/mcp.test.ts new file mode 100644 index 0000000..8b26348 --- /dev/null +++ b/tests/integration/core/mcp/mcp.test.ts @@ -0,0 +1,905 @@ +import { Client } from '@modelcontextprotocol/sdk/client'; +import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp'; + +import { parseClipboardConfig, tryParseClipboardConfig } from '@/core/mcp/McpConfigParser'; +import { McpServerManager } from '@/core/mcp/McpServerManager'; +import { testMcpServer } from '@/core/mcp/McpTester'; +import type { + ManagedMcpServer, + McpHttpServerConfig, + McpServerConfig, + McpSSEServerConfig, + McpStdioServerConfig, +} from '@/core/types/mcp'; +import { + DEFAULT_MCP_SERVER, + getMcpServerType, + isValidMcpServerConfig, +} from '@/core/types/mcp'; +import { MCP_CONFIG_PATH, McpStorage } from '@/providers/claude/storage/McpStorage'; +import { + extractMcpMentions, + parseCommand, + splitCommandString, +} from '@/utils/mcp'; + +jest.mock('@modelcontextprotocol/sdk/client', () => ({ + Client: jest.fn(), +})); + +jest.mock('@modelcontextprotocol/sdk/client/stdio', () => ({ + StdioClientTransport: jest.fn(), +})); + +jest.mock('@modelcontextprotocol/sdk/client/sse', () => ({ + SSEClientTransport: jest.fn(), +})); + +jest.mock('@modelcontextprotocol/sdk/client/streamableHttp', () => ({ + StreamableHTTPClientTransport: jest.fn(), +})); + +function createMemoryStorage(initialFile?: Record): { + storage: McpStorage; + files: Map; +} { + const files = new Map(); + if (initialFile) { + files.set(MCP_CONFIG_PATH, JSON.stringify(initialFile)); + } + + const adapter = { + exists: async (path: string) => files.has(path), + read: async (path: string) => files.get(path) ?? '', + write: async (path: string, content: string) => { + files.set(path, content); + }, + }; + + return { storage: new McpStorage(adapter as any), files }; +} + +// ============================================================================ +// MCP Type Tests +// ============================================================================ + +describe('MCP Types', () => { + describe('getMcpServerType', () => { + it('should return stdio for command-based config', () => { + const config: McpStdioServerConfig = { command: 'npx' }; + expect(getMcpServerType(config)).toBe('stdio'); + }); + + it('should return stdio for config with explicit type', () => { + const config: McpStdioServerConfig = { type: 'stdio', command: 'docker' }; + expect(getMcpServerType(config)).toBe('stdio'); + }); + + it('should return sse for SSE config', () => { + const config: McpSSEServerConfig = { type: 'sse', url: 'http://localhost:3000/sse' }; + expect(getMcpServerType(config)).toBe('sse'); + }); + + it('should return http for HTTP config', () => { + const config: McpHttpServerConfig = { type: 'http', url: 'http://localhost:3000/mcp' }; + expect(getMcpServerType(config)).toBe('http'); + }); + + it('should return http for URL without explicit type', () => { + const config = { url: 'http://localhost:3000/mcp' } as McpServerConfig; + expect(getMcpServerType(config)).toBe('http'); + }); + }); + + describe('isValidMcpServerConfig', () => { + it('should return true for valid stdio config', () => { + expect(isValidMcpServerConfig({ command: 'npx' })).toBe(true); + expect(isValidMcpServerConfig({ command: 'docker', args: ['exec', '-i'] })).toBe(true); + }); + + it('should return true for valid URL config', () => { + expect(isValidMcpServerConfig({ url: 'http://localhost:3000' })).toBe(true); + expect(isValidMcpServerConfig({ type: 'sse', url: 'http://localhost:3000/sse' })).toBe(true); + expect(isValidMcpServerConfig({ type: 'http', url: 'http://localhost:3000/mcp' })).toBe(true); + }); + + it('should return false for invalid configs', () => { + expect(isValidMcpServerConfig(null)).toBe(false); + expect(isValidMcpServerConfig(undefined)).toBe(false); + expect(isValidMcpServerConfig({})).toBe(false); + expect(isValidMcpServerConfig({ command: 123 })).toBe(false); + expect(isValidMcpServerConfig({ url: 123 })).toBe(false); + expect(isValidMcpServerConfig('string')).toBe(false); + expect(isValidMcpServerConfig(123)).toBe(false); + }); + }); + + describe('DEFAULT_MCP_SERVER', () => { + it('should have enabled true by default', () => { + expect(DEFAULT_MCP_SERVER.enabled).toBe(true); + }); + + it('should have contextSaving true by default', () => { + expect(DEFAULT_MCP_SERVER.contextSaving).toBe(true); + }); + }); +}); + +// ============================================================================ +// McpStorage Clipboard Parsing Tests +// ============================================================================ + +describe('McpStorage', () => { + describe('parseClipboardConfig', () => { + it('should parse full Claude Code format', () => { + const json = JSON.stringify({ + mcpServers: { + 'my-server': { command: 'npx', args: ['server'] }, + 'other-server': { type: 'sse', url: 'http://localhost:3000' }, + }, + }); + + const result = parseClipboardConfig(json); + + expect(result.needsName).toBe(false); + expect(result.servers).toHaveLength(2); + expect(result.servers[0].name).toBe('my-server'); + expect(result.servers[0].config).toEqual({ command: 'npx', args: ['server'] }); + expect(result.servers[1].name).toBe('other-server'); + }); + + it('should parse single server with name', () => { + const json = JSON.stringify({ + 'my-server': { command: 'docker', args: ['exec', '-i', 'container'] }, + }); + + const result = parseClipboardConfig(json); + + expect(result.needsName).toBe(false); + expect(result.servers).toHaveLength(1); + expect(result.servers[0].name).toBe('my-server'); + }); + + it('should parse single config without name', () => { + const json = JSON.stringify({ + command: 'python', + args: ['-m', 'server'], + }); + + const result = parseClipboardConfig(json); + + expect(result.needsName).toBe(true); + expect(result.servers).toHaveLength(1); + expect(result.servers[0].name).toBe(''); + expect(result.servers[0].config).toEqual({ command: 'python', args: ['-m', 'server'] }); + }); + + it('should parse URL config without name', () => { + const json = JSON.stringify({ + type: 'sse', + url: 'http://localhost:3000/sse', + headers: { Authorization: 'Bearer token' }, + }); + + const result = parseClipboardConfig(json); + + expect(result.needsName).toBe(true); + expect(result.servers).toHaveLength(1); + expect(result.servers[0].config).toEqual({ + type: 'sse', + url: 'http://localhost:3000/sse', + headers: { Authorization: 'Bearer token' }, + }); + }); + + it('should parse multiple named servers without mcpServers wrapper', () => { + const json = JSON.stringify({ + server1: { command: 'npx' }, + server2: { url: 'http://localhost:3000' }, + }); + + const result = parseClipboardConfig(json); + + expect(result.needsName).toBe(false); + expect(result.servers).toHaveLength(2); + }); + + it('should throw for invalid JSON', () => { + expect(() => parseClipboardConfig('not json')).toThrow('Invalid JSON'); + }); + + it('should throw for non-object JSON', () => { + expect(() => parseClipboardConfig('"string"')).toThrow('Invalid JSON object'); + expect(() => parseClipboardConfig('123')).toThrow('Invalid JSON object'); + expect(() => parseClipboardConfig('null')).toThrow('Invalid JSON object'); + }); + + it('should throw for empty mcpServers', () => { + const json = JSON.stringify({ mcpServers: {} }); + expect(() => parseClipboardConfig(json)).toThrow('No valid server configs'); + }); + + it('should throw for invalid config format', () => { + const json = JSON.stringify({ invalidKey: 'invalidValue' }); + expect(() => parseClipboardConfig(json)).toThrow('Invalid MCP configuration'); + }); + + it('should skip invalid configs in mcpServers', () => { + const json = JSON.stringify({ + mcpServers: { + valid: { command: 'npx' }, + invalid: { notACommand: 'foo' }, + }, + }); + + const result = parseClipboardConfig(json); + + expect(result.servers).toHaveLength(1); + expect(result.servers[0].name).toBe('valid'); + }); + }); + + describe('tryParseClipboardConfig', () => { + it('should return parsed config for valid JSON', () => { + const json = JSON.stringify({ command: 'npx' }); + const result = tryParseClipboardConfig(json); + + expect(result).not.toBeNull(); + expect(result!.needsName).toBe(true); + }); + + it('should return null for non-JSON text', () => { + expect(tryParseClipboardConfig('hello world')).toBeNull(); + expect(tryParseClipboardConfig('not { json')).toBeNull(); + }); + + it('should return null for text not starting with {', () => { + expect(tryParseClipboardConfig('[]')).toBeNull(); + expect(tryParseClipboardConfig(' []')).toBeNull(); + }); + + it('should handle whitespace before JSON', () => { + const json = ' { "command": "npx" }'; + const result = tryParseClipboardConfig(json); + + expect(result).not.toBeNull(); + }); + + it('should return null for invalid MCP config', () => { + const json = JSON.stringify({ random: 'object' }); + expect(tryParseClipboardConfig(json)).toBeNull(); + }); + }); + + describe('load/save', () => { + it('should preserve unknown top-level keys and merge _claudian', async () => { + const initial = { + mcpServers: { + legacy: { command: 'node' }, + }, + _claudian: { + servers: { + legacy: { enabled: false }, + }, + extra: { keep: true }, + }, + other: { keep: true }, + }; + const { storage, files } = createMemoryStorage(initial); + + const servers: ManagedMcpServer[] = [ + { + name: 'new-server', + config: { + type: 'http', + url: 'http://localhost:3000/mcp', + headers: { Authorization: 'Bearer token' }, + }, + enabled: false, + contextSaving: false, + description: 'New server', + }, + ]; + + await storage.save(servers); + + const saved = JSON.parse(files.get(MCP_CONFIG_PATH) || '{}') as Record; + expect(saved.other).toEqual({ keep: true }); + expect(saved.mcpServers).toEqual({ + 'new-server': { + type: 'http', + url: 'http://localhost:3000/mcp', + headers: { Authorization: 'Bearer token' }, + }, + }); + expect(saved._claudian).toEqual({ + extra: { keep: true }, + servers: { + 'new-server': { + enabled: false, + contextSaving: false, + description: 'New server', + }, + }, + }); + }); + + it('should keep existing _claudian fields when metadata is defaulted', async () => { + const initial = { + mcpServers: { + legacy: { command: 'node' }, + }, + _claudian: { + extra: { keep: true }, + }, + }; + const { storage, files } = createMemoryStorage(initial); + + const servers: ManagedMcpServer[] = [ + { + name: 'default-meta', + config: { command: 'npx' }, + enabled: DEFAULT_MCP_SERVER.enabled, + contextSaving: DEFAULT_MCP_SERVER.contextSaving, + }, + ]; + + await storage.save(servers); + + const saved = JSON.parse(files.get(MCP_CONFIG_PATH) || '{}') as Record; + expect(saved._claudian).toEqual({ extra: { keep: true } }); + expect(saved.mcpServers).toEqual({ 'default-meta': { command: 'npx' } }); + }); + + it('should load servers with metadata and defaults', async () => { + const initial = { + mcpServers: { + stdio: { command: 'npx' }, + remote: { type: 'sse', url: 'http://localhost:3000/sse' }, + }, + _claudian: { + servers: { + stdio: { enabled: false, contextSaving: false, description: 'Local tools' }, + }, + }, + }; + const { storage } = createMemoryStorage(initial); + + const servers = await storage.load(); + + expect(servers).toHaveLength(2); + const stdio = servers.find((server) => server.name === 'stdio')!; + const remote = servers.find((server) => server.name === 'remote')!; + + expect(stdio.enabled).toBe(false); + expect(stdio.contextSaving).toBe(false); + expect(stdio.description).toBe('Local tools'); + + expect(remote.enabled).toBe(true); + expect(remote.contextSaving).toBe(true); + }); + + it('should skip invalid server configs on load', async () => { + const initial = { + mcpServers: { + valid: { command: 'npx' }, + invalid: { foo: 'bar' }, + }, + _claudian: { + servers: { + invalid: { enabled: false }, + }, + }, + }; + const { storage } = createMemoryStorage(initial); + + const servers = await storage.load(); + + expect(servers).toHaveLength(1); + expect(servers[0].name).toBe('valid'); + expect(servers[0].enabled).toBe(true); + expect(servers[0].contextSaving).toBe(true); + }); + + it('should remove _claudian when only servers metadata exists', async () => { + const initial = { + mcpServers: { + legacy: { command: 'node' }, + }, + _claudian: { + servers: { + legacy: { enabled: false }, + }, + }, + }; + const { storage, files } = createMemoryStorage(initial); + + const servers: ManagedMcpServer[] = [ + { + name: 'legacy', + config: { command: 'node' }, + enabled: DEFAULT_MCP_SERVER.enabled, + contextSaving: DEFAULT_MCP_SERVER.contextSaving, + }, + ]; + + await storage.save(servers); + + const saved = JSON.parse(files.get(MCP_CONFIG_PATH) || '{}') as Record; + expect(saved._claudian).toBeUndefined(); + }); + }); +}); + +// ============================================================================ +// MCP Utils Tests +// ============================================================================ + +describe('MCP Utils', () => { + describe('extractMcpMentions', () => { + it('should extract valid @mentions', () => { + const validNames = new Set(['context7', 'code-exec', 'my_server']); + const text = 'Use @context7 and @code-exec to help'; + + const result = extractMcpMentions(text, validNames); + + expect(result.size).toBe(2); + expect(result.has('context7')).toBe(true); + expect(result.has('code-exec')).toBe(true); + }); + + it('should only extract valid names', () => { + const validNames = new Set(['valid-server']); + const text = 'Use @valid-server and @invalid-server'; + + const result = extractMcpMentions(text, validNames); + + expect(result.size).toBe(1); + expect(result.has('valid-server')).toBe(true); + expect(result.has('invalid-server')).toBe(false); + }); + + it('should handle dots and underscores in names', () => { + const validNames = new Set(['server.v2', 'my_server', 'test-server']); + const text = '@server.v2 @my_server @test-server'; + + const result = extractMcpMentions(text, validNames); + + expect(result.size).toBe(3); + }); + + it('should return empty set for no mentions', () => { + const validNames = new Set(['server']); + const text = 'No mentions here'; + + const result = extractMcpMentions(text, validNames); + + expect(result.size).toBe(0); + }); + + it('should handle multiple same mentions', () => { + const validNames = new Set(['server']); + const text = '@server and @server again'; + + const result = extractMcpMentions(text, validNames); + + expect(result.size).toBe(1); + }); + + it('should ignore @name/ filter mentions', () => { + const validNames = new Set(['workspace']); + const text = 'Use @workspace/ to filter files'; + + const result = extractMcpMentions(text, validNames); + + expect(result.size).toBe(0); + }); + + it('should not match partial names from email addresses', () => { + // The regex captures everything after @ until a non-valid char + // So user@example.com captures 'example.com', not 'example' + const validNames = new Set(['example']); + const text = 'Contact user@example.com for help'; + + const result = extractMcpMentions(text, validNames); + + // 'example.com' is captured, but 'example' alone is not in the capture + // So it won't match the validNames set + expect(result.size).toBe(0); + }); + }); + + describe('splitCommandString', () => { + it('should split simple command', () => { + expect(splitCommandString('docker exec -i')).toEqual(['docker', 'exec', '-i']); + }); + + it('should handle quoted arguments', () => { + expect(splitCommandString('echo "hello world"')).toEqual(['echo', 'hello world']); + expect(splitCommandString("echo 'hello world'")).toEqual(['echo', 'hello world']); + }); + + it('should handle mixed quotes', () => { + expect(splitCommandString('cmd "arg 1" \'arg 2\'')).toEqual(['cmd', 'arg 1', 'arg 2']); + }); + + it('should handle empty string', () => { + expect(splitCommandString('')).toEqual([]); + }); + + it('should handle multiple spaces', () => { + expect(splitCommandString('cmd arg1 arg2')).toEqual(['cmd', 'arg1', 'arg2']); + }); + + it('should preserve quotes content with special chars', () => { + expect(splitCommandString('echo "hello=world"')).toEqual(['echo', 'hello=world']); + }); + }); + + describe('parseCommand', () => { + it('should parse command without args', () => { + const result = parseCommand('docker'); + expect(result.cmd).toBe('docker'); + expect(result.args).toEqual([]); + }); + + it('should parse command with inline args', () => { + const result = parseCommand('docker exec -i container'); + expect(result.cmd).toBe('docker'); + expect(result.args).toEqual(['exec', '-i', 'container']); + }); + + it('should use provided args if given', () => { + const result = parseCommand('docker', ['run', '-it']); + expect(result.cmd).toBe('docker'); + expect(result.args).toEqual(['run', '-it']); + }); + + it('should prefer provided args over inline', () => { + const result = parseCommand('docker exec', ['run']); + expect(result.cmd).toBe('docker exec'); + expect(result.args).toEqual(['run']); + }); + + it('should handle empty command', () => { + const result = parseCommand(''); + expect(result.cmd).toBe(''); + expect(result.args).toEqual([]); + }); + }); +}); + +// ============================================================================ +// McpTester Tests +// ============================================================================ + +describe('McpTester', () => { + let mockClientInstance: { + connect: jest.Mock; + listTools: jest.Mock; + close: jest.Mock; + getServerVersion: jest.Mock; + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockClientInstance = { + connect: jest.fn().mockResolvedValue(undefined), + listTools: jest.fn().mockResolvedValue({ + tools: [{ name: 'tool-a', description: 'Tool A', inputSchema: { type: 'object' } }], + }), + close: jest.fn().mockResolvedValue(undefined), + getServerVersion: jest.fn().mockReturnValue({ name: 'test-srv', version: '1.0.0' }), + }; + (Client as jest.Mock).mockImplementation(() => mockClientInstance); + }); + + it('should test stdio server and return tools', async () => { + const server: ManagedMcpServer = { + name: 'local', + config: { command: 'node', args: ['server'] }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(true); + expect(result.serverName).toBe('test-srv'); + expect(result.serverVersion).toBe('1.0.0'); + expect(result.tools).toEqual([{ name: 'tool-a', description: 'Tool A', inputSchema: { type: 'object' } }]); + expect(StdioClientTransport).toHaveBeenCalledWith( + expect.objectContaining({ command: 'node', args: ['server'] }), + ); + expect(mockClientInstance.connect).toHaveBeenCalledTimes(1); + expect(mockClientInstance.listTools).toHaveBeenCalledTimes(1); + }); + + it('should fail when stdio command is missing', async () => { + const server: ManagedMcpServer = { + name: 'missing', + config: { command: '' }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(false); + expect(result.error).toBe('Missing command'); + expect(mockClientInstance.connect).not.toHaveBeenCalled(); + }); + + it('should fail for invalid URL', async () => { + const server: ManagedMcpServer = { + name: 'bad-url', + config: { type: 'http', url: 'not-a-valid-url' }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(mockClientInstance.connect).not.toHaveBeenCalled(); + }); + + it('should test http server and return tools', async () => { + const server: ManagedMcpServer = { + name: 'http', + config: { type: 'http', url: 'http://localhost:3000/mcp', headers: { Authorization: 'token' } }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(true); + expect(result.serverName).toBe('test-srv'); + expect(result.serverVersion).toBe('1.0.0'); + expect(result.tools).toEqual([{ name: 'tool-a', description: 'Tool A', inputSchema: { type: 'object' } }]); + expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + fetch: expect.any(Function), + requestInit: { headers: { Authorization: 'token' } }, + }), + ); + }); + + it('should test sse server and return tools', async () => { + const server: ManagedMcpServer = { + name: 'sse', + config: { type: 'sse', url: 'http://localhost:3000/sse', headers: { Authorization: 'token' } }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(true); + expect(result.serverName).toBe('test-srv'); + expect(result.serverVersion).toBe('1.0.0'); + expect(result.tools).toEqual([{ name: 'tool-a', description: 'Tool A', inputSchema: { type: 'object' } }]); + expect(SSEClientTransport).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + fetch: expect.any(Function), + requestInit: { headers: { Authorization: 'token' } }, + }), + ); + }); + + it('should return failure when connect fails', async () => { + mockClientInstance.connect.mockRejectedValue(new Error('Connection refused')); + + const server: ManagedMcpServer = { + name: 'fail', + config: { type: 'http', url: 'http://localhost:3000/mcp' }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(false); + expect(result.error).toBe('Connection refused'); + }); + + it('should return partial success when listTools fails', async () => { + mockClientInstance.listTools.mockRejectedValue(new Error('tools/list not supported')); + + const server: ManagedMcpServer = { + name: 'partial', + config: { type: 'http', url: 'http://localhost:3000/mcp' }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(true); + expect(result.serverName).toBe('test-srv'); + expect(result.serverVersion).toBe('1.0.0'); + expect(result.tools).toEqual([]); + }); + + it('should return timeout error when connection times out', async () => { + jest.useFakeTimers(); + try { + mockClientInstance.connect.mockImplementation( + (_transport: unknown, options?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener('abort', () => reject(new Error('aborted'))); + }), + ); + + const server: ManagedMcpServer = { + name: 'timeout', + config: { type: 'http', url: 'http://localhost:3000/mcp' }, + enabled: true, + contextSaving: false, + }; + + const resultPromise = testMcpServer(server); + jest.advanceTimersByTime(10000); + const result = await resultPromise; + + expect(result.success).toBe(false); + expect(result.error).toBe('Connection timeout (10s)'); + } finally { + jest.useRealTimers(); + } + }); +}); + +// ============================================================================ +// McpServerManager Tests (Unit tests without plugin dependency) +// ============================================================================ + +describe('McpServerManager', () => { + function createManager(servers: ManagedMcpServer[]): McpServerManager { + const manager = new McpServerManager({ + load: jest.fn().mockResolvedValue(servers), + }); + // Directly set the manager's servers for testing + (manager as any).servers = servers; + return manager; + } + + describe('getActiveServers', () => { + const servers: ManagedMcpServer[] = [ + { + name: 'always-on', + config: { command: 'server1' }, + enabled: true, + contextSaving: false, + }, + { + name: 'context-saving', + config: { command: 'server2' }, + enabled: true, + contextSaving: true, + }, + { + name: 'disabled', + config: { command: 'server3' }, + enabled: false, + contextSaving: false, + }, + { + name: 'disabled-context', + config: { command: 'server4' }, + enabled: false, + contextSaving: true, + }, + ]; + + it('should include enabled servers without context-saving', () => { + const manager = createManager(servers); + const result = manager.getActiveServers(new Set()); + + expect(result['always-on']).toBeDefined(); + expect(result['disabled']).toBeUndefined(); + }); + + it('should exclude context-saving servers when not mentioned', () => { + const manager = createManager(servers); + const result = manager.getActiveServers(new Set()); + + expect(result['context-saving']).toBeUndefined(); + }); + + it('should include context-saving servers when mentioned', () => { + const manager = createManager(servers); + const result = manager.getActiveServers(new Set(['context-saving'])); + + expect(result['context-saving']).toBeDefined(); + expect(result['always-on']).toBeDefined(); + }); + + it('should never include disabled servers even when mentioned', () => { + const manager = createManager(servers); + const result = manager.getActiveServers(new Set(['disabled', 'disabled-context'])); + + expect(result['disabled']).toBeUndefined(); + expect(result['disabled-context']).toBeUndefined(); + }); + + it('should return empty object for all disabled servers', () => { + const disabledServers: ManagedMcpServer[] = [ + { name: 's1', config: { command: 'c1' }, enabled: false, contextSaving: false }, + { name: 's2', config: { command: 'c2' }, enabled: false, contextSaving: true }, + ]; + + const manager = createManager(disabledServers); + const result = manager.getActiveServers(new Set(['s1', 's2'])); + + expect(Object.keys(result)).toHaveLength(0); + }); + }); + + describe('getContextSavingServers', () => { + const servers: ManagedMcpServer[] = [ + { name: 's1', config: { command: 'c1' }, enabled: true, contextSaving: true }, + { name: 's2', config: { command: 'c2' }, enabled: true, contextSaving: false }, + { name: 's3', config: { command: 'c3' }, enabled: false, contextSaving: true }, + { name: 's4', config: { command: 'c4' }, enabled: true, contextSaving: true }, + ]; + + it('should return only enabled context-saving servers', () => { + const manager = createManager(servers); + const result = manager.getContextSavingServers(); + + expect(result).toHaveLength(2); + expect(result.map((s) => s.name)).toEqual(['s1', 's4']); + }); + }); + + describe('extractMentions', () => { + const servers: ManagedMcpServer[] = [ + { name: 'context7', config: { command: 'c1' }, enabled: true, contextSaving: true }, + { name: 'always-on', config: { command: 'c2' }, enabled: true, contextSaving: false }, + { name: 'disabled', config: { command: 'c3' }, enabled: false, contextSaving: true }, + ]; + + it('should only extract enabled context-saving mentions', () => { + const manager = createManager(servers); + const result = manager.extractMentions('Use @context7 and @always-on and @disabled'); + + expect(result.size).toBe(1); + expect(result.has('context7')).toBe(true); + }); + + it('should return empty set when no valid mentions exist', () => { + const manager = createManager(servers); + const result = manager.extractMentions('No mentions here'); + + expect(result.size).toBe(0); + }); + }); + + describe('helper methods', () => { + it('should report enabled counts and server presence', () => { + const servers: ManagedMcpServer[] = [ + { name: 's1', config: { command: 'c1' }, enabled: true, contextSaving: true }, + { name: 's2', config: { command: 'c2' }, enabled: true, contextSaving: false }, + { name: 's3', config: { command: 'c3' }, enabled: false, contextSaving: true }, + ]; + const manager = createManager(servers); + + expect(manager.getEnabledCount()).toBe(2); + expect(manager.hasServers()).toBe(true); + }); + + it('should return false when no servers are configured', () => { + const manager = createManager([]); + + expect(manager.getEnabledCount()).toBe(0); + expect(manager.hasServers()).toBe(false); + }); + }); +}); diff --git a/tests/integration/features/chat/imagePersistence.test.ts b/tests/integration/features/chat/imagePersistence.test.ts new file mode 100644 index 0000000..00a4eb8 --- /dev/null +++ b/tests/integration/features/chat/imagePersistence.test.ts @@ -0,0 +1,38 @@ +import type { ChatMessage, ImageAttachment } from '@/core/types'; +import { ChatState } from '@/features/chat/state/ChatState'; + +describe('ChatState persistence', () => { + it('preserves image data when persisting messages', () => { + const state = new ChatState(); + + const images: ImageAttachment[] = [ + { + id: 'img-1', + name: 'test.png', + mediaType: 'image/png', + size: 10, + data: 'YmFzZTY0', + source: 'paste', + }, + ]; + + const messages: ChatMessage[] = [ + { + id: 'msg-1', + role: 'user', + content: 'hello', + timestamp: Date.now(), + images, + }, + ]; + + state.messages = messages; + + const persisted = state.getPersistedMessages(); + + // Image data is preserved (single source of truth) + expect(persisted[0].images?.[0].data).toBe('YmFzZTY0'); + expect(persisted[0].images?.[0].name).toBe('test.png'); + expect(persisted[0].images?.[0].mediaType).toBe('image/png'); + }); +}); diff --git a/tests/integration/main.test.ts b/tests/integration/main.test.ts new file mode 100644 index 0000000..02a848f --- /dev/null +++ b/tests/integration/main.test.ts @@ -0,0 +1,2137 @@ + +import { TOOL_SUBAGENT } from '@/core/tools/toolNames'; +import { VIEW_TYPE_CLAUDIAN } from '@/core/types'; +import * as sdkSession from '@/providers/claude/history/ClaudeHistoryStore'; +import { DEFAULT_SETTINGS } from '@/providers/claude/types/settings'; + +// Mock fs for ClaudianService +jest.mock('fs'); + +// Now import the plugin after mocking +import ClaudianPlugin from '@/main'; + +describe('ClaudianPlugin', () => { + let plugin: ClaudianPlugin; + let mockApp: any; + let mockManifest: any; + + function getRegisteredCommand(commandId: string) { + const call = (plugin.addCommand as jest.Mock).mock.calls.find( + ([config]) => config.id === commandId, + ); + + if (!call) { + throw new Error(`Command ${commandId} was not registered`); + } + + return call[0]; + } + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks(); + jest.spyOn(sdkSession, 'locateSDKSession').mockImplementation(async (_vaultPath, sessionId) => ({ + availability: 'available', + sessionPath: `/test/claude-project/${sessionId}.jsonl`, + })); + jest.spyOn(sdkSession, 'locateSDKSessions').mockImplementation(async (_vaultPath, sessionIds) => new Map( + sessionIds.map(sessionId => [sessionId, { + availability: 'available' as const, + sessionPath: `/test/claude-project/${sessionId}.jsonl`, + }]), + )); + + mockApp = { + vault: { + adapter: { + basePath: '/test/vault', + exists: jest.fn().mockResolvedValue(false), + read: jest.fn().mockResolvedValue(''), + write: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + mkdir: jest.fn().mockResolvedValue(undefined), + list: jest.fn().mockResolvedValue({ files: [], folders: [] }), + stat: jest.fn().mockResolvedValue(null), + rename: jest.fn().mockResolvedValue(undefined), + }, + }, + workspace: { + getLeavesOfType: jest.fn().mockReturnValue([]), + getRightLeaf: jest.fn().mockReturnValue({ + setViewState: jest.fn().mockResolvedValue(undefined), + }), + getLeftLeaf: jest.fn().mockReturnValue({ + setViewState: jest.fn().mockResolvedValue(undefined), + }), + getLeaf: jest.fn().mockReturnValue({ + setViewState: jest.fn().mockResolvedValue(undefined), + }), + setActiveLeaf: jest.fn(), + revealLeaf: jest.fn(), + }, + }; + + mockManifest = { + id: 'claudian', + name: 'Claudian', + version: '0.1.0', + }; + + // Create plugin instance with mocked app + plugin = new ClaudianPlugin(mockApp, mockManifest); + (plugin.loadData as jest.Mock).mockResolvedValue({}); + }); + + describe('onload', () => { + it('should initialize settings with defaults', async () => { + await plugin.onload(); + + expect(plugin.settings).toBeDefined(); + expect(plugin.settings.permissionMode).toBe(DEFAULT_SETTINGS.permissionMode); + expect(plugin.settings.hiddenProviderCommands).toEqual(DEFAULT_SETTINGS.hiddenProviderCommands); + }); + + // Note: With multi-tab, agentService is per-tab via TabManager, not on plugin + + it('should register the view', async () => { + await plugin.onload(); + + expect((plugin.registerView as jest.Mock)).toHaveBeenCalledWith( + VIEW_TYPE_CLAUDIAN, + expect.any(Function) + ); + }); + + it('should add ribbon icon', async () => { + await plugin.onload(); + + expect((plugin.addRibbonIcon as jest.Mock)).toHaveBeenCalledWith( + 'bot', + 'Open Claudian', + expect.any(Function) + ); + }); + + it('should add command to open view', async () => { + await plugin.onload(); + + expect((plugin.addCommand as jest.Mock)).toHaveBeenCalledWith({ + id: 'open-view', + name: 'Open chat view', + callback: expect.any(Function), + }); + }); + + }); + + describe('onunload', () => { + // Note: With multi-tab, cleanup is handled per-tab via ClaudianView.onClose() + it('should complete without error', async () => { + await plugin.onload(); + + expect(() => plugin.onunload()).not.toThrow(); + }); + }); + + describe('activateView', () => { + it('should reveal existing leaf if view already exists', async () => { + const mockLeaf = { id: 'existing-leaf' }; + mockApp.workspace.getLeavesOfType.mockReturnValue([mockLeaf]); + + await plugin.onload(); + await plugin.activateView(); + + expect(mockApp.workspace.revealLeaf).toHaveBeenCalledWith(mockLeaf); + }); + + it('should create new leaf in right sidebar by default if view does not exist', async () => { + const mockRightLeaf = { + setViewState: jest.fn().mockResolvedValue(undefined), + }; + mockApp.workspace.getLeavesOfType.mockReturnValue([]); + mockApp.workspace.getRightLeaf.mockReturnValue(mockRightLeaf); + + await plugin.onload(); + await plugin.activateView(); + + expect(mockApp.workspace.getRightLeaf).toHaveBeenCalledWith(false); + expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({ + type: VIEW_TYPE_CLAUDIAN, + active: true, + }); + }); + + it('should create new leaf in left sidebar when chatViewPlacement is left-sidebar', async () => { + const mockLeftLeaf = { + setViewState: jest.fn().mockResolvedValue(undefined), + }; + mockApp.workspace.getLeavesOfType.mockReturnValue([]); + mockApp.workspace.getLeftLeaf.mockReturnValue(mockLeftLeaf); + + await plugin.onload(); + plugin.settings.chatViewPlacement = 'left-sidebar'; + await plugin.activateView(); + + expect(mockApp.workspace.getLeftLeaf).toHaveBeenCalledWith(false); + expect(mockApp.workspace.getRightLeaf).not.toHaveBeenCalled(); + expect(mockApp.workspace.getLeaf).not.toHaveBeenCalled(); + expect(mockLeftLeaf.setViewState).toHaveBeenCalledWith({ + type: VIEW_TYPE_CLAUDIAN, + active: true, + }); + }); + + it('should handle null right leaf gracefully', async () => { + mockApp.workspace.getLeavesOfType.mockReturnValue([]); + mockApp.workspace.getRightLeaf.mockReturnValue(null); + + await plugin.onload(); + + // Should not throw + await expect(plugin.activateView()).resolves.not.toThrow(); + }); + + it('should create new leaf in main editor area when chatViewPlacement is main-tab', async () => { + const mockMainLeaf = { + setViewState: jest.fn().mockResolvedValue(undefined), + }; + mockApp.workspace.getLeavesOfType.mockReturnValue([]); + mockApp.workspace.getLeaf.mockReturnValue(mockMainLeaf); + + await plugin.onload(); + plugin.settings.chatViewPlacement = 'main-tab'; + await plugin.activateView(); + + expect(mockApp.workspace.getLeaf).toHaveBeenCalledWith('tab'); + expect(mockApp.workspace.getRightLeaf).not.toHaveBeenCalled(); + expect(mockApp.workspace.getLeftLeaf).not.toHaveBeenCalled(); + expect(mockMainLeaf.setViewState).toHaveBeenCalledWith({ + type: VIEW_TYPE_CLAUDIAN, + active: true, + }); + }); + + it('should handle null main leaf gracefully when chatViewPlacement is main-tab', async () => { + mockApp.workspace.getLeavesOfType.mockReturnValue([]); + mockApp.workspace.getLeaf.mockReturnValue(null); + + await plugin.onload(); + plugin.settings.chatViewPlacement = 'main-tab'; + + await expect(plugin.activateView()).resolves.not.toThrow(); + }); + }); + + describe('loadSettings', () => { + it('should merge saved data with defaults', async () => { + // Mock claudian-settings.json exists with custom values (Claudian-specific settings) + mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { + return path === '.claudian/claudian-settings.json'; + }); + mockApp.vault.adapter.read.mockImplementation(async (path: string) => { + if (path === '.claudian/claudian-settings.json') { + return JSON.stringify({ + userName: 'TestUser', + }); + } + return ''; + }); + + await plugin.loadSettings(); + + expect(plugin.settings.userName).toBe('TestUser'); + expect(plugin.settings.hiddenProviderCommands).toEqual(DEFAULT_SETTINGS.hiddenProviderCommands); + }); + + it('should strip legacy blocklist fields when loading old settings', async () => { + mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { + return path === '.claudian/claudian-settings.json'; + }); + mockApp.vault.adapter.read.mockImplementation(async (path: string) => { + if (path === '.claudian/claudian-settings.json') { + return JSON.stringify({ + enableBlocklist: false, + blockedCommands: { unix: ['rm -rf', ' '] }, + }); + } + return ''; + }); + + await plugin.loadSettings(); + + expect('enableBlocklist' in plugin.settings).toBe(false); + expect('blockedCommands' in plugin.settings).toBe(false); + expect(mockApp.vault.adapter.write).toHaveBeenCalledWith( + '.claudian/claudian-settings.json', + expect.any(String), + ); + const writeCall = (mockApp.vault.adapter.write as jest.Mock).mock.calls.find( + ([path]) => path === '.claudian/claudian-settings.json', + ); + expect(writeCall).toBeDefined(); + const content = JSON.parse(writeCall[1]); + expect(content).not.toHaveProperty('enableBlocklist'); + expect(content).not.toHaveProperty('blockedCommands'); + }); + + it('should use defaults when no saved data', async () => { + // No settings file exists + mockApp.vault.adapter.exists.mockResolvedValue(false); + (plugin.loadData as jest.Mock).mockResolvedValue(null); + + await plugin.loadSettings(); + + expect(plugin.settings).toEqual(DEFAULT_SETTINGS); + }); + + it('should use defaults when loadData returns empty object', async () => { + // No settings file exists + mockApp.vault.adapter.exists.mockResolvedValue(false); + (plugin.loadData as jest.Mock).mockResolvedValue({}); + + await plugin.loadSettings(); + + expect(plugin.settings).toEqual(DEFAULT_SETTINGS); + }); + + it('should migrate legacy openInMainTab true to main-tab placement', async () => { + mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { + return path === '.claudian/claudian-settings.json'; + }); + mockApp.vault.adapter.read.mockImplementation(async (path: string) => { + if (path === '.claudian/claudian-settings.json') { + return JSON.stringify({ openInMainTab: true }); + } + return ''; + }); + + await plugin.loadSettings(); + + expect(plugin.settings.chatViewPlacement).toBe('main-tab'); + const writeCall = (mockApp.vault.adapter.write as jest.Mock).mock.calls.find( + ([path]) => path === '.claudian/claudian-settings.json', + ); + expect(writeCall).toBeDefined(); + const content = JSON.parse(writeCall[1]); + expect(content.chatViewPlacement).toBe('main-tab'); + expect(content).not.toHaveProperty('openInMainTab'); + }); + + it('should reconcile model from environment and persist when changed', async () => { + // Mock claudian-settings.json with environment variables + mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { + return path === '.claudian/claudian-settings.json'; + }); + mockApp.vault.adapter.read.mockImplementation(async (path: string) => { + if (path === '.claudian/claudian-settings.json') { + return JSON.stringify({ + environmentVariables: 'ANTHROPIC_MODEL=custom-model', + lastEnvHash: '', + }); + } + return ''; + }); + + const saveSpy = jest.spyOn(plugin, 'saveSettings'); + await plugin.loadSettings(); + + expect(plugin.settings.model).toBe('claude-code/custom-model'); + expect(saveSpy).toHaveBeenCalled(); + }); + }); + + describe('saveSettings', () => { + it('should save settings to file', async () => { + await plugin.onload(); + + await plugin.saveSettings(); + + // Claudian-specific settings should be written to .claudian/claudian-settings.json + expect(mockApp.vault.adapter.write).toHaveBeenCalledWith( + '.claudian/claudian-settings.json', + expect.any(String) + ); + + // The written content should include state fields + const writeCall = (mockApp.vault.adapter.write as jest.Mock).mock.calls.find( + ([path]) => path === '.claudian/claudian-settings.json' + ); + expect(writeCall).toBeDefined(); + const content = JSON.parse(writeCall[1]); + expect(content).not.toHaveProperty('activeConversationId'); + expect(content).toHaveProperty('providerConfigs.claude.environmentHash'); + expect(content).toHaveProperty('providerConfigs.claude.lastModel'); + expect(content).toHaveProperty('lastCustomModel'); + expect(content).not.toHaveProperty('enableBlocklist'); + expect(content).not.toHaveProperty('blockedCommands'); + // Permissions are now in .claude/settings.json (CC format), not claudian-settings.json + expect(content).not.toHaveProperty('permissions'); + }); + }); + + describe('applyEnvironmentVariables', () => { + it('updates runtime env vars when changed', async () => { + await plugin.onload(); + + await plugin.applyEnvironmentVariables('shared', 'A=2'); + expect(plugin.getEnvironmentVariablesForScope('shared')).toBe('A=2'); + + await plugin.applyEnvironmentVariables('shared', 'A=3'); + expect(plugin.getEnvironmentVariablesForScope('shared')).toBe('A=3'); + + // No change - should not update + const currentEnv = plugin.getEnvironmentVariablesForScope('shared'); + await plugin.applyEnvironmentVariables('shared', 'A=3'); + expect(plugin.getEnvironmentVariablesForScope('shared')).toBe(currentEnv); + }); + + it('invalidates sessions when env hash changes', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation({ sessionId: 'session-123' }); + const saveMetadataSpy = jest.spyOn(plugin.storage.sessions, 'saveMetadata'); + saveMetadataSpy.mockClear(); + + await plugin.applyEnvironmentVariables('provider:claude', 'ANTHROPIC_MODEL=claude-sonnet-4-5'); + + const updated = await plugin.getConversationById(conv.id); + expect(updated?.sessionId).toBeNull(); + expect(saveMetadataSpy).toHaveBeenCalled(); + }); + + it('broadcasts ensureReady with force when env changes without model change', async () => { + await plugin.onload(); + + // Mock one open view with an initialized Claude runtime. + const mockSyncConversationState = jest.fn(); + const mockEnsureReady = jest.fn().mockResolvedValue(true); + const mockTabManager = { + getAllTabs: jest.fn().mockReturnValue([{ + providerId: 'claude', + conversationId: null, + state: { isStreaming: false }, + serviceInitialized: true, + service: { + ensureReady: mockEnsureReady, + syncConversationState: mockSyncConversationState, + }, + ui: { externalContextSelector: { getExternalContexts: jest.fn().mockReturnValue([]) } }, + }]), + }; + const mockView = { + getTabManager: jest.fn().mockReturnValue(mockTabManager), + invalidateProviderCommandCaches: jest.fn(), + refreshModelSelector: jest.fn(), + }; + jest.spyOn(plugin, 'getAllViews').mockReturnValue([mockView as any]); + + // Change env but not in a way that affects model + await plugin.applyEnvironmentVariables('shared', 'SOME_VAR=value'); + + expect(mockSyncConversationState).toHaveBeenCalledWith(null, []); + expect(mockEnsureReady).toHaveBeenCalledWith({ force: true }); + }); + + it('restarts affected runtimes in every open Claudian view', async () => { + await plugin.onload(); + + const createView = () => { + const ensureReady = jest.fn().mockResolvedValue(true); + const tabManager = { + getAllTabs: jest.fn().mockReturnValue([{ + providerId: 'claude', + conversationId: null, + state: { isStreaming: false }, + serviceInitialized: true, + service: { + ensureReady, + syncConversationState: jest.fn(), + }, + ui: { externalContextSelector: { getExternalContexts: jest.fn().mockReturnValue([]) } }, + }]), + }; + return { + ensureReady, + view: { + getTabManager: jest.fn().mockReturnValue(tabManager), + invalidateProviderCommandCaches: jest.fn(), + refreshModelSelector: jest.fn(), + }, + }; + }; + const first = createView(); + const second = createView(); + jest.spyOn(plugin, 'getAllViews').mockReturnValue([ + first.view as any, + second.view as any, + ]); + + await plugin.applyEnvironmentVariables('shared', 'SOME_VAR=value'); + + expect(first.ensureReady).toHaveBeenCalledWith({ force: true }); + expect(second.ensureReady).toHaveBeenCalledWith({ force: true }); + }); + + it('syncs live external contexts before restarting invalidated Claude runtimes', async () => { + await plugin.onload(); + + const conversation = await plugin.createConversation({ + providerId: 'claude', + sessionId: 'session-123', + }); + await plugin.updateConversation(conversation.id, { + externalContextPaths: ['/saved/context'], + messages: [{ + content: 'hi', + id: 'msg-1', + role: 'user', + timestamp: Date.now(), + userMessageId: 'msg-1', + }], + }); + + const mockSyncConversationState = jest.fn(); + const mockResetSession = jest.fn(); + const mockEnsureReady = jest.fn().mockResolvedValue(true); + const mockTabManager = { + getAllTabs: jest.fn().mockReturnValue([{ + conversationId: conversation.id, + providerId: 'claude', + state: { isStreaming: false }, + serviceInitialized: true, + service: { + ensureReady: mockEnsureReady, + resetSession: mockResetSession, + syncConversationState: mockSyncConversationState, + }, + ui: { externalContextSelector: { getExternalContexts: jest.fn().mockReturnValue(['/live/context']) } }, + }]), + }; + const mockView = { + getTabManager: jest.fn().mockReturnValue(mockTabManager), + invalidateProviderCommandCaches: jest.fn(), + refreshModelSelector: jest.fn(), + }; + jest.spyOn(plugin, 'getAllViews').mockReturnValue([mockView as any]); + + await plugin.applyEnvironmentVariables('provider:claude', 'ANTHROPIC_MODEL=claude-sonnet-4-5'); + + expect(mockSyncConversationState).toHaveBeenCalledWith( + expect.objectContaining({ id: conversation.id }), + ['/live/context'], + ); + expect(mockResetSession).toHaveBeenCalledTimes(1); + expect(mockEnsureReady).toHaveBeenCalledWith(); + }); + }); + + describe('ribbon icon callback', () => { + it('reveals existing view when ribbon icon is clicked', async () => { + await plugin.onload(); + const mockLeaf = { id: 'existing' }; + mockApp.workspace.getLeavesOfType.mockReturnValue([mockLeaf]); + + const ribbonCallback = (plugin.addRibbonIcon as jest.Mock).mock.calls[0][2]; + await ribbonCallback(); + + expect(mockApp.workspace.revealLeaf).toHaveBeenCalledWith(mockLeaf); + }); + }); + + describe('command callback', () => { + it('reveals existing view when command is executed', async () => { + await plugin.onload(); + const mockLeaf = { id: 'existing' }; + mockApp.workspace.getLeavesOfType.mockReturnValue([mockLeaf]); + + const commandConfig = (plugin.addCommand as jest.Mock).mock.calls[0][0]; + await commandConfig.callback(); + + expect(mockApp.workspace.revealLeaf).toHaveBeenCalledWith(mockLeaf); + }); + }); + + describe('new-tab command', () => { + it('opens the view without creating a duplicate tab when no tab layout is persisted', async () => { + await plugin.onload(); + + const createNewTab = jest.fn().mockResolvedValue(undefined); + const mockView = { + createNewTab, + }; + + let viewOpened = false; + jest.spyOn(plugin, 'activateView').mockImplementation(async () => { + viewOpened = true; + }); + jest.spyOn(plugin, 'getView').mockImplementation(() => ( + viewOpened ? mockView as any : null + )); + + const command = getRegisteredCommand('new-tab'); + + expect(command.checkCallback(true)).toBe(true); + expect(command.checkCallback(false)).toBe(true); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(plugin.activateView).toHaveBeenCalledTimes(1); + expect(createNewTab).not.toHaveBeenCalled(); + }); + + it('creates a new tab after reopening a persisted tab layout', async () => { + (plugin.loadData as jest.Mock).mockResolvedValue({ + tabManagerState: { + openTabs: [ + { tabId: 'tab-1', conversationId: null }, + ], + activeTabId: 'tab-1', + }, + }); + + await plugin.onload(); + + const createNewTab = jest.fn().mockResolvedValue(undefined); + const mockView = { + createNewTab, + }; + + let viewOpened = false; + jest.spyOn(plugin, 'activateView').mockImplementation(async () => { + viewOpened = true; + }); + jest.spyOn(plugin, 'getView').mockImplementation(() => ( + viewOpened ? mockView as any : null + )); + + const command = getRegisteredCommand('new-tab'); + + expect(command.checkCallback(true)).toBe(true); + expect(command.checkCallback(false)).toBe(true); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(plugin.activateView).toHaveBeenCalledTimes(1); + expect(createNewTab).toHaveBeenCalledTimes(1); + }); + + it('stays unavailable when the open view is already at the tab limit', async () => { + await plugin.onload(); + + const mockView = { + getTabManager: jest.fn().mockReturnValue({ + canCreateTab: jest.fn().mockReturnValue(false), + }), + }; + + jest.spyOn(plugin, 'getView').mockReturnValue(mockView as any); + + const command = getRegisteredCommand('new-tab'); + + expect(command.checkCallback(true)).toBe(false); + }); + + it('keeps tab commands unavailable while a Claudian leaf view is not initialized', async () => { + await plugin.onload(); + + mockApp.workspace.getLeavesOfType.mockReturnValue([{ view: {} }]); + + for (const commandId of ['new-tab', 'new-session', 'close-current-tab']) { + const command = getRegisteredCommand(commandId); + + expect(() => command.checkCallback(true)).not.toThrow(); + expect(command.checkCallback(true)).toBe(false); + } + }); + + it('stays unavailable when reopening the persisted layout would already hit the tab limit', async () => { + (plugin.loadData as jest.Mock).mockResolvedValue({ + tabManagerState: { + openTabs: [ + { tabId: 'tab-1', conversationId: null }, + { tabId: 'tab-2', conversationId: null }, + { tabId: 'tab-3', conversationId: null }, + ], + activeTabId: 'tab-3', + }, + }); + + await plugin.onload(); + + jest.spyOn(plugin, 'getView').mockReturnValue(null); + + const command = getRegisteredCommand('new-tab'); + + expect(command.checkCallback(true)).toBe(false); + }); + }); + + describe('createConversation', () => { + it('should create a new conversation with unique ID', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + + expect(conv.id).toMatch(/^conv-\d+-[a-z0-9]+$/); + expect(conv.messages).toEqual([]); + expect(conv.sessionId).toBeNull(); + }); + + it('should allow retrieving created conversation by ID', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + const fetched = await plugin.getConversationById(conv.id); + + expect(fetched?.id).toBe(conv.id); + }); + + it('should store the selected model in conversation metadata', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation({ selectedModel: 'opus' }); + const fetched = await plugin.getConversationById(conv.id); + + expect(conv.selectedModel).toBe('opus'); + expect(fetched?.selectedModel).toBe('opus'); + }); + + it('should preserve custom selected models that are not in picker options', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation({ + providerId: 'codex', + selectedModel: 'gpt-5.4-experimental', + }); + const fetched = await plugin.getConversationById(conv.id); + + expect(conv.selectedModel).toBe('gpt-5.4-experimental'); + expect(fetched?.selectedModel).toBe('gpt-5.4-experimental'); + }); + + it('should lazily migrate missing selected model from usage metadata', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + delete (conv as { selectedModel?: string }).selectedModel; + conv.usage = { + model: 'opus', + inputTokens: 1, + contextTokens: 1, + contextWindow: 200000, + percentage: 1, + }; + const saveMetadataSpy = jest.spyOn(plugin.storage.sessions, 'saveMetadata'); + saveMetadataSpy.mockClear(); + + const fetched = await plugin.getConversationById(conv.id); + + expect(fetched?.selectedModel).toBe('opus'); + expect(saveMetadataSpy).toHaveBeenCalledWith(expect.objectContaining({ + selectedModel: 'opus', + })); + }); + + it('should not permanently default legacy conversations with unknown model metadata', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + delete (conv as { selectedModel?: string }).selectedModel; + const saveMetadataSpy = jest.spyOn(plugin.storage.sessions, 'saveMetadata'); + saveMetadataSpy.mockClear(); + + const fetched = await plugin.getConversationById(conv.id); + + expect(fetched?.selectedModel).toBeUndefined(); + expect(saveMetadataSpy).not.toHaveBeenCalled(); + }); + + it('should generate default title with timestamp', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + + // Title should contain month and time + expect(conv.title).toBeTruthy(); + expect(conv.title.length).toBeGreaterThan(0); + }); + + // Note: Session management is now per-tab via TabManager + }); + + describe('switchConversation', () => { + it('should switch to existing conversation', async () => { + await plugin.onload(); + + const conv1 = await plugin.createConversation(); + await plugin.createConversation(); // Create second conversation to switch from + + const result = await plugin.switchConversation(conv1.id); + + expect(result?.id).toBe(conv1.id); + }); + + // Note: Session ID restoration is now handled per-tab via TabManager + + it('should return null for non-existent conversation', async () => { + await plugin.onload(); + + const result = await plugin.switchConversation('non-existent-id'); + + expect(result).toBeNull(); + }); + + it('should preserve a conversation when local Claude history is missing', async () => { + await plugin.onload(); + const conversation = await plugin.createConversation({ + sessionId: 'session-removed-after-startup', + }); + const availabilitySpy = jest.mocked(sdkSession.locateSDKSession) + .mockResolvedValue({ availability: 'missing' }); + + const result = await plugin.switchConversation(conversation.id); + + expect(result?.id).toBe(conversation.id); + expect(plugin.getConversationList()).toHaveLength(1); + expect(mockApp.vault.adapter.remove).not.toHaveBeenCalledWith( + '.claudian/sessions/session-removed-after-startup.meta.json', + ); + availabilitySpy.mockRestore(); + }); + + it('should preserve a conversation whose Claude session belongs to a previous vault path', async () => { + await plugin.onload(); + const conversation = await plugin.createConversation({ + sessionId: 'session-from-previous-vault-path', + }); + const availabilitySpy = jest.mocked(sdkSession.locateSDKSession) + .mockResolvedValue({ + availability: 'relocated', + sessionPath: '/old-project/session-from-previous-vault-path.jsonl', + }); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages') + .mockResolvedValue({ messages: [], skippedLines: 0 }); + + const result = await plugin.switchConversation(conversation.id); + + expect(result?.id).toBe(conversation.id); + expect(plugin.getConversationList()).toHaveLength(1); + expect(result?.sessionId).toBeNull(); + expect(result?.providerState).toEqual(expect.objectContaining({ + previousProviderSessionIds: ['session-from-previous-vault-path'], + })); + expect(mockApp.vault.adapter.remove).not.toHaveBeenCalledWith( + '.claudian/sessions/session-from-previous-vault-path.meta.json', + ); + availabilitySpy.mockRestore(); + loadSpy.mockRestore(); + }); + + it('should restore resume metadata when relocated-state persistence fails', async () => { + await plugin.onload(); + const conversation = await plugin.createConversation({ + sessionId: 'session-relocation-save-failure', + }); + const availabilitySpy = jest.mocked(sdkSession.locateSDKSession) + .mockResolvedValue({ + availability: 'relocated', + sessionPath: '/old-project/session-relocation-save-failure.jsonl', + }); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages') + .mockResolvedValue({ messages: [], skippedLines: 0 }); + const saveSpy = jest.spyOn(plugin.storage.sessions, 'saveMetadata') + .mockRejectedValueOnce(new Error('Write failed')); + + const result = await plugin.switchConversation(conversation.id); + + expect(result?.sessionId).toBe('session-relocation-save-failure'); + expect(result?.providerState).toBeUndefined(); + + availabilitySpy.mockRestore(); + loadSpy.mockRestore(); + saveSpy.mockRestore(); + }); + }); + + describe('deleteConversation', () => { + it('should delete conversation by ID', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + const convId = conv.id; + + // Create another so we have at least one left + await plugin.createConversation(); + + await plugin.deleteConversation(convId); + + const list = plugin.getConversationList(); + expect(list.find(c => c.id === convId)).toBeUndefined(); + }); + + it('should allow deleting last conversation without recreating', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.deleteConversation(conv.id); + + const list = plugin.getConversationList(); + expect(list.find(c => c.id === conv.id)).toBeUndefined(); + }); + + it('should preserve the provider-native session when requested', async () => { + await plugin.onload(); + const conv = await plugin.createConversation({ sessionId: 'provider-session-1' }); + const deleteNativeSpy = jest.spyOn(sdkSession, 'deleteSDKSession'); + + await plugin.deleteConversation(conv.id, { deleteProviderSession: false }); + + expect(deleteNativeSpy).not.toHaveBeenCalled(); + expect(plugin.getConversationList().find(item => item.id === conv.id)).toBeUndefined(); + deleteNativeSpy.mockRestore(); + }); + + it('should reset every open tab that references the deleted conversation', async () => { + await plugin.onload(); + const conv = await plugin.createConversation(); + const cancelStreaming = jest.fn(); + const createNew = jest.fn().mockResolvedValue(undefined); + mockApp.workspace.getLeavesOfType.mockReturnValue([{ + view: { + getTabManager: () => ({ + getAllTabs: () => [{ + conversationId: conv.id, + controllers: { + inputController: { cancelStreaming }, + conversationController: { createNew }, + }, + }], + }), + }, + }]); + + await plugin.deleteConversation(conv.id, { deleteProviderSession: false }); + + expect(cancelStreaming).toHaveBeenCalledTimes(1); + expect(createNew).toHaveBeenCalledWith({ force: true }); + }); + }); + + describe('handleMissingProviderSession', () => { + it('preserves the record when the provider cannot verify a safe disposition', async () => { + await plugin.onload(); + const conv = await plugin.createConversation({ + providerId: 'codex', + sessionId: 'unverified-provider-session', + }); + + await expect(plugin.handleMissingProviderSession( + conv.id, + 'unverified-provider-session', + )).resolves.toBe('preserved'); + expect(plugin.getConversationSync(conv.id)).toBe(conv); + }); + + it('removes the record when every provider transcript segment is missing', async () => { + await plugin.onload(); + const conv = await plugin.createConversation({ sessionId: 'missing-current' }); + jest.mocked(sdkSession.locateSDKSessions).mockResolvedValue(new Map([ + ['missing-current', { availability: 'missing' }], + ])); + + await expect(plugin.handleMissingProviderSession( + conv.id, + 'missing-current', + )).resolves.toBe('deleted'); + expect(plugin.getConversationList().find(item => item.id === conv.id)).toBeUndefined(); + }); + + it('preserves the record and clears resume state when older history is inaccessible', async () => { + await plugin.onload(); + const conv = await plugin.createConversation({ sessionId: 'missing-current' }); + await plugin.updateConversation(conv.id, { + providerState: { + providerSessionId: 'missing-current', + previousProviderSessionIds: ['temporarily-inaccessible'], + }, + }); + jest.mocked(sdkSession.locateSDKSessions).mockResolvedValue(new Map([ + ['temporarily-inaccessible', { availability: 'unknown' }], + ['missing-current', { availability: 'missing' }], + ])); + + await expect(plugin.handleMissingProviderSession( + conv.id, + 'missing-current', + )).resolves.toBe('reset'); + + const preserved = plugin.getConversationSync(conv.id); + expect(preserved?.sessionId).toBeNull(); + expect(preserved?.providerState).toEqual({ + previousProviderSessionIds: ['temporarily-inaccessible'], + }); + }); + + it('preserves metadata when the missing-session disposition cannot be read', async () => { + await plugin.onload(); + const conv = await plugin.createConversation({ sessionId: 'missing-current' }); + jest.mocked(sdkSession.locateSDKSessions).mockRejectedValueOnce(new Error('EACCES')); + + await expect(plugin.handleMissingProviderSession( + conv.id, + 'missing-current', + )).resolves.toBe('preserved'); + expect(plugin.getConversationSync(conv.id)?.sessionId).toBe('missing-current'); + }); + }); + + describe('renameConversation', () => { + it('should rename conversation', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + + await plugin.renameConversation(conv.id, 'New Title'); + + const updated = await plugin.getConversationById(conv.id); + expect(updated?.title).toBe('New Title'); + }); + + it('should use default title if empty string provided', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + + await plugin.renameConversation(conv.id, ' '); + + const updated = await plugin.getConversationById(conv.id); + expect(updated?.title).toBeTruthy(); + }); + }); + + describe('updateConversation', () => { + it('should update conversation messages', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + const messages = [ + { id: 'msg-1', role: 'user' as const, content: 'Hello', timestamp: Date.now() }, + ]; + + await plugin.updateConversation(conv.id, { messages }); + + const updated = await plugin.getConversationById(conv.id); + expect(updated?.messages).toEqual(messages); + }); + + it('should preserve image data when updating conversation messages', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + const messages = [ + { + id: 'msg-1', + role: 'user' as const, + content: 'See attached image', + timestamp: Date.now(), + images: [ + { + id: 'img-1', + name: 'pasted.png', + mediaType: 'image/png' as const, + data: 'YmFzZTY0', + size: 10, + source: 'paste' as const, + }, + ], + }, + ]; + + await plugin.updateConversation(conv.id, { messages }); + + const updated = await plugin.getConversationById(conv.id); + expect(updated?.messages[0].images?.[0].data).toBe('YmFzZTY0'); + }); + + it('should update conversation sessionId', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + + await plugin.updateConversation(conv.id, { sessionId: 'new-session-id' }); + + const updated = await plugin.getConversationById(conv.id); + expect(updated?.sessionId).toBe('new-session-id'); + }); + + it('should update updatedAt timestamp', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + const originalUpdatedAt = conv.updatedAt; + + // Small delay to ensure timestamp differs + await new Promise(resolve => setTimeout(resolve, 10)); + + await plugin.updateConversation(conv.id, { title: 'Changed' }); + + const updated = await plugin.getConversationById(conv.id); + expect(updated?.updatedAt).toBeGreaterThan(originalUpdatedAt); + }); + }); + + describe('getConversationList', () => { + it('should return conversation metadata', async () => { + await plugin.onload(); + + await plugin.createConversation(); + + const list = plugin.getConversationList(); + + expect(list.length).toBeGreaterThan(0); + expect(list[0]).toHaveProperty('id'); + expect(list[0]).toHaveProperty('title'); + expect(list[0]).toHaveProperty('messageCount'); + expect(list[0]).toHaveProperty('preview'); + }); + + it('should return preview from first user message', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + messages: [ + { id: 'msg-1', role: 'user', content: 'Hello Claude', timestamp: Date.now() }, + ], + }); + + const list = plugin.getConversationList(); + const meta = list.find(c => c.id === conv.id); + + expect(meta?.preview).toContain('Hello Claude'); + }); + }); + + describe('loadSettings with conversations', () => { + it('should preserve Claude metadata during startup when local native history is missing', async () => { + const timestamp = Date.now(); + const sessionMeta = JSON.stringify({ + id: 'conv-stale-1', + providerId: 'claude', + title: 'Stale Chat', + createdAt: timestamp, + updatedAt: timestamp, + sessionId: 'missing-session', + }); + + mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { + return path === '.claudian/claudian-settings.json' + || path === '.claudian/sessions' + || path === '.claudian/sessions/conv-stale-1.meta.json'; + }); + mockApp.vault.adapter.list.mockImplementation(async (path: string) => { + if (path === '.claudian/sessions') { + return { files: ['.claudian/sessions/conv-stale-1.meta.json'], folders: [] }; + } + return { files: [], folders: [] }; + }); + mockApp.vault.adapter.read.mockImplementation(async (path: string) => { + if (path === '.claudian/sessions/conv-stale-1.meta.json') { + return sessionMeta; + } + if (path === '.claudian/claudian-settings.json') { + return JSON.stringify({}); + } + return ''; + }); + + await plugin.loadSettings(); + + expect(plugin.getConversationList()).toHaveLength(1); + expect(mockApp.vault.adapter.remove).not.toHaveBeenCalledWith( + '.claudian/sessions/conv-stale-1.meta.json', + ); + }); + + it('should load saved conversations from metadata files', async () => { + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const timestamp = Date.now(); + const sessionMeta = JSON.stringify({ + id: 'conv-saved-1', + title: 'Saved Chat', + createdAt: timestamp, + updatedAt: timestamp, + sessionId: 'saved-session', + }); + + // Mock files exist + mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { + // Session files + if (path === '.claudian/sessions' || path === '.claudian/sessions/conv-saved-1.meta.json') { + return true; + } + // claudian-settings.json exists + if (path === '.claudian/claudian-settings.json') { + return true; + } + return false; + }); + mockApp.vault.adapter.list.mockImplementation(async (path: string) => { + if (path === '.claudian/sessions') { + return { files: ['.claudian/sessions/conv-saved-1.meta.json'], folders: [] }; + } + return { files: [], folders: [] }; + }); + mockApp.vault.adapter.read.mockImplementation(async (path: string) => { + if (path === '.claudian/sessions/conv-saved-1.meta.json') { + return sessionMeta; + } + if (path === '.claudian/claudian-settings.json') { + return JSON.stringify({}); + } + return ''; + }); + + // data.json is minimal (no state - already migrated) + (plugin.loadData as jest.Mock).mockResolvedValue({}); + + await plugin.loadSettings(); + + const loaded = await plugin.getConversationById('conv-saved-1'); + expect(loaded?.id).toBe('conv-saved-1'); + expect(loaded?.title).toBe('Saved Chat'); + existsSpy.mockRestore(); + }); + + it('should clear session IDs when provider base URL changes', async () => { + const timestamp = Date.now(); + const sessionMeta = JSON.stringify({ + id: 'conv-saved-1', + title: 'Saved Chat', + createdAt: timestamp, + updatedAt: timestamp, + sessionId: 'saved-session', + }); + + mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { + return path === '.claudian/claudian-settings.json' || + path === '.claudian/sessions' || + path === '.claudian/sessions/conv-saved-1.meta.json'; + }); + mockApp.vault.adapter.list.mockImplementation(async (path: string) => { + if (path === '.claudian/sessions') { + return { files: ['.claudian/sessions/conv-saved-1.meta.json'], folders: [] }; + } + return { files: [], folders: [] }; + }); + mockApp.vault.adapter.read.mockImplementation(async (path: string) => { + if (path === '.claudian/claudian-settings.json') { + // All these fields are now in claudian-settings.json + return JSON.stringify({ + lastEnvHash: 'old-hash', + environmentVariables: 'ANTHROPIC_BASE_URL=https://api.example.com', + }); + } + if (path === '.claudian/sessions/conv-saved-1.meta.json') { + return sessionMeta; + } + return ''; + }); + + // data.json is minimal (already migrated) + (plugin.loadData as jest.Mock).mockResolvedValue({}); + + await plugin.loadSettings(); + + const loaded = await plugin.getConversationById('conv-saved-1'); + expect(loaded?.sessionId).toBeNull(); + + const sessionWrite = (mockApp.vault.adapter.write as jest.Mock).mock.calls.find( + ([path]) => path === '.claudian/sessions/conv-saved-1.meta.json' + ); + expect(sessionWrite).toBeDefined(); + const meta = JSON.parse(sessionWrite?.[1] as string); + expect(meta.sessionId).toBeNull(); + }); + + it('should ignore legacy activeConversationId when no sessions exist', async () => { + // No sessions exist + mockApp.vault.adapter.exists.mockResolvedValue(false); + mockApp.vault.adapter.list.mockResolvedValue({ files: [], folders: [] }); + + (plugin.loadData as jest.Mock).mockResolvedValue({ + activeConversationId: 'non-existent', + migrationVersion: 2, + }); + + await plugin.loadSettings(); + + expect(plugin.getConversationList()).toHaveLength(0); + }); + }); + + describe('Multi-session message loading', () => { + it('should load messages from previousProviderSessionIds when present', async () => { + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const timestamp = Date.now(); + + // Setup conversation with previousProviderSessionIds + const sessionMeta = JSON.stringify({ + type: 'meta', + id: 'conv-multi-session', + title: 'Multi Session Chat', + createdAt: timestamp, + updatedAt: timestamp, + providerState: { + providerSessionId: 'session-B', + previousProviderSessionIds: ['session-A'], + }, + }); + + mockApp.vault.adapter.exists.mockImplementation(async (path: string) => { + return path === '.claudian/claudian-settings.json' || + path === '.claudian/sessions' || + path === '.claudian/sessions/conv-multi-session.meta.json'; + }); + mockApp.vault.adapter.list.mockImplementation(async (path: string) => { + if (path === '.claudian/sessions') { + return { files: ['.claudian/sessions/conv-multi-session.meta.json'], folders: [] }; + } + return { files: [], folders: [] }; + }); + mockApp.vault.adapter.read.mockImplementation(async (path: string) => { + if (path === '.claudian/sessions/conv-multi-session.meta.json') { + return sessionMeta; + } + if (path === '.claudian/claudian-settings.json') { + return JSON.stringify({}); + } + return ''; + }); + + (plugin.loadData as jest.Mock).mockResolvedValue({}); + + await plugin.loadSettings(); + + const loaded = await plugin.getConversationById('conv-multi-session'); + expect((loaded?.providerState as any)?.previousProviderSessionIds).toEqual(['session-A']); + expect((loaded?.providerState as any)?.providerSessionId).toBe('session-B'); + existsSpy.mockRestore(); + }); + + it('should preserve previousProviderSessionIds through conversation updates', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + providerSessionId: 'session-B', + previousProviderSessionIds: ['session-A'], + }, + }); + + const updated = await plugin.getConversationById(conv.id); + expect((updated?.providerState as any)?.previousProviderSessionIds).toEqual(['session-A']); + expect((updated?.providerState as any)?.providerSessionId).toBe('session-B'); + + // Further update should preserve previousProviderSessionIds + await plugin.updateConversation(conv.id, { + title: 'Updated Title', + }); + + const afterTitleUpdate = await plugin.getConversationById(conv.id); + expect((afterTitleUpdate?.providerState as any)?.previousProviderSessionIds).toEqual(['session-A']); + }); + + it('should handle empty previousProviderSessionIds array', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + providerSessionId: 'session-A', + previousProviderSessionIds: [], + }, + }); + + const updated = await plugin.getConversationById(conv.id); + expect((updated?.providerState as any)?.previousProviderSessionIds).toEqual([]); + }); + }); + + describe('loadSdkMessagesForConversation - fork branch', () => { + it('should repair blank image data from Claude SDK history during hydration', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + providerSessionId: 'session-with-image', + }, + messages: [ + { + id: 'user-with-image', + role: 'user', + content: 'See attached image', + timestamp: 1000, + images: [{ + id: 'img-blank', + name: 'pasted.png', + mediaType: 'image/png', + data: '', + size: 0, + source: 'paste', + }], + }, + ], + }); + + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ + messages: [ + { + id: 'user-with-image', + role: 'user', + content: 'See attached image', + timestamp: 1000, + images: [{ + id: 'sdk-img-user-with-image-0', + name: 'image-1', + mediaType: 'image/png', + data: 'aGVsbG8=', + size: 5, + source: 'paste', + }], + }, + ], + skippedLines: 0, + }); + + const loaded = await plugin.getConversationById(conv.id); + + expect(loadSpy).toHaveBeenCalledWith( + expect.any(String), + 'session-with-image', + undefined, + undefined, + expect.any(Object), + ); + expect(loaded?.messages[0].images?.[0]).toMatchObject({ + id: 'img-blank', + data: 'aGVsbG8=', + mediaType: 'image/png', + size: 5, + }); + + existsSpy.mockRestore(); + loadSpy.mockRestore(); + }); + + it('should load from forkSource.sessionId and truncate at forkSource.resumeAt for pending fork', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + forkSource: { sessionId: 'source-session-abc', resumeAt: 'asst-uuid-cutoff' }, + // No providerSessionId → isPendingFork returns true + providerSessionId: undefined, + }, + sessionId: null, + }); + + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ + messages: [ + { id: 'sdk-msg-1', role: 'user', content: 'Hello', timestamp: 1000 }, + { id: 'sdk-msg-2', role: 'assistant', content: 'Hi', timestamp: 1001 }, + ], + skippedLines: 0, + }); + + // Trigger loadSdkMessagesForConversation via public API + const loaded = await plugin.getConversationById(conv.id); + + // Should check existence of source session, not the conversation's own session + expect(sdkSession.locateSDKSession).toHaveBeenCalledWith( + expect.any(String), + 'source-session-abc', + expect.any(Object), + ); + + // Should load from forkSource.sessionId with forkSource.resumeAt as truncation point + expect(loadSpy).toHaveBeenCalledWith( + expect.any(String), + 'source-session-abc', + 'asst-uuid-cutoff', + undefined, + expect.any(Object), + ); + + // Messages should be loaded + expect(loaded?.messages).toBeDefined(); + + existsSpy.mockRestore(); + loadSpy.mockRestore(); + }); + + it('should NOT use fork path when conversation has its own providerSessionId', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + forkSource: { sessionId: 'source-session', resumeAt: 'asst-uuid' }, + providerSessionId: 'own-session-id', + }, + }); + + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ + messages: [], + skippedLines: 0, + }); + + await plugin.getConversationById(conv.id); + + // Should load from own session, not forkSource session + expect(sdkSession.locateSDKSession).toHaveBeenCalledWith( + expect.any(String), + 'own-session-id', + expect.any(Object), + ); + + existsSpy.mockRestore(); + loadSpy.mockRestore(); + }); + }); + + describe('loadSdkMessagesForConversation - subagent recovery', () => { + it('restores subagent data when Task tool exists but subagent content block is missing', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + providerSessionId: 'session-subagent-recovery', + subagentData: { + 'task-1': { + id: 'task-1', + description: 'Recovered subagent', + status: 'completed', + result: 'Recovered result', + toolCalls: [ + { + id: 'sub-tool-1', + name: 'Read', + input: { file_path: 'README.md' }, + status: 'completed', + result: 'content', + } as any, + ], + isExpanded: false, + } as any, + }, + }, + messages: [ + { + id: 'assistant-1', + role: 'assistant', + content: '', + timestamp: 1000, + toolCalls: [ + { + id: 'task-1', + name: 'Task', + input: { description: 'Do sub task' }, + status: 'completed', + result: 'Task completed', + } as any, + ], + // Simulate partial persisted blocks that lost the task tool block. + contentBlocks: [{ type: 'text', content: 'Done' }] as any, + } as any, + ], + }); + + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ + messages: [], + skippedLines: 0, + }); + + const loaded = await plugin.getConversationById(conv.id); + expect(loadSpy).toHaveBeenCalledWith( + expect.any(String), + 'session-subagent-recovery', + undefined, + undefined, + expect.any(Object), + ); + expect(loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-1')).toEqual( + expect.objectContaining({ + subagent: expect.objectContaining({ + id: 'task-1', + description: 'Recovered subagent', + result: 'Recovered result', + }), + }) + ); + expect(loaded?.messages[0].contentBlocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'subagent', subagentId: 'task-1' }), + ]) + ); + + existsSpy.mockRestore(); + loadSpy.mockRestore(); + }); + + it('prefers richer SDK task result over stale cached subagent result', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + providerSessionId: 'session-subagent-merge', + subagentData: { + 'task-merge-1': { + id: 'task-merge-1', + description: 'Recovered subagent', + mode: 'async', + asyncStatus: 'completed', + status: 'completed', + result: 'Short stale result', + toolCalls: [], + isExpanded: false, + } as any, + }, + }, + messages: [ + { + id: 'assistant-merge', + role: 'assistant', + content: '', + timestamp: 1000, + toolCalls: [ + { + id: 'task-merge-1', + name: 'Task', + input: { description: 'Do sub task', run_in_background: true }, + status: 'completed', + result: 'Full SDK result from queue-operation', + } as any, + ], + contentBlocks: [{ type: 'subagent', subagentId: 'task-merge-1', mode: 'async' }] as any, + } as any, + ], + }); + + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ + messages: [], + skippedLines: 0, + }); + + const loaded = await plugin.getConversationById(conv.id); + const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-merge-1'); + + expect(loadSpy).toHaveBeenCalledWith( + expect.any(String), + 'session-subagent-merge', + undefined, + undefined, + expect.any(Object), + ); + expect(taskTool?.result).toBe('Full SDK result from queue-operation'); + expect(taskTool?.subagent?.result).toBe('Full SDK result from queue-operation'); + + existsSpy.mockRestore(); + loadSpy.mockRestore(); + }); + + it('keeps the richer cached async result when both SDK and cache are terminal', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + providerSessionId: 'session-subagent-cache-richer', + subagentData: { + 'task-merge-2': { + id: 'task-merge-2', + description: 'Recovered subagent', + mode: 'async', + asyncStatus: 'completed', + status: 'completed', + result: 'Recovered final result with full details', + toolCalls: [], + isExpanded: false, + agentId: 'agent-cache-richer', + } as any, + }, + }, + messages: [], + }); + + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ + messages: [ + { + id: 'assistant-cache-richer', + role: 'assistant', + content: '', + timestamp: 1000, + toolCalls: [ + { + id: 'task-merge-2', + name: 'Task', + input: { description: 'SDK async subagent', run_in_background: true }, + status: 'completed', + result: 'Short SDK result', + subagent: { + id: 'task-merge-2', + description: 'SDK async subagent', + mode: 'async', + asyncStatus: 'completed', + status: 'completed', + result: 'Short SDK result', + toolCalls: [], + isExpanded: false, + agentId: 'agent-cache-richer', + }, + } as any, + ], + contentBlocks: [{ type: 'subagent', subagentId: 'task-merge-2', mode: 'async' }] as any, + } as any, + ], + skippedLines: 0, + }); + + const loaded = await plugin.getConversationById(conv.id); + const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-merge-2'); + + expect(taskTool?.status).toBe('completed'); + expect(taskTool?.result).toBe('Recovered final result with full details'); + expect(taskTool?.subagent?.result).toBe('Recovered final result with full details'); + + existsSpy.mockRestore(); + loadSpy.mockRestore(); + }); + + it('drops stale asyncStatus from cached sync subagents during recovery', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + providerSessionId: 'session-sync-subagent-cleanup', + subagentData: { + 'task-sync-1': { + id: 'task-sync-1', + description: 'Recovered sync subagent', + mode: 'sync', + asyncStatus: 'completed', + status: 'completed', + result: 'Recovered sync result', + toolCalls: [], + isExpanded: false, + } as any, + }, + }, + messages: [ + { + id: 'assistant-sync', + role: 'assistant', + content: '', + timestamp: 1000, + toolCalls: [ + { + id: 'task-sync-1', + name: 'Task', + input: { description: 'Do sync task' }, + status: 'completed', + result: 'Sync result', + } as any, + ], + contentBlocks: [{ type: 'subagent', subagentId: 'task-sync-1', mode: 'sync' }] as any, + } as any, + ], + }); + + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ + messages: [], + skippedLines: 0, + }); + + const loaded = await plugin.getConversationById(conv.id); + const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-sync-1'); + + expect(taskTool?.subagent?.mode).toBe('sync'); + expect(taskTool?.subagent?.asyncStatus).toBeUndefined(); + + existsSpy.mockRestore(); + loadSpy.mockRestore(); + }); + + it('prefers terminal SDK async status over stale cached running state', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + providerSessionId: 'session-async-sdk-terminal', + subagentData: { + 'task-async-sdk-terminal': { + id: 'task-async-sdk-terminal', + description: 'Cached async subagent', + mode: 'async', + asyncStatus: 'running', + status: 'running', + result: 'Still running', + toolCalls: [], + isExpanded: false, + } as any, + }, + }, + messages: [], + }); + + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ + messages: [ + { + id: 'assistant-sdk-terminal', + role: 'assistant', + content: '', + timestamp: 1000, + toolCalls: [ + { + id: 'task-async-sdk-terminal', + name: 'Task', + input: { description: 'SDK async subagent', run_in_background: true }, + status: 'completed', + result: 'Full SDK final result', + subagent: { + id: 'task-async-sdk-terminal', + description: 'SDK async subagent', + mode: 'async', + asyncStatus: 'completed', + status: 'completed', + result: 'Full SDK final result', + toolCalls: [], + isExpanded: false, + agentId: 'agent-sdk-terminal', + }, + } as any, + ], + contentBlocks: [{ type: 'subagent', subagentId: 'task-async-sdk-terminal', mode: 'async' }] as any, + } as any, + ], + skippedLines: 0, + }); + + const loaded = await plugin.getConversationById(conv.id); + const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-async-sdk-terminal'); + + expect(taskTool?.status).toBe('completed'); + expect(taskTool?.result).toBe('Full SDK final result'); + expect(taskTool?.subagent?.status).toBe('completed'); + expect(taskTool?.subagent?.asyncStatus).toBe('completed'); + expect(taskTool?.subagent?.result).toBe('Full SDK final result'); + + existsSpy.mockRestore(); + loadSpy.mockRestore(); + }); + + it('prefers cached terminal async status over SDK launch-only running state', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + providerSessionId: 'session-async-cache-terminal', + subagentData: { + 'task-async-cache-terminal': { + id: 'task-async-cache-terminal', + description: 'Cached async subagent', + mode: 'async', + asyncStatus: 'completed', + status: 'completed', + result: 'Recovered final result', + toolCalls: [], + isExpanded: false, + agentId: 'agent-cache-terminal', + } as any, + }, + }, + messages: [], + }); + + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ + messages: [ + { + id: 'assistant-sdk-running', + role: 'assistant', + content: '', + timestamp: 1000, + toolCalls: [ + { + id: 'task-async-cache-terminal', + name: 'Task', + input: { description: 'SDK async subagent', run_in_background: true }, + status: 'running', + result: 'Task launched in background.', + subagent: { + id: 'task-async-cache-terminal', + description: 'SDK async subagent', + mode: 'async', + asyncStatus: 'running', + status: 'running', + result: 'Task launched in background.', + toolCalls: [], + isExpanded: false, + agentId: 'agent-cache-terminal', + }, + } as any, + ], + contentBlocks: [{ type: 'subagent', subagentId: 'task-async-cache-terminal', mode: 'async' }] as any, + } as any, + ], + skippedLines: 0, + }); + + const loaded = await plugin.getConversationById(conv.id); + const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-async-cache-terminal'); + + expect(taskTool?.status).toBe('completed'); + expect(taskTool?.result).toBe('Recovered final result'); + expect(taskTool?.subagent?.status).toBe('completed'); + expect(taskTool?.subagent?.asyncStatus).toBe('completed'); + expect(taskTool?.subagent?.result).toBe('Recovered final result'); + + existsSpy.mockRestore(); + loadSpy.mockRestore(); + }); + + it('restores async subagent data and mode when Task tool exists but async block is missing', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + providerSessionId: 'session-async-subagent-recovery', + subagentData: { + 'task-async-1': { + id: 'task-async-1', + description: 'Recovered async subagent', + mode: 'async', + asyncStatus: 'completed', + status: 'completed', + result: 'Recovered async result', + toolCalls: [], + isExpanded: false, + } as any, + }, + }, + messages: [ + { + id: 'assistant-1', + role: 'assistant', + content: '', + timestamp: 1000, + toolCalls: [ + { + id: 'task-async-1', + name: 'Task', + input: { description: 'Do background task', run_in_background: true }, + status: 'completed', + result: 'Task started', + } as any, + ], + contentBlocks: [{ type: 'text', content: 'Started' }] as any, + } as any, + ], + }); + + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ + messages: [], + skippedLines: 0, + }); + + const loaded = await plugin.getConversationById(conv.id); + const block = loaded?.messages[0].contentBlocks?.find( + (b: any) => b.type === 'subagent' && b.subagentId === 'task-async-1' + ) as any; + + expect(loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-async-1')).toEqual( + expect.objectContaining({ + id: 'task-async-1', + subagent: expect.objectContaining({ + id: 'task-async-1', + mode: 'async', + asyncStatus: 'completed', + }), + }) + ); + expect(block).toEqual( + expect.objectContaining({ type: 'subagent', subagentId: 'task-async-1', mode: 'async' }) + ); + + existsSpy.mockRestore(); + loadSpy.mockRestore(); + }); + + it('hydrates async subagent tool calls from SDK subagent files on reload', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + providerSessionId: 'session-async-subagent-tools', + subagentData: { + 'task-async-tools': { + id: 'task-async-tools', + description: 'Recovered async subagent', + mode: 'async', + asyncStatus: 'completed', + status: 'completed', + result: 'Recovered async result', + agentId: 'agent-a123', + toolCalls: [], + isExpanded: false, + } as any, + }, + }, + messages: [ + { + id: 'assistant-1', + role: 'assistant', + content: '', + timestamp: 1000, + toolCalls: [ + { + id: 'task-async-tools', + name: 'Task', + input: { description: 'Do background task', run_in_background: true }, + status: 'completed', + result: 'Task started', + } as any, + ], + contentBlocks: [{ type: 'subagent', subagentId: 'task-async-tools', mode: 'async' }] as any, + } as any, + ], + }); + + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ + messages: [], + skippedLines: 0, + }); + const loadSubagentToolsSpy = jest.spyOn(sdkSession, 'loadSubagentToolCalls').mockResolvedValue([ + { + id: 'sub-tool-1', + name: 'Bash', + input: { command: 'ls' }, + status: 'completed', + result: 'ok', + isExpanded: false, + } as any, + ]); + + const loaded = await plugin.getConversationById(conv.id); + const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-async-tools'); + + expect(loadSubagentToolsSpy).toHaveBeenCalledWith( + expect.any(String), + 'session-async-subagent-tools', + 'agent-a123', + undefined, + expect.any(Object), + ); + expect(taskTool?.subagent?.toolCalls).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'sub-tool-1', + name: 'Bash', + result: 'ok', + }), + ]) + ); + + existsSpy.mockRestore(); + loadSpy.mockRestore(); + loadSubagentToolsSpy.mockRestore(); + }); + + it('keeps async subagent renderer visible when task block and task tool call are both missing', async () => { + await plugin.onload(); + + const conv = await plugin.createConversation(); + await plugin.updateConversation(conv.id, { + providerState: { + providerSessionId: 'session-async-subagent-fallback', + subagentData: { + 'task-async-orphan': { + id: 'task-async-orphan', + description: 'Recovered async orphan subagent', + mode: 'async', + asyncStatus: 'running', + status: 'running', + result: 'Running in background', + toolCalls: [], + isExpanded: false, + } as any, + }, + }, + messages: [ + { + id: 'assistant-1', + role: 'assistant', + content: 'Background work started', + timestamp: 1000, + contentBlocks: [{ type: 'text', content: 'Background work started' }] as any, + } as any, + ], + }); + + const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true); + const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({ + messages: [], + skippedLines: 0, + }); + + const loaded = await plugin.getConversationById(conv.id); + const assistant = loaded?.messages.find(m => m.id === 'assistant-1'); + const block = assistant?.contentBlocks?.find( + (b: any) => b.type === 'subagent' && b.subagentId === 'task-async-orphan' + ) as any; + + expect(assistant?.toolCalls?.find((tc: any) => tc.id === 'task-async-orphan')).toEqual( + expect.objectContaining({ + id: 'task-async-orphan', + name: TOOL_SUBAGENT, + subagent: expect.objectContaining({ + id: 'task-async-orphan', + mode: 'async', + asyncStatus: 'running', + }), + }) + ); + expect(block).toEqual( + expect.objectContaining({ + type: 'subagent', + subagentId: 'task-async-orphan', + mode: 'async', + }) + ); + + existsSpy.mockRestore(); + loadSpy.mockRestore(); + }); + }); + +}); diff --git a/tests/integration/providers/ProviderLifecycleFixtures.test.ts b/tests/integration/providers/ProviderLifecycleFixtures.test.ts new file mode 100644 index 0000000..b4e7dff --- /dev/null +++ b/tests/integration/providers/ProviderLifecycleFixtures.test.ts @@ -0,0 +1,184 @@ +import * as path from 'node:path'; + +import { AcpJsonRpcTransport } from '@/providers/acp/AcpJsonRpcTransport'; +import { AcpSubprocess } from '@/providers/acp/AcpSubprocess'; +import { CodexAppServerProcess } from '@/providers/codex/runtime/CodexAppServerProcess'; +import { CodexRpcTransport } from '@/providers/codex/runtime/CodexRpcTransport'; +import { PiRpcTransport } from '@/providers/pi/runtime/PiRpcTransport'; +import { PiSubprocess } from '@/providers/pi/runtime/PiSubprocess'; + +const fixturePath = path.join(process.cwd(), 'tests/fixtures/provider-protocol-child.mjs'); + +describe('provider lifecycle fixture subprocesses', () => { + it('runs a Codex JSON-RPC request through the real process and transport owners', async () => { + const processOwner = new CodexAppServerProcess({ + args: [fixturePath, 'codex'], + command: process.execPath, + env: { ...process.env } as Record, + spawnCwd: process.cwd(), + }); + processOwner.start(); + const transport = new CodexRpcTransport(processOwner); + transport.start(); + + await expect(transport.request('fixture/ping', { value: 1 }, 2_000)).resolves.toEqual({ + method: 'fixture/ping', + params: { value: 1 }, + }); + + transport.dispose(); + await processOwner.shutdown(); + expect(processOwner.isAlive()).toBe(false); + }); + + it('runs an ACP request through the real process and transport owners', async () => { + const processOwner = new AcpSubprocess({ + args: [fixturePath, 'acp'], + command: process.execPath, + cwd: process.cwd(), + env: { ...process.env }, + }); + processOwner.start(); + const transport = new AcpJsonRpcTransport({ + input: processOwner.stdout, + onClose: listener => processOwner.onClose(listener), + output: processOwner.stdin, + }, 2_000); + + await expect(transport.request('fixture/ping', { value: 2 })).resolves.toEqual({ + method: 'fixture/ping', + params: { value: 2 }, + }); + + transport.dispose(); + await processOwner.shutdown(); + expect(processOwner.isAlive()).toBe(false); + }); + + it('runs a Pi RPC request through the real process and transport owners', async () => { + const processOwner = new PiSubprocess({ + args: [fixturePath, 'pi'], + command: process.execPath, + cwd: process.cwd(), + env: { ...process.env }, + }); + processOwner.start(); + const transport = new PiRpcTransport({ + input: processOwner.stdout, + onClose: listener => processOwner.onClose(listener), + output: processOwner.stdin, + }, 2_000); + + await expect(transport.request('fixture_ping', { payload: 3 })).resolves.toEqual({ + command: 'fixture_ping', + payload: 3, + }); + + transport.dispose(); + await processOwner.shutdown(); + expect(processOwner.isAlive()).toBe(false); + }); + + it('ignores primitive Codex frames from a real process before a valid response', async () => { + const processOwner = new CodexAppServerProcess({ + args: [fixturePath, 'codex'], + command: process.execPath, + env: { ...process.env } as Record, + spawnCwd: process.cwd(), + }); + processOwner.start(); + const transport = new CodexRpcTransport(processOwner); + transport.start(); + + await expect(transport.request('fixture/primitive', {}, 2_000)).resolves.toEqual({ + method: 'fixture/primitive', + params: {}, + }); + + transport.dispose(); + await processOwner.shutdown(); + }); + + it('rejects pending Codex requests when the real subprocess exits', async () => { + const processOwner = new CodexAppServerProcess({ + args: [fixturePath, 'codex'], + command: process.execPath, + env: { ...process.env } as Record, + spawnCwd: process.cwd(), + }); + processOwner.start(); + const transport = new CodexRpcTransport(processOwner); + transport.start(); + + await expect(transport.request('fixture/exit', {}, 2_000)).rejects.toThrow( + 'App-server process exited', + ); + + transport.dispose(); + await processOwner.shutdown(); + }); + + it('rejects pending ACP requests when the real subprocess exits', async () => { + const processOwner = new AcpSubprocess({ + args: [fixturePath, 'acp'], + command: process.execPath, + cwd: process.cwd(), + env: { ...process.env }, + }); + processOwner.start(); + const transport = new AcpJsonRpcTransport({ + input: processOwner.stdout, + onClose: listener => processOwner.onClose(listener), + output: processOwner.stdin, + }, 2_000); + + await expect(transport.request('fixture/exit')).rejects.toThrow( + /JSON-RPC input closed|ACP subprocess exited/, + ); + + transport.dispose(); + await processOwner.shutdown(); + }); + + it('rejects pending Pi requests when the real subprocess exits', async () => { + const processOwner = new PiSubprocess({ + args: [fixturePath, 'pi'], + command: process.execPath, + cwd: process.cwd(), + env: { ...process.env }, + }); + processOwner.start(); + const transport = new PiRpcTransport({ + input: processOwner.stdout, + onClose: listener => processOwner.onClose(listener), + output: processOwner.stdin, + }, 2_000); + + await expect(transport.request('fixture_exit')).rejects.toThrow( + /Pi RPC input closed|Pi subprocess exited/, + ); + + transport.dispose(); + await processOwner.shutdown(); + }); + + it('enforces request deadlines against a non-responsive real subprocess', async () => { + const processOwner = new AcpSubprocess({ + args: [fixturePath, 'acp'], + command: process.execPath, + cwd: process.cwd(), + env: { ...process.env }, + }); + processOwner.start(); + const transport = new AcpJsonRpcTransport({ + input: processOwner.stdout, + onClose: listener => processOwner.onClose(listener), + output: processOwner.stdin, + }, 200); + + await expect(transport.request('fixture/hang')).rejects.toThrow('Request timeout'); + + transport.dispose(); + await processOwner.shutdown(); + }); +}); diff --git a/tests/setupWindow.ts b/tests/setupWindow.ts new file mode 100644 index 0000000..aa2cdd8 --- /dev/null +++ b/tests/setupWindow.ts @@ -0,0 +1,26 @@ +type TestWindow = typeof globalThis & { + cancelAnimationFrame?: (handle: number) => void; + requestAnimationFrame?: (callback: FrameRequestCallback) => number; +}; + +const testWindow = globalThis as TestWindow; + +if (!testWindow.requestAnimationFrame) { + testWindow.requestAnimationFrame = (callback: FrameRequestCallback): number => ( + Number(setTimeout(() => callback(Date.now()), 0)) + ); +} + +if (!testWindow.cancelAnimationFrame) { + testWindow.cancelAnimationFrame = (handle: number): void => { + clearTimeout(handle); + }; +} + +if (!('window' in globalThis)) { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: testWindow, + writable: true, + }); +} diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..5cd1ad2 --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.jest.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["**/*.ts"] +} diff --git a/tests/unit/app/providers/ClaudianProviderHost.test.ts b/tests/unit/app/providers/ClaudianProviderHost.test.ts new file mode 100644 index 0000000..817e48f --- /dev/null +++ b/tests/unit/app/providers/ClaudianProviderHost.test.ts @@ -0,0 +1,76 @@ +import { ClaudianProviderHost } from '@/app/providers/ClaudianProviderHost'; +import type ClaudianPlugin from '@/main'; + +function createPlugin(overrides: Record = {}): ClaudianPlugin { + return { + app: {}, + settings: {}, + storage: {}, + manifest: { version: '1.2.3' }, + saveSettings: jest.fn(async () => undefined), + loadData: jest.fn(async () => ({})), + saveData: jest.fn(async () => undefined), + normalizeModelVariantSettings: jest.fn(() => false), + getActiveEnvironmentVariables: jest.fn(() => 'OPENAI_API_KEY=test'), + getEnvironmentVariablesForScope: jest.fn(() => 'SHARED=value'), + applyEnvironmentVariables: jest.fn(async () => undefined), + applyEnvironmentVariablesBatch: jest.fn(async () => undefined), + getResolvedProviderCliPath: jest.fn(() => '/usr/bin/provider'), + getAllViews: jest.fn(() => []), + getView: jest.fn(() => null), + ...overrides, + } as unknown as ClaudianPlugin; +} + +describe('ClaudianProviderHost', () => { + it('delegates provider capabilities without exposing plugin lifecycle APIs', async () => { + const trace: string[] = []; + const plugin = createPlugin({ + saveSettings: jest.fn(async () => { trace.push('save'); }), + applyEnvironmentVariables: jest.fn(async () => { trace.push('environment'); }), + getResolvedProviderCliPath: jest.fn(() => { + trace.push('cli'); + return '/usr/bin/codex'; + }), + }); + const host = new ClaudianProviderHost(plugin); + + await host.saveSettings(); + await host.applyEnvironmentVariables('provider:codex', 'OPENAI_API_KEY=test'); + expect(host.getResolvedProviderCliPath('codex')).toBe('/usr/bin/codex'); + + expect(trace).toEqual(['save', 'environment', 'cli']); + expect('registerView' in host).toBe(false); + expect('addCommand' in host).toBe(false); + }); + + it('delivers provider runtime recycling to views in their existing order', async () => { + const trace: string[] = []; + const createView = (id: string) => ({ + getTabManager: () => ({ + recycleProviderRuntimes: async (providerId: string) => { + trace.push(`${id}:recycle:${providerId}`); + }, + }), + invalidateProviderCommandCaches: (providerIds: string[]) => { + trace.push(`${id}:invalidate:${providerIds.join(',')}`); + }, + refreshModelSelector: () => { trace.push(`${id}:refresh`); }, + }); + const plugin = createPlugin({ + getAllViews: jest.fn(() => [createView('first'), createView('second')]), + }); + const host = new ClaudianProviderHost(plugin); + + await host.recycleProviderRuntimes('opencode'); + + expect(trace).toEqual([ + 'first:recycle:opencode', + 'first:invalidate:opencode', + 'first:refresh', + 'second:recycle:opencode', + 'second:invalidate:opencode', + 'second:refresh', + ]); + }); +}); diff --git a/tests/unit/app/settings/SettingsCoordinator.test.ts b/tests/unit/app/settings/SettingsCoordinator.test.ts new file mode 100644 index 0000000..c13cf39 --- /dev/null +++ b/tests/unit/app/settings/SettingsCoordinator.test.ts @@ -0,0 +1,75 @@ +import { SettingsCoordinator } from '@/app/settings/SettingsCoordinator'; + +describe('SettingsCoordinator', () => { + it('serializes mutation closures over the latest in-memory settings', async () => { + const settings: Record = { alpha: 0, beta: 0 }; + const persisted: Array> = []; + const coordinator = new SettingsCoordinator(settings, async (current) => { + persisted.push({ ...current }); + }); + + const first = coordinator.mutate((current) => { + current.alpha = 1; + }); + const second = coordinator.mutate((current) => { + expect(current.alpha).toBe(1); + current.beta = 2; + }); + await Promise.all([first, second]); + + expect(settings).toEqual({ alpha: 1, beta: 2 }); + expect(persisted).toEqual([ + { alpha: 1, beta: 0 }, + { alpha: 1, beta: 2 }, + ]); + }); + + it('surfaces a failed save without poisoning later queued mutations', async () => { + const settings: Record = {}; + const persist = jest.fn() + .mockRejectedValueOnce(new Error('write failed')) + .mockResolvedValueOnce(undefined); + const coordinator = new SettingsCoordinator(settings, persist); + + await expect(coordinator.mutate(current => { current.first = true; })).rejects.toThrow('write failed'); + await expect(coordinator.mutate(current => { current.second = true; })).resolves.toBeUndefined(); + + expect(settings).toEqual({ first: true, second: true }); + expect(persist).toHaveBeenCalledTimes(2); + }); + + it('serializes compatibility saves with mutations', async () => { + const settings: Record = { value: 1 }; + const persisted: number[] = []; + const coordinator = new SettingsCoordinator(settings, async current => { + persisted.push(current.value as number); + }); + + await Promise.all([ + coordinator.persistCurrent(), + coordinator.mutate(current => { current.value = 2; }), + ]); + + expect(persisted).toEqual([1, 2]); + }); + + it('keeps conditional mutations serialized and persists only when requested', async () => { + const settings: Record = { transient: 0, persisted: 0 }; + const persisted: Array> = []; + const coordinator = new SettingsCoordinator(settings, async current => { + persisted.push({ ...current }); + }); + + await coordinator.mutateConditionally(current => { + current.transient = 1; + return false; + }); + await coordinator.mutateConditionally(current => { + current.persisted = 2; + return true; + }); + + expect(settings).toEqual({ transient: 1, persisted: 2 }); + expect(persisted).toEqual([{ transient: 1, persisted: 2 }]); + }); +}); diff --git a/tests/unit/core/bootstrap/tabManagerState.test.ts b/tests/unit/core/bootstrap/tabManagerState.test.ts new file mode 100644 index 0000000..1cf9ce3 --- /dev/null +++ b/tests/unit/core/bootstrap/tabManagerState.test.ts @@ -0,0 +1,36 @@ +import { normalizeTabManagerState } from '@/core/bootstrap/tabManagerState'; + +describe('normalizeTabManagerState', () => { + it('preserves valid expanded title tab ids', () => { + const result = normalizeTabManagerState({ + openTabs: [ + { tabId: 'tab-1', conversationId: 'conv-1' }, + { tabId: 'tab-2', conversationId: null }, + ], + activeTabId: 'tab-2', + expandedTitleTabIds: ['tab-2', 'tab-1'], + }); + + expect(result).toEqual({ + openTabs: [ + { tabId: 'tab-1', conversationId: 'conv-1' }, + { tabId: 'tab-2', conversationId: null }, + ], + activeTabId: 'tab-2', + expandedTitleTabIds: ['tab-2', 'tab-1'], + }); + }); + + it('drops invalid, stale, and duplicate expanded title tab ids', () => { + const result = normalizeTabManagerState({ + openTabs: [ + { tabId: 'tab-1', conversationId: null }, + { tabId: 'tab-2', conversationId: null }, + ], + activeTabId: 'tab-1', + expandedTitleTabIds: ['tab-2', 'missing-tab', 'tab-2', 7, 'tab-1'], + }); + + expect(result?.expandedTitleTabIds).toEqual(['tab-2', 'tab-1']); + }); +}); diff --git a/tests/unit/core/commands/builtInCommands.test.ts b/tests/unit/core/commands/builtInCommands.test.ts new file mode 100644 index 0000000..6086132 --- /dev/null +++ b/tests/unit/core/commands/builtInCommands.test.ts @@ -0,0 +1,239 @@ +import '@/providers'; + +import { + BUILT_IN_COMMANDS, + detectBuiltInCommand, + getBuiltInCommandsForDropdown, + isBuiltInCommandSupported, +} from '../../../../src/core/commands/builtInCommands'; + +describe('builtInCommands', () => { + describe('detectBuiltInCommand', () => { + it('detects /clear command', () => { + const result = detectBuiltInCommand('/clear'); + expect(result).not.toBeNull(); + expect(result?.command.name).toBe('clear'); + expect(result?.command.action).toBe('clear'); + expect(result?.args).toBe(''); + }); + + it('detects /new command as alias for clear', () => { + const result = detectBuiltInCommand('/new'); + expect(result).not.toBeNull(); + expect(result?.command.name).toBe('clear'); + expect(result?.command.action).toBe('clear'); + }); + + it('is case-insensitive', () => { + expect(detectBuiltInCommand('/CLEAR')).not.toBeNull(); + expect(detectBuiltInCommand('/Clear')).not.toBeNull(); + expect(detectBuiltInCommand('/NEW')).not.toBeNull(); + }); + + it('detects command with trailing whitespace', () => { + const result = detectBuiltInCommand('/clear '); + expect(result).not.toBeNull(); + expect(result?.command.name).toBe('clear'); + expect(result?.args).toBe(''); + }); + + it('detects command with arguments', () => { + const result = detectBuiltInCommand('/clear some arguments'); + expect(result).not.toBeNull(); + expect(result?.command.name).toBe('clear'); + expect(result?.args).toBe('some arguments'); + }); + + it('detects /add-dir command with path argument', () => { + const result = detectBuiltInCommand('/add-dir /path/to/dir'); + expect(result).not.toBeNull(); + expect(result?.command.name).toBe('add-dir'); + expect(result?.command.action).toBe('add-dir'); + expect(result?.args).toBe('/path/to/dir'); + }); + + it('detects /add-dir command with home path', () => { + const result = detectBuiltInCommand('/add-dir ~/projects'); + expect(result).not.toBeNull(); + expect(result?.command.name).toBe('add-dir'); + expect(result?.args).toBe('~/projects'); + }); + + it('returns null for non-slash input', () => { + expect(detectBuiltInCommand('clear')).toBeNull(); + expect(detectBuiltInCommand('hello /clear')).toBeNull(); + }); + + it('returns null for unknown commands', () => { + expect(detectBuiltInCommand('/unknown')).toBeNull(); + expect(detectBuiltInCommand('/foo')).toBeNull(); + }); + + it('returns null for empty input', () => { + expect(detectBuiltInCommand('')).toBeNull(); + expect(detectBuiltInCommand(' ')).toBeNull(); + }); + + it('returns null for just slash', () => { + expect(detectBuiltInCommand('/')).toBeNull(); + }); + + it('detects /resume command', () => { + const result = detectBuiltInCommand('/resume'); + expect(result).not.toBeNull(); + expect(result?.command.name).toBe('resume'); + expect(result?.command.action).toBe('resume'); + expect(result?.args).toBe(''); + }); + + it('detects /fork command', () => { + const result = detectBuiltInCommand('/fork'); + expect(result).not.toBeNull(); + expect(result?.command.name).toBe('fork'); + expect(result?.command.action).toBe('fork'); + expect(result?.args).toBe(''); + }); + + it('detects /fork case-insensitively', () => { + expect(detectBuiltInCommand('/FORK')).not.toBeNull(); + expect(detectBuiltInCommand('/Fork')).not.toBeNull(); + }); + }); + + describe('getBuiltInCommandsForDropdown', () => { + it('returns all built-in commands with proper format', () => { + const commands = getBuiltInCommandsForDropdown(); + + expect(commands.length).toBe(BUILT_IN_COMMANDS.length); + + const clearCmd = commands.find((c) => c.name === 'clear'); + expect(clearCmd).toBeDefined(); + expect(clearCmd?.id).toBe('builtin:clear'); + expect(clearCmd?.description).toBe('Start a new conversation'); + expect(clearCmd?.content).toBe(''); + }); + + it('returns commands compatible with SlashCommand interface', () => { + const commands = getBuiltInCommandsForDropdown(); + + for (const cmd of commands) { + expect(cmd).toHaveProperty('id'); + expect(cmd).toHaveProperty('name'); + expect(cmd).toHaveProperty('description'); + expect(cmd).toHaveProperty('content'); + } + }); + }); + + describe('BUILT_IN_COMMANDS', () => { + it('has clear command with new alias', () => { + const clearCmd = BUILT_IN_COMMANDS.find((c) => c.name === 'clear'); + expect(clearCmd).toBeDefined(); + expect(clearCmd?.aliases).toContain('new'); + expect(clearCmd?.action).toBe('clear'); + }); + + it('has add-dir command that accepts args', () => { + const addDirCmd = BUILT_IN_COMMANDS.find((c) => c.name === 'add-dir'); + expect(addDirCmd).toBeDefined(); + expect(addDirCmd?.action).toBe('add-dir'); + expect(addDirCmd?.hasArgs).toBe(true); + expect(addDirCmd?.description).toBe('Add external context directory'); + }); + + it('has resume command', () => { + const resumeCmd = BUILT_IN_COMMANDS.find((c) => c.name === 'resume'); + expect(resumeCmd).toBeDefined(); + expect(resumeCmd?.action).toBe('resume'); + expect(resumeCmd?.description).toBe('Resume a previous conversation'); + }); + + it('has fork command without args', () => { + const forkCmd = BUILT_IN_COMMANDS.find((c) => c.name === 'fork'); + expect(forkCmd).toBeDefined(); + expect(forkCmd?.action).toBe('fork'); + expect(forkCmd?.hasArgs).toBeUndefined(); + }); + + it('clear has no provider restriction', () => { + const clearCmd = BUILT_IN_COMMANDS.find((c) => c.name === 'clear'); + expect(clearCmd?.requiredCapability).toBeUndefined(); + }); + + it('add-dir has no provider restriction', () => { + const cmd = BUILT_IN_COMMANDS.find((c) => c.name === 'add-dir'); + expect(cmd?.requiredCapability).toBeUndefined(); + }); + + it('resume requires native history support', () => { + const cmd = BUILT_IN_COMMANDS.find((c) => c.name === 'resume'); + expect(cmd?.requiredCapability).toBe('supportsNativeHistory'); + }); + + it('fork requires fork support', () => { + const cmd = BUILT_IN_COMMANDS.find((c) => c.name === 'fork'); + expect(cmd?.requiredCapability).toBe('supportsFork'); + }); + }); + + describe('getBuiltInCommandsForDropdown - provider filtering', () => { + it('returns all commands when no providerId is given', () => { + const commands = getBuiltInCommandsForDropdown(); + expect(commands.length).toBe(BUILT_IN_COMMANDS.length); + }); + + it('returns all commands for claude provider', () => { + const commands = getBuiltInCommandsForDropdown('claude'); + expect(commands.length).toBe(BUILT_IN_COMMANDS.length); + expect(commands.map(c => c.name)).toContain('clear'); + expect(commands.map(c => c.name)).toContain('add-dir'); + expect(commands.map(c => c.name)).toContain('resume'); + expect(commands.map(c => c.name)).toContain('fork'); + }); + + it('returns all capability-supported commands for codex provider', () => { + const commands = getBuiltInCommandsForDropdown('codex'); + const names = commands.map(c => c.name); + expect(names).toContain('clear'); + expect(names).toContain('add-dir'); + expect(names).toContain('resume'); + expect(names).toContain('fork'); + }); + + it('returns only commands supported by codex capabilities', () => { + const commands = getBuiltInCommandsForDropdown('codex'); + expect(commands.length).toBe(4); + expect(commands.map(c => c.name)).toEqual(['clear', 'add-dir', 'resume', 'fork']); + }); + }); + + describe('isBuiltInCommandSupported', () => { + it('returns true for universal commands on any provider', () => { + const clearCmd = BUILT_IN_COMMANDS.find((c) => c.name === 'clear')!; + expect(isBuiltInCommandSupported(clearCmd, 'claude')).toBe(true); + expect(isBuiltInCommandSupported(clearCmd, 'codex')).toBe(true); + }); + + it('returns false for provider-restricted commands on other providers', () => { + const resumeCmd = BUILT_IN_COMMANDS.find((c) => c.name === 'resume')!; + expect(isBuiltInCommandSupported(resumeCmd, 'claude')).toBe(true); + expect(isBuiltInCommandSupported( + resumeCmd, + { supportsNativeHistory: false, supportsFork: true }, + )).toBe(false); + }); + + it('uses provider capabilities for provider-specific commands', () => { + const forkCmd = BUILT_IN_COMMANDS.find((c) => c.name === 'fork')!; + expect(isBuiltInCommandSupported( + forkCmd, + { supportsNativeHistory: true, supportsFork: true }, + )).toBe(true); + expect(isBuiltInCommandSupported( + forkCmd, + { supportsNativeHistory: true, supportsFork: false }, + )).toBe(false); + }); + }); + +}); diff --git a/tests/unit/core/mcp/McpServerManager.test.ts b/tests/unit/core/mcp/McpServerManager.test.ts new file mode 100644 index 0000000..077076c --- /dev/null +++ b/tests/unit/core/mcp/McpServerManager.test.ts @@ -0,0 +1,405 @@ +import { McpServerManager } from '@/core/mcp/McpServerManager'; +import type { ManagedMcpServer } from '@/core/types'; + +const createManager = async (servers: ManagedMcpServer[]) => { + const manager = new McpServerManager({ + load: async () => servers, + }); + await manager.loadServers(); + return manager; +}; + +describe('McpServerManager', () => { + describe('getDisallowedMcpTools', () => { + it('returns empty array when no servers are loaded', async () => { + const manager = await createManager([]); + expect(manager.getDisallowedMcpTools(new Set())).toEqual([]); + }); + + it('formats disabled tools for enabled servers', async () => { + const manager = await createManager([ + { + name: 'alpha', + config: { command: 'alpha-cmd' }, + enabled: true, + contextSaving: false, + disabledTools: ['tool_a', 'tool_b'], + }, + ]); + + expect(manager.getDisallowedMcpTools(new Set())).toEqual([ + 'mcp__alpha__tool_a', + 'mcp__alpha__tool_b', + ]); + }); + + it('skips disabled servers', async () => { + const manager = await createManager([ + { + name: 'alpha', + config: { command: 'alpha-cmd' }, + enabled: false, + contextSaving: false, + disabledTools: ['tool_a'], + }, + { + name: 'beta', + config: { command: 'beta-cmd' }, + enabled: true, + contextSaving: false, + disabledTools: ['tool_b'], + }, + ]); + + expect(manager.getDisallowedMcpTools(new Set())).toEqual(['mcp__beta__tool_b']); + }); + + it('trims tool names and ignores blanks', async () => { + const manager = await createManager([ + { + name: 'alpha', + config: { command: 'alpha-cmd' }, + enabled: true, + contextSaving: false, + disabledTools: [' tool_a ', ''], + }, + ]); + + expect(manager.getDisallowedMcpTools(new Set())).toEqual(['mcp__alpha__tool_a']); + }); + + it('skips context-saving servers not mentioned', async () => { + const manager = await createManager([ + { + name: 'alpha', + config: { command: 'alpha-cmd' }, + enabled: true, + contextSaving: true, + disabledTools: ['tool_a'], + }, + { + name: 'beta', + config: { command: 'beta-cmd' }, + enabled: true, + contextSaving: false, + disabledTools: ['tool_b'], + }, + ]); + + expect(manager.getDisallowedMcpTools(new Set())).toEqual(['mcp__beta__tool_b']); + }); + + it('includes context-saving servers when mentioned', async () => { + const manager = await createManager([ + { + name: 'alpha', + config: { command: 'alpha-cmd' }, + enabled: true, + contextSaving: true, + disabledTools: ['tool_a'], + }, + { + name: 'beta', + config: { command: 'beta-cmd' }, + enabled: true, + contextSaving: false, + disabledTools: ['tool_b'], + }, + ]); + + expect(manager.getDisallowedMcpTools(new Set(['alpha']))).toEqual([ + 'mcp__alpha__tool_a', + 'mcp__beta__tool_b', + ]); + }); + }); + + describe('getActiveServers', () => { + it('returns empty record when no servers loaded', async () => { + const manager = await createManager([]); + expect(manager.getActiveServers(new Set())).toEqual({}); + }); + + it('returns enabled non-context-saving servers', async () => { + const manager = await createManager([ + { + name: 'alpha', + config: { command: 'alpha-cmd', args: ['--flag'] }, + enabled: true, + contextSaving: false, + }, + ]); + + const result = manager.getActiveServers(new Set()); + expect(result).toEqual({ + alpha: { command: 'alpha-cmd', args: ['--flag'] }, + }); + }); + + it('excludes disabled servers', async () => { + const manager = await createManager([ + { + name: 'disabled-one', + config: { command: 'cmd' }, + enabled: false, + contextSaving: false, + }, + { + name: 'enabled-one', + config: { command: 'cmd2' }, + enabled: true, + contextSaving: false, + }, + ]); + + const result = manager.getActiveServers(new Set()); + expect(Object.keys(result)).toEqual(['enabled-one']); + }); + + it('excludes context-saving servers not mentioned', async () => { + const manager = await createManager([ + { + name: 'ctx-server', + config: { command: 'ctx-cmd' }, + enabled: true, + contextSaving: true, + }, + { + name: 'normal-server', + config: { command: 'normal-cmd' }, + enabled: true, + contextSaving: false, + }, + ]); + + const result = manager.getActiveServers(new Set()); + expect(Object.keys(result)).toEqual(['normal-server']); + }); + + it('includes context-saving servers when mentioned', async () => { + const manager = await createManager([ + { + name: 'ctx-server', + config: { command: 'ctx-cmd' }, + enabled: true, + contextSaving: true, + }, + ]); + + const result = manager.getActiveServers(new Set(['ctx-server'])); + expect(result).toEqual({ 'ctx-server': { command: 'ctx-cmd' } }); + }); + }); + + describe('getAllDisallowedMcpTools', () => { + it('returns empty array when no servers', async () => { + const manager = await createManager([]); + expect(manager.getAllDisallowedMcpTools()).toEqual([]); + }); + + it('includes disabled tools from all enabled servers regardless of context-saving', async () => { + const manager = await createManager([ + { + name: 'ctx-server', + config: { command: 'cmd' }, + enabled: true, + contextSaving: true, + disabledTools: ['tool_x'], + }, + { + name: 'normal-server', + config: { command: 'cmd2' }, + enabled: true, + contextSaving: false, + disabledTools: ['tool_y'], + }, + ]); + + const result = manager.getAllDisallowedMcpTools(); + expect(result).toEqual([ + 'mcp__ctx-server__tool_x', + 'mcp__normal-server__tool_y', + ]); + }); + + it('skips disabled servers', async () => { + const manager = await createManager([ + { + name: 'off', + config: { command: 'cmd' }, + enabled: false, + contextSaving: false, + disabledTools: ['tool_a'], + }, + ]); + + expect(manager.getAllDisallowedMcpTools()).toEqual([]); + }); + + it('returns sorted results', async () => { + const manager = await createManager([ + { + name: 'beta', + config: { command: 'cmd' }, + enabled: true, + contextSaving: false, + disabledTools: ['zzz'], + }, + { + name: 'alpha', + config: { command: 'cmd' }, + enabled: true, + contextSaving: false, + disabledTools: ['aaa'], + }, + ]); + + const result = manager.getAllDisallowedMcpTools(); + expect(result).toEqual(['mcp__alpha__aaa', 'mcp__beta__zzz']); + }); + + it('skips servers with no disabledTools', async () => { + const manager = await createManager([ + { + name: 'no-disabled', + config: { command: 'cmd' }, + enabled: true, + contextSaving: false, + }, + { + name: 'empty-disabled', + config: { command: 'cmd' }, + enabled: true, + contextSaving: false, + disabledTools: [], + }, + ]); + + expect(manager.getAllDisallowedMcpTools()).toEqual([]); + }); + }); + + describe('getEnabledCount', () => { + it('returns 0 for no servers', async () => { + const manager = await createManager([]); + expect(manager.getEnabledCount()).toBe(0); + }); + + it('counts only enabled servers', async () => { + const manager = await createManager([ + { name: 'a', config: { command: 'a' }, enabled: true, contextSaving: false }, + { name: 'b', config: { command: 'b' }, enabled: false, contextSaving: false }, + { name: 'c', config: { command: 'c' }, enabled: true, contextSaving: false }, + ]); + expect(manager.getEnabledCount()).toBe(2); + }); + }); + + describe('hasServers', () => { + it('returns false for empty', async () => { + const manager = await createManager([]); + expect(manager.hasServers()).toBe(false); + }); + + it('returns true when servers exist', async () => { + const manager = await createManager([ + { name: 'a', config: { command: 'a' }, enabled: false, contextSaving: false }, + ]); + expect(manager.hasServers()).toBe(true); + }); + }); + + describe('getServers', () => { + it('returns loaded servers', async () => { + const servers: ManagedMcpServer[] = [ + { name: 'x', config: { command: 'x' }, enabled: true, contextSaving: false }, + ]; + const manager = await createManager(servers); + expect(manager.getServers()).toEqual(servers); + }); + }); + + describe('getContextSavingServers', () => { + it('returns only enabled context-saving servers', async () => { + const manager = await createManager([ + { name: 'ctx-on', config: { command: 'a' }, enabled: true, contextSaving: true }, + { name: 'ctx-off', config: { command: 'b' }, enabled: true, contextSaving: false }, + { name: 'disabled-ctx', config: { command: 'c' }, enabled: false, contextSaving: true }, + ]); + + const result = manager.getContextSavingServers(); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('ctx-on'); + }); + }); + + describe('extractMentions', () => { + it('extracts mentions of context-saving servers from text', async () => { + const manager = await createManager([ + { name: 'my-server', config: { command: 'a' }, enabled: true, contextSaving: true }, + { name: 'other', config: { command: 'b' }, enabled: true, contextSaving: false }, + ]); + + const mentions = manager.extractMentions('please use @my-server for this'); + expect(mentions).toEqual(new Set(['my-server'])); + }); + + it('ignores mentions of non-context-saving servers', async () => { + const manager = await createManager([ + { name: 'normal', config: { command: 'a' }, enabled: true, contextSaving: false }, + ]); + + const mentions = manager.extractMentions('@normal do something'); + expect(mentions.size).toBe(0); + }); + + it('ignores mentions of disabled context-saving servers', async () => { + const manager = await createManager([ + { name: 'disabled-ctx', config: { command: 'a' }, enabled: false, contextSaving: true }, + ]); + + const mentions = manager.extractMentions('@disabled-ctx'); + expect(mentions.size).toBe(0); + }); + }); + + describe('transformMentions', () => { + it('appends MCP after context-saving server mentions', async () => { + const manager = await createManager([ + { name: 'my-server', config: { command: 'a' }, enabled: true, contextSaving: true }, + ]); + + const result = manager.transformMentions('use @my-server please'); + expect(result).toBe('use @my-server MCP please'); + }); + + it('does not transform non-context-saving server mentions', async () => { + const manager = await createManager([ + { name: 'normal', config: { command: 'a' }, enabled: true, contextSaving: false }, + ]); + + const result = manager.transformMentions('use @normal please'); + expect(result).toBe('use @normal please'); + }); + + it('returns text unchanged when no context-saving servers', async () => { + const manager = await createManager([]); + const text = 'hello @world'; + expect(manager.transformMentions(text)).toBe(text); + }); + }); + + describe('loadServers', () => { + it('populates servers from storage', async () => { + const servers: ManagedMcpServer[] = [ + { name: 'a', config: { command: 'cmd-a' }, enabled: true, contextSaving: false }, + { name: 'b', config: { type: 'sse', url: 'http://localhost' }, enabled: false, contextSaving: true }, + ]; + const manager = new McpServerManager({ load: async () => servers }); + + expect(manager.getServers()).toEqual([]); + await manager.loadServers(); + expect(manager.getServers()).toEqual(servers); + }); + }); +}); diff --git a/tests/unit/core/mcp/McpTester.test.ts b/tests/unit/core/mcp/McpTester.test.ts new file mode 100644 index 0000000..68a718c --- /dev/null +++ b/tests/unit/core/mcp/McpTester.test.ts @@ -0,0 +1,282 @@ +import { testMcpServer } from '@/core/mcp/McpTester'; +import type { ManagedMcpServer } from '@/core/types'; + +// Mock the MCP SDK transports and client +jest.mock('@modelcontextprotocol/sdk/client', () => ({ + Client: jest.fn().mockImplementation(() => ({ + connect: jest.fn(), + getServerVersion: jest.fn().mockReturnValue({ name: 'test-server', version: '1.0.0' }), + listTools: jest.fn().mockResolvedValue({ + tools: [ + { name: 'tool1', description: 'A test tool', inputSchema: { type: 'object' } }, + { name: 'tool2' }, + ], + }), + close: jest.fn(), + })), +})); + +jest.mock('@modelcontextprotocol/sdk/client/sse', () => ({ + SSEClientTransport: jest.fn(), +})); + +jest.mock('@modelcontextprotocol/sdk/client/stdio', () => ({ + StdioClientTransport: jest.fn(), +})); + +jest.mock('@modelcontextprotocol/sdk/client/streamableHttp', () => ({ + StreamableHTTPClientTransport: jest.fn(), +})); + +jest.mock('@/utils/env', () => ({ + getEnhancedPath: jest.fn((p?: string) => p || '/usr/bin'), +})); + +jest.mock('@/utils/mcp', () => ({ + parseCommand: jest.fn((cmd: string, args?: string[]) => { + if (args && args.length > 0) return { cmd, args }; + const parts = cmd.split(' '); + return { cmd: parts[0] || '', args: parts.slice(1) }; + }), +})); + +describe('testMcpServer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('stdio server', () => { + it('should connect and return tools for a valid stdio server', async () => { + const server: ManagedMcpServer = { + name: 'test', + config: { command: 'node server.js', args: ['--port', '3000'] }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(true); + expect(result.serverName).toBe('test-server'); + expect(result.serverVersion).toBe('1.0.0'); + expect(result.tools).toHaveLength(2); + expect(result.tools[0].name).toBe('tool1'); + expect(result.tools[0].description).toBe('A test tool'); + expect(result.tools[1].name).toBe('tool2'); + }); + + it('should return error for missing command', async () => { + const { parseCommand } = jest.requireMock('@/utils/mcp'); + parseCommand.mockReturnValueOnce({ cmd: '', args: [] }); + + const server: ManagedMcpServer = { + name: 'empty', + config: { command: '' }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(false); + expect(result.error).toBe('Missing command'); + expect(result.tools).toEqual([]); + }); + }); + + describe('sse server', () => { + it('should connect to an SSE server', async () => { + const { SSEClientTransport } = jest.requireMock('@modelcontextprotocol/sdk/client/sse'); + const server: ManagedMcpServer = { + name: 'sse-test', + config: { type: 'sse' as const, url: 'https://example.com/sse' }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(true); + expect(result.tools).toHaveLength(2); + expect(SSEClientTransport).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + fetch: expect.any(Function), + }), + ); + }); + }); + + describe('http server', () => { + it('should connect to an HTTP server', async () => { + const { StreamableHTTPClientTransport } = jest.requireMock('@modelcontextprotocol/sdk/client/streamableHttp'); + const server: ManagedMcpServer = { + name: 'http-test', + config: { type: 'http' as const, url: 'https://example.com/api' }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(true); + expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + fetch: expect.any(Function), + }), + ); + }); + + it('should pass headers when configured', async () => { + const { StreamableHTTPClientTransport } = jest.requireMock('@modelcontextprotocol/sdk/client/streamableHttp'); + const server: ManagedMcpServer = { + name: 'http-auth', + config: { + type: 'http' as const, + url: 'https://example.com/api', + headers: { Authorization: 'Bearer token' }, + }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(true); + expect(StreamableHTTPClientTransport).toHaveBeenCalledWith( + expect.any(URL), + expect.objectContaining({ + fetch: expect.any(Function), + requestInit: { headers: { Authorization: 'Bearer token' } }, + }), + ); + }); + }); + + describe('error handling', () => { + it('should return error when transport creation fails', async () => { + const { SSEClientTransport } = jest.requireMock('@modelcontextprotocol/sdk/client/sse'); + SSEClientTransport.mockImplementationOnce(() => { + throw new Error('Transport init failed'); + }); + + const server: ManagedMcpServer = { + name: 'bad-sse', + config: { type: 'sse' as const, url: 'https://example.com/sse' }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(false); + expect(result.error).toBe('Transport init failed'); + expect(result.tools).toEqual([]); + }); + + it('should return generic error for non-Error transport failures', async () => { + const { StreamableHTTPClientTransport } = jest.requireMock('@modelcontextprotocol/sdk/client/streamableHttp'); + StreamableHTTPClientTransport.mockImplementationOnce(() => { + throw 'string error'; + }); + + const server: ManagedMcpServer = { + name: 'bad-http', + config: { type: 'http' as const, url: 'https://example.com' }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid server configuration'); + }); + + it('should return error when connection fails', async () => { + const { Client } = jest.requireMock('@modelcontextprotocol/sdk/client'); + Client.mockImplementationOnce(() => ({ + connect: jest.fn().mockRejectedValue(new Error('Connection refused')), + close: jest.fn(), + })); + + const server: ManagedMcpServer = { + name: 'refused', + config: { command: 'node server.js' }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(false); + expect(result.error).toBe('Connection refused'); + }); + + it('should return unknown error for non-Error connection failures', async () => { + const { Client } = jest.requireMock('@modelcontextprotocol/sdk/client'); + Client.mockImplementationOnce(() => ({ + connect: jest.fn().mockRejectedValue(42), + close: jest.fn(), + })); + + const server: ManagedMcpServer = { + name: 'weird-error', + config: { command: 'node server.js' }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(false); + expect(result.error).toBe('Unknown error'); + }); + + it('should handle listTools failure gracefully (partial success)', async () => { + const { Client } = jest.requireMock('@modelcontextprotocol/sdk/client'); + Client.mockImplementationOnce(() => ({ + connect: jest.fn(), + getServerVersion: jest.fn().mockReturnValue({ name: 'partial', version: '0.1' }), + listTools: jest.fn().mockRejectedValue(new Error('listTools not supported')), + close: jest.fn(), + })); + + const server: ManagedMcpServer = { + name: 'partial', + config: { command: 'node server.js' }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(true); + expect(result.serverName).toBe('partial'); + expect(result.tools).toEqual([]); + }); + + it('should handle close errors silently', async () => { + const { Client } = jest.requireMock('@modelcontextprotocol/sdk/client'); + Client.mockImplementationOnce(() => ({ + connect: jest.fn(), + getServerVersion: jest.fn().mockReturnValue(null), + listTools: jest.fn().mockResolvedValue({ tools: [] }), + close: jest.fn().mockRejectedValue(new Error('close failed')), + })); + + const server: ManagedMcpServer = { + name: 'close-fail', + config: { command: 'node server.js' }, + enabled: true, + contextSaving: false, + }; + + const result = await testMcpServer(server); + + expect(result.success).toBe(true); + expect(result.serverName).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/core/mcp/createNodeFetch.test.ts b/tests/unit/core/mcp/createNodeFetch.test.ts new file mode 100644 index 0000000..f8a0b62 --- /dev/null +++ b/tests/unit/core/mcp/createNodeFetch.test.ts @@ -0,0 +1,188 @@ +import * as http from 'http'; +import type { AddressInfo } from 'net'; + +import { createNodeFetch } from '@/core/mcp/McpTester'; + +interface ReceivedRequest { + method: string; + url: string; + headers: http.IncomingHttpHeaders; + body: string; +} + +function createTestServer(handler?: (req: ReceivedRequest, res: http.ServerResponse) => void): { + server: http.Server; + getUrl: () => string; + received: ReceivedRequest[]; +} { + const received: ReceivedRequest[] = []; + + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => chunks.push(chunk)); + req.on('end', () => { + const entry: ReceivedRequest = { + method: req.method ?? 'GET', + url: req.url ?? '/', + headers: req.headers, + body: Buffer.concat(chunks).toString('utf-8'), + }; + received.push(entry); + + if (handler) { + handler(entry, res); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + } + }); + }); + + server.listen(0); + + return { + server, + getUrl: () => { + const addr = server.address() as AddressInfo; + return `http://127.0.0.1:${addr.port}`; + }, + received, + }; +} + +describe('createNodeFetch', () => { + let server: http.Server; + let getUrl: () => string; + let received: ReceivedRequest[]; + let nodeFetch: ReturnType; + const serversToClose: http.Server[] = []; + + beforeAll(() => { + ({ server, getUrl, received } = createTestServer()); + nodeFetch = createNodeFetch(); + }); + + afterAll(() => new Promise((resolve) => { + server.close(() => resolve()); + })); + + afterEach(async () => { + received.length = 0; + await Promise.all( + serversToClose.map((s) => new Promise((resolve) => s.close(() => resolve()))), + ); + serversToClose.length = 0; + }); + + it('should set Content-Length header for POST with body', async () => { + const body = JSON.stringify({ jsonrpc: '2.0', method: 'initialize', id: 1 }); + + const response = await nodeFetch(getUrl(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + expect(response.ok).toBe(true); + expect(received).toHaveLength(1); + expect(received[0].headers['content-length']).toBe(String(Buffer.byteLength(body))); + expect(received[0].headers['transfer-encoding']).toBeUndefined(); + }); + + it('should deliver valid JSON body without chunk framing', async () => { + const payload = { jsonrpc: '2.0', method: 'tools/list', id: 2 }; + const body = JSON.stringify(payload); + + await nodeFetch(getUrl(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + expect(received).toHaveLength(1); + const parsed = JSON.parse(received[0].body); + expect(parsed).toEqual(payload); + }); + + it('should not set Content-Length for GET requests without body', async () => { + await nodeFetch(getUrl(), { method: 'GET' }); + + expect(received).toHaveLength(1); + expect(received[0].headers['content-length']).toBeUndefined(); + expect(received[0].method).toBe('GET'); + }); + + it('should forward custom headers', async () => { + await nodeFetch(getUrl(), { + method: 'GET', + headers: { 'X-Custom': 'test-value', Authorization: 'Bearer token123' }, + }); + + expect(received).toHaveLength(1); + expect(received[0].headers['x-custom']).toBe('test-value'); + expect(received[0].headers['authorization']).toBe('Bearer token123'); + }); + + it('should return response status and body', async () => { + const response = await nodeFetch(getUrl(), { method: 'GET' }); + + expect(response.status).toBe(200); + expect(response.ok).toBe(true); + + const data = await response.json() as { ok: boolean }; + expect(data).toEqual({ ok: true }); + }); + + it('should handle non-200 responses', async () => { + const { server: errorServer, getUrl: errorUrl, received: errorReceived } = createTestServer( + (_req, res) => { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + }, + ); + serversToClose.push(errorServer); + + const response = await nodeFetch(errorUrl(), { method: 'GET' }); + + expect(response.status).toBe(404); + expect(response.ok).toBe(false); + expect(errorReceived).toHaveLength(1); + + const data = await response.json() as { error: string }; + expect(data).toEqual({ error: 'not found' }); + }); + + it('should support abort signal', async () => { + const controller = new AbortController(); + controller.abort(); + + await expect( + nodeFetch(getUrl(), { method: 'GET', signal: controller.signal }), + ).rejects.toThrow(); + }); + + it('should accept URL object as input', async () => { + const url = new URL(getUrl()); + + const response = await nodeFetch(url, { method: 'GET' }); + + expect(response.ok).toBe(true); + expect(received).toHaveLength(1); + }); + + it('should handle multi-byte characters in body with correct Content-Length', async () => { + const body = JSON.stringify({ text: '你好世界' }); + + await nodeFetch(getUrl(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + expect(received).toHaveLength(1); + // Content-Length should be byte length, not character length + expect(received[0].headers['content-length']).toBe(String(Buffer.byteLength(body))); + const parsed = JSON.parse(received[0].body) as { text: string }; + expect(parsed.text).toBe('你好世界'); + }); +}); diff --git a/tests/unit/core/providers/ProviderRegistry.test.ts b/tests/unit/core/providers/ProviderRegistry.test.ts new file mode 100644 index 0000000..5c3e5d0 --- /dev/null +++ b/tests/unit/core/providers/ProviderRegistry.test.ts @@ -0,0 +1,276 @@ +import '@/providers'; + +import { TEST_CODEX_MODEL } from '@test/helpers/codexModels'; + +import { ProviderRegistry } from '@/core/providers/ProviderRegistry'; +import { ProviderWorkspaceRegistry } from '@/core/providers/ProviderWorkspaceRegistry'; +import type { + ProviderId, + TitleGenerationCallback, + TitleGenerationResult, + TitleGenerationService, +} from '@/core/providers/types'; + +describe('ProviderRegistry', () => { + beforeEach(() => { + ProviderWorkspaceRegistry.clear(); + ProviderWorkspaceRegistry.setServices('claude', { + mcpManager: {} as any, + mcpServerManager: {} as any, + } as any); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('creates a runtime with the default provider id', () => { + const runtime = ProviderRegistry.createChatRuntime({ + plugin: {} as any, + }); + + expect(runtime.providerId).toBe('claude'); + }); + + it('returns capabilities for the default provider', () => { + const caps = ProviderRegistry.getCapabilities(); + expect(caps.providerId).toBe('claude'); + expect(caps).toHaveProperty('supportsPlanMode'); + expect(caps).toHaveProperty('supportsFork'); + }); + + it('returns boundary services for the default provider', () => { + const historyService = ProviderRegistry.getConversationHistoryService(); + expect(historyService).toHaveProperty('hydrateConversationHistory'); + + const taskInterpreter = ProviderRegistry.getTaskResultInterpreter(); + expect(taskInterpreter).toHaveProperty('resolveTerminalStatus'); + }); + + it('returns a settings reconciler for the default provider', () => { + const reconciler = ProviderRegistry.getSettingsReconciler(); + expect(reconciler).toHaveProperty('reconcileModelWithEnvironment'); + expect(reconciler).toHaveProperty('normalizeModelVariantSettings'); + }); + + it('returns a chat UI config for the default provider', () => { + const uiConfig = ProviderRegistry.getChatUIConfig(); + expect(uiConfig).toHaveProperty('getModelOptions'); + expect(uiConfig).toHaveProperty('getCustomModelIds'); + }); + + it('throws when an unknown provider is requested', () => { + expect(() => ProviderRegistry.getCapabilities( + 'nonexistent' as any, + )).toThrow('Provider "nonexistent" is not registered.'); + }); + + it('creates a Codex runtime', () => { + const runtime = ProviderRegistry.createChatRuntime({ + providerId: 'codex', + plugin: {} as any, + }); + expect(runtime.providerId).toBe('codex'); + }); + + it('returns Codex capabilities', () => { + const caps = ProviderRegistry.getCapabilities('codex'); + expect(caps.providerId).toBe('codex'); + expect(caps.supportsPlanMode).toBe(true); + expect(caps.supportsFork).toBe(true); + expect(caps.supportsInstructionMode).toBe(true); + expect(caps.supportsRewind).toBe(false); + expect(caps.reasoningControl).toBe('effort'); + }); + + it('returns OpenCode capabilities', () => { + const caps = ProviderRegistry.getCapabilities('opencode'); + expect(caps.providerId).toBe('opencode'); + expect(caps.supportsProviderCommands).toBe(true); + expect(caps.supportsInstructionMode).toBe(true); + expect(caps.supportsFork).toBe(false); + }); + + it('returns Pi capabilities', () => { + const caps = ProviderRegistry.getCapabilities('pi'); + expect(caps.providerId).toBe('pi'); + expect(caps.supportsProviderCommands).toBe(true); + expect(caps.supportsImageAttachments).toBe(true); + expect(caps.supportsTurnSteer).toBe(true); + expect(caps.supportsFork).toBe(true); + }); + + it('lists registered provider ids', () => { + const ids = ProviderRegistry.getRegisteredProviderIds(); + expect(ids).toContain('claude'); + expect(ids).toContain('codex'); + expect(ids).toContain('pi'); + }); + + it('filters enabled provider ids using registration metadata', () => { + expect(ProviderRegistry.getEnabledProviderIds({ + providerConfigs: { + codex: { enabled: false }, + }, + })).toEqual(['claude']); + expect(ProviderRegistry.getEnabledProviderIds({ + providerConfigs: { + codex: { enabled: true }, + }, + })).toEqual(['codex', 'claude']); + expect(ProviderRegistry.getEnabledProviderIds({ + providerConfigs: { + codex: { enabled: true }, + opencode: { enabled: true }, + }, + })).toEqual(['opencode', 'codex', 'claude']); + expect(ProviderRegistry.getEnabledProviderIds({ + providerConfigs: { + codex: { enabled: true }, + opencode: { enabled: true }, + pi: { enabled: true }, + }, + })).toEqual(['opencode', 'pi', 'codex', 'claude']); + }); + + it('returns the display name from provider registration metadata', () => { + expect(ProviderRegistry.getProviderDisplayName('claude')).toBe('Claude'); + expect(ProviderRegistry.getProviderDisplayName('codex')).toBe('Codex'); + }); + + it('routes auto title generation to Claude independently of chat provider state', async () => { + const providerCalls: ProviderId[] = []; + const originalCreate = ProviderRegistry.createTitleGenerationService.bind(ProviderRegistry); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService') + .mockImplementation((plugin: any, providerId?: ProviderId) => { + if (!providerId) { + return originalCreate(plugin); + } + providerCalls.push(providerId); + return createMockTitleService(providerId); + }); + + const service = ProviderRegistry.createTitleGenerationService({ + settings: { + titleGenerationModel: '', + providerConfigs: { + codex: { enabled: true }, + }, + }, + } as any); + const callback = jest.fn(); + + await service.generateTitle('conv-1', 'hello', callback); + + expect(providerCalls).toEqual(['claude']); + expect(callback).toHaveBeenCalledWith('conv-1', { + success: true, + title: 'claude title', + }); + }); + + it('routes explicit title model selections to the owning provider', async () => { + const providerCalls: ProviderId[] = []; + const originalCreate = ProviderRegistry.createTitleGenerationService.bind(ProviderRegistry); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService') + .mockImplementation((plugin: any, providerId?: ProviderId) => { + if (!providerId) { + return originalCreate(plugin); + } + providerCalls.push(providerId); + return createMockTitleService(providerId); + }); + + const service = ProviderRegistry.createTitleGenerationService({ + settings: { + titleGenerationModel: TEST_CODEX_MODEL, + providerConfigs: { + codex: { enabled: true }, + }, + }, + } as any); + const callback = jest.fn(); + + await service.generateTitle('conv-1', 'hello', callback); + + expect(providerCalls).toEqual(['codex']); + expect(callback).toHaveBeenCalledWith('conv-1', { + success: true, + title: 'codex title', + }); + }); + + it('suppresses stale callbacks when a newer title generation replaces the old one', async () => { + const originalCreate = ProviderRegistry.createTitleGenerationService.bind(ProviderRegistry); + const claudeService = createDeferredTitleService(); + const codexService = createMockTitleService('codex'); + + jest.spyOn(ProviderRegistry, 'createTitleGenerationService') + .mockImplementation((plugin: any, providerId?: ProviderId) => { + if (!providerId) { + return originalCreate(plugin); + } + return providerId === 'claude' ? claudeService : codexService; + }); + + const plugin = { + settings: { + titleGenerationModel: 'sonnet', + providerConfigs: { + codex: { enabled: true }, + }, + }, + } as any; + const service = ProviderRegistry.createTitleGenerationService(plugin); + const callback = jest.fn(); + + const first = service.generateTitle('conv-1', 'first', callback); + plugin.settings.titleGenerationModel = TEST_CODEX_MODEL; + await service.generateTitle('conv-1', 'second', callback); + await claudeService.resolve({ success: true, title: 'stale title' }); + await first; + + expect(claudeService.cancel).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('conv-1', { + success: true, + title: 'codex title', + }); + }); +}); + +function createMockTitleService(providerId: ProviderId): TitleGenerationService { + return { + cancel: jest.fn(), + generateTitle: jest.fn(async (conversationId, _userMessage, callback) => { + await callback(conversationId, { + success: true, + title: `${providerId} title`, + }); + }), + }; +} + +function createDeferredTitleService(): TitleGenerationService & { + resolve: (result: TitleGenerationResult) => Promise; +} { + let callback: TitleGenerationCallback | null = null; + let conversationId = ''; + let resolvePromise: (() => void) | null = null; + const done = new Promise((resolve) => { + resolvePromise = resolve; + }); + + return { + cancel: jest.fn(), + generateTitle: jest.fn(async (nextConversationId, _userMessage, nextCallback) => { + conversationId = nextConversationId; + callback = nextCallback; + await done; + }), + resolve: async (result) => { + await callback?.(conversationId, result); + resolvePromise?.(); + }, + }; +} diff --git a/tests/unit/core/providers/ProviderSettingsCoordinator.test.ts b/tests/unit/core/providers/ProviderSettingsCoordinator.test.ts new file mode 100644 index 0000000..a4b2ad6 --- /dev/null +++ b/tests/unit/core/providers/ProviderSettingsCoordinator.test.ts @@ -0,0 +1,562 @@ +import '@/providers'; + +import { TEST_CODEX_CATALOG, TEST_CODEX_MODEL } from '@test/helpers/codexModels'; + +import { getProviderSettingsSnapshotWithModel } from '@/core/providers/conversationModel'; +import { ProviderRegistry } from '@/core/providers/ProviderRegistry'; +import { ProviderSettingsCoordinator } from '@/core/providers/ProviderSettingsCoordinator'; +import type { Conversation } from '@/core/types'; +import { DEFAULT_CLAUDE_PROVIDER_SETTINGS } from '@/providers/claude/settings'; + +describe('ProviderSettingsCoordinator', () => { + describe('conversation model projection', () => { + it('preserves a valid explicit reasoning choice when reading an existing conversation', () => { + const settings: Record = { + settingsProvider: 'codex', + model: TEST_CODEX_MODEL, + effortLevel: 'low', + serviceTier: 'default', + savedProviderModel: { codex: TEST_CODEX_MODEL }, + savedProviderEffort: { codex: 'low' }, + providerConfigs: { + codex: { + enabled: true, + discoveredModels: TEST_CODEX_CATALOG, + }, + }, + }; + + const snapshot = getProviderSettingsSnapshotWithModel( + settings, + 'codex', + TEST_CODEX_MODEL, + ); + + expect(snapshot.effortLevel).toBe('low'); + expect(settings.effortLevel).toBe('low'); + }); + }); + + describe('applyModelSelection', () => { + it('clamps reasoning and service tier values to the selected model metadata', () => { + const settings: Record = { + model: TEST_CODEX_MODEL, + effortLevel: 'unsupported', + serviceTier: 'priority', + providerConfigs: { + codex: { + enabled: true, + discoveredModels: TEST_CODEX_CATALOG, + }, + }, + }; + + ProviderSettingsCoordinator.applyModelSelection(settings, 'codex', 'gpt-5.4-mini'); + + expect(settings.model).toBe('gpt-5.4-mini'); + expect(settings.effortLevel).toBe('medium'); + expect(settings.serviceTier).toBe('default'); + }); + + it('applies high as the default when switching to a Codex model that supports it', () => { + const settings: Record = { + model: 'gpt-5.4-mini', + effortLevel: 'low', + serviceTier: 'default', + providerConfigs: { + codex: { + enabled: true, + discoveredModels: TEST_CODEX_CATALOG, + }, + }, + }; + + ProviderSettingsCoordinator.applyModelSelection(settings, 'codex', TEST_CODEX_MODEL); + + expect(settings.effortLevel).toBe('high'); + }); + }); + + describe('normalizeProviderSelection', () => { + it('falls back to claude when codex is disabled', () => { + const settings: Record = { + settingsProvider: 'codex', + providerConfigs: { + codex: { enabled: false }, + }, + }; + + const changed = ProviderSettingsCoordinator.normalizeProviderSelection(settings); + + expect(changed).toBe(true); + expect(settings.settingsProvider).toBe('claude'); + }); + + it('falls back to claude for unknown providers', () => { + const settings: Record = { + settingsProvider: 'mystery-provider', + providerConfigs: { + codex: { enabled: true, discoveredModels: TEST_CODEX_CATALOG }, + }, + }; + + const changed = ProviderSettingsCoordinator.normalizeProviderSelection(settings); + + expect(changed).toBe(true); + expect(settings.settingsProvider).toBe('claude'); + }); + + it('returns false when already normalized (no-op)', () => { + const settings: Record = { + settingsProvider: 'claude', + providerConfigs: { + codex: { enabled: false }, + }, + }; + expect(ProviderSettingsCoordinator.normalizeProviderSelection(settings)).toBe(false); + }); + }); + + describe('reconcileAllProviders', () => { + it('delegates to each registered provider reconciler with its own conversations', () => { + const settings: Record = { model: 'haiku' }; + const claudeConv = { providerId: 'claude', messages: [] } as unknown as Conversation; + const conversations = [claudeConv]; + + const result = ProviderSettingsCoordinator.reconcileAllProviders(settings, conversations); + + expect(result).toHaveProperty('changed'); + expect(result).toHaveProperty('invalidatedConversations'); + expect(Array.isArray(result.invalidatedConversations)).toBe(true); + }); + + it('filters conversations per provider', () => { + const reconcileSpy = jest.spyOn( + ProviderRegistry.getSettingsReconciler('claude'), + 'reconcileModelWithEnvironment', + ); + + const claudeConv = { providerId: 'claude', messages: [] } as unknown as Conversation; + const otherConv = { providerId: 'codex', messages: [] } as unknown as Conversation; + const settings: Record = { model: 'haiku' }; + + ProviderSettingsCoordinator.reconcileAllProviders(settings, [claudeConv, otherConv]); + + // Claude reconciler should only receive claude conversations + expect(reconcileSpy).toHaveBeenCalledWith( + settings, + [claudeConv], + ); + + reconcileSpy.mockRestore(); + }); + }); + + describe('normalizeAllModelVariants', () => { + it('delegates to registered providers', () => { + const settings: Record = { model: 'haiku' }; + const result = ProviderSettingsCoordinator.normalizeAllModelVariants(settings); + expect(typeof result).toBe('boolean'); + }); + + it('migrates the active Codex primary model when an older built-in value is persisted', () => { + const settings: Record = { + settingsProvider: 'codex', + model: 'gpt-5.4', + providerConfigs: { + codex: { enabled: true, discoveredModels: TEST_CODEX_CATALOG }, + }, + savedProviderModel: { codex: 'gpt-5.4' }, + }; + + expect(ProviderSettingsCoordinator.normalizeAllModelVariants(settings)).toBe(true); + expect(settings.model).toBe(TEST_CODEX_MODEL); + expect(settings.savedProviderModel).toEqual({ codex: TEST_CODEX_MODEL }); + }); + }); + + describe('reconcileTitleGenerationModelSelection', () => { + it('migrates available Claude custom title models to provider-qualified ids', () => { + const settings: Record = { + titleGenerationModel: 'claude-opus-4-6', + providerConfigs: { + claude: { + ...DEFAULT_CLAUDE_PROVIDER_SETTINGS, + customModels: 'claude-opus-4-6', + }, + }, + }; + + expect( + ProviderSettingsCoordinator.reconcileTitleGenerationModelSelection(settings), + ).toBe(true); + expect(settings.titleGenerationModel).toBe('claude-code/claude-opus-4-6'); + }); + + it('clears titleGenerationModel when no provider exposes the saved model', () => { + const settings: Record = { + titleGenerationModel: 'claude-opus-4-6', + providerConfigs: { + claude: { + ...DEFAULT_CLAUDE_PROVIDER_SETTINGS, + customModels: '', + }, + }, + }; + + expect( + ProviderSettingsCoordinator.reconcileTitleGenerationModelSelection(settings), + ).toBe(true); + expect(settings.titleGenerationModel).toBe(''); + }); + + it('clears stale provider-qualified custom title models instead of retargeting to a fallback', () => { + const settings: Record = { + titleGenerationModel: 'openai-codex/my-custom-model', + providerConfigs: { + codex: { + enabled: true, + customModels: '', + }, + }, + }; + + expect( + ProviderSettingsCoordinator.reconcileTitleGenerationModelSelection(settings), + ).toBe(true); + expect(settings.titleGenerationModel).toBe(''); + }); + + it('migrates available Codex custom title models to provider-qualified ids', () => { + const settings: Record = { + titleGenerationModel: 'my-custom-model', + providerConfigs: { + codex: { + enabled: true, + customModels: 'my-custom-model', + }, + }, + }; + + expect( + ProviderSettingsCoordinator.reconcileTitleGenerationModelSelection(settings), + ).toBe(true); + expect(settings.titleGenerationModel).toBe('openai-codex/my-custom-model'); + }); + }); + + describe('projectActiveProviderState', () => { + it('projects saved model and effort for the settings provider', () => { + const settings: Record = { + settingsProvider: 'codex', + providerConfigs: { + codex: { enabled: true, discoveredModels: TEST_CODEX_CATALOG }, + }, + permissionMode: 'yolo', + model: 'haiku', + effortLevel: 'high', + serviceTier: 'default', + thinkingBudget: 'off', + savedProviderModel: { codex: TEST_CODEX_MODEL, claude: 'haiku' }, + savedProviderEffort: { codex: 'medium', claude: 'high' }, + savedProviderServiceTier: { codex: 'fast', claude: 'default' }, + savedProviderThinkingBudget: { codex: '1024', claude: 'off' }, + savedProviderPermissionMode: { codex: 'normal', claude: 'yolo' }, + }; + + ProviderSettingsCoordinator.projectActiveProviderState(settings); + + expect(settings.model).toBe(TEST_CODEX_MODEL); + expect(settings.effortLevel).toBe('medium'); + expect(settings.serviceTier).toBe('fast'); + expect(settings.thinkingBudget).toBe('off'); + expect(settings.permissionMode).toBe('normal'); + }); + + it('migrates a saved legacy Codex model before projecting provider state', () => { + const settings: Record = { + settingsProvider: 'claude', + model: 'haiku', + effortLevel: 'high', + serviceTier: 'default', + thinkingBudget: 'off', + providerConfigs: { + codex: { enabled: true, discoveredModels: TEST_CODEX_CATALOG }, + }, + savedProviderModel: { claude: 'haiku', codex: 'gpt-5.4' }, + savedProviderEffort: { claude: 'high', codex: 'medium' }, + savedProviderServiceTier: { claude: 'default', codex: 'fast' }, + savedProviderThinkingBudget: { claude: 'off', codex: 'off' }, + }; + + const snapshot = ProviderSettingsCoordinator.getProviderSettingsSnapshot(settings, 'codex'); + + expect(snapshot.model).toBe(TEST_CODEX_MODEL); + expect(snapshot.serviceTier).toBe('fast'); + }); + + it('defaults to claude when settingsProvider is not set', () => { + const settings: Record = { + model: 'old-model', + effortLevel: 'low', + serviceTier: 'default', + thinkingBudget: '500', + savedProviderModel: { claude: 'sonnet' }, + savedProviderEffort: { claude: 'high' }, + savedProviderServiceTier: { claude: 'default' }, + savedProviderThinkingBudget: { claude: 'off' }, + }; + + ProviderSettingsCoordinator.projectActiveProviderState(settings); + + expect(settings.model).toBe('sonnet'); + expect(settings.effortLevel).toBe('high'); + expect(settings.serviceTier).toBe('default'); + expect(settings.thinkingBudget).toBe('500'); + }); + + it('does not overwrite when no saved values exist', () => { + const settings: Record = { + settingsProvider: 'claude', + model: 'haiku', + effortLevel: 'high', + serviceTier: 'default', + thinkingBudget: 'off', + savedProviderModel: {}, + savedProviderEffort: {}, + savedProviderServiceTier: {}, + savedProviderThinkingBudget: {}, + }; + + ProviderSettingsCoordinator.projectActiveProviderState(settings); + + expect(settings.model).toBe('haiku'); + expect(settings.effortLevel).toBe('high'); + expect(settings.thinkingBudget).toBe('off'); + }); + + it('handles missing saved maps gracefully', () => { + const settings: Record = { + settingsProvider: 'claude', + model: 'haiku', + effortLevel: 'high', + serviceTier: 'default', + thinkingBudget: 'off', + }; + + // Should not throw + ProviderSettingsCoordinator.projectActiveProviderState(settings); + + expect(settings.model).toBe('haiku'); + }); + + it('normalizes saved effort values that the projected Claude model no longer supports', () => { + const settings: Record = { + settingsProvider: 'claude', + model: 'claude-sonnet-4-5', + effortLevel: 'xhigh', + serviceTier: 'default', + thinkingBudget: 'off', + savedProviderModel: { claude: 'claude-sonnet-4-5' }, + savedProviderEffort: { claude: 'xhigh' }, + savedProviderServiceTier: { claude: 'default' }, + savedProviderThinkingBudget: { claude: 'off' }, + }; + + ProviderSettingsCoordinator.projectActiveProviderState(settings); + + expect(settings.model).toBe('claude-sonnet-4-5'); + expect(settings.effortLevel).toBe('high'); + }); + }); + + describe('persistProjectedProviderState', () => { + it('stores the current top-level projection for the settings provider', () => { + const settings: Record = { + settingsProvider: 'codex', + providerConfigs: { + codex: { enabled: true, discoveredModels: TEST_CODEX_CATALOG }, + }, + permissionMode: 'normal', + model: TEST_CODEX_MODEL, + effortLevel: 'low', + serviceTier: 'fast', + thinkingBudget: 'off', + savedProviderModel: { claude: 'haiku' }, + savedProviderEffort: { claude: 'high' }, + savedProviderServiceTier: { claude: 'default' }, + savedProviderThinkingBudget: { claude: 'off' }, + savedProviderPermissionMode: { claude: 'yolo' }, + }; + + ProviderSettingsCoordinator.persistProjectedProviderState(settings); + + expect(settings.savedProviderModel).toEqual({ + claude: 'haiku', + codex: TEST_CODEX_MODEL, + }); + expect(settings.savedProviderEffort).toEqual({ + claude: 'high', + codex: 'low', + }); + expect(settings.savedProviderServiceTier).toEqual({ + claude: 'default', + codex: 'fast', + }); + expect(settings.savedProviderPermissionMode).toEqual({ + claude: 'yolo', + codex: 'normal', + }); + }); + }); + + describe('projectProviderState', () => { + it('seeds a provider projection from provider defaults when no saved values exist', () => { + const settings: Record = { + settingsProvider: 'claude', + providerConfigs: { + codex: { + enabled: true, + discoveredModels: TEST_CODEX_CATALOG, + environmentVariables: '', + }, + }, + model: 'haiku', + effortLevel: 'high', + serviceTier: 'default', + thinkingBudget: 'off', + savedProviderModel: {}, + savedProviderEffort: {}, + savedProviderServiceTier: {}, + savedProviderThinkingBudget: {}, + }; + + ProviderSettingsCoordinator.projectProviderState(settings, 'codex'); + + expect(settings.model).toBe(TEST_CODEX_MODEL); + expect(settings.effortLevel).toBe('high'); + expect(settings.serviceTier).toBe('default'); + }); + + it('preserves saved service tier when the projected model hides the toggle', () => { + const settings: Record = { + settingsProvider: 'codex', + providerConfigs: { + codex: { + enabled: true, + environmentVariables: '', + }, + }, + model: 'gpt-5.4-mini', + effortLevel: 'medium', + serviceTier: 'default', + thinkingBudget: 'off', + savedProviderModel: { codex: 'gpt-5.4-mini' }, + savedProviderEffort: { codex: 'medium' }, + savedProviderServiceTier: { codex: 'fast' }, + savedProviderThinkingBudget: { codex: 'off' }, + }; + + ProviderSettingsCoordinator.projectProviderState(settings, 'codex'); + + expect(settings.model).toBe('gpt-5.4-mini'); + expect(settings.serviceTier).toBe('fast'); + }); + + it('derives OpenCode permission mode from the managed selected mode when no provider snapshot exists yet', () => { + const settings: Record = { + settingsProvider: 'claude', + permissionMode: 'yolo', + providerConfigs: { + opencode: { + enabled: true, + selectedMode: 'claudian-safe', + }, + }, + model: 'haiku', + effortLevel: 'high', + serviceTier: 'default', + thinkingBudget: 'off', + savedProviderModel: {}, + savedProviderEffort: {}, + savedProviderServiceTier: {}, + savedProviderThinkingBudget: {}, + savedProviderPermissionMode: {}, + }; + + ProviderSettingsCoordinator.projectProviderState(settings, 'opencode'); + + expect(settings.permissionMode).toBe('normal'); + }); + + it('prefers the active OpenCode selected mode over a stale top-level permission projection', () => { + const settings: Record = { + settingsProvider: 'opencode', + permissionMode: 'normal', + providerConfigs: { + opencode: { + enabled: true, + selectedMode: 'build', + }, + }, + model: 'haiku', + effortLevel: 'high', + serviceTier: 'default', + thinkingBudget: 'off', + savedProviderModel: {}, + savedProviderEffort: {}, + savedProviderServiceTier: {}, + savedProviderThinkingBudget: {}, + savedProviderPermissionMode: {}, + }; + + ProviderSettingsCoordinator.projectProviderState(settings, 'opencode'); + + expect(settings.permissionMode).toBe('yolo'); + }); + }); + + describe('provider-scoped reconciliation', () => { + it('updates the inactive provider snapshot without clobbering the active projection', () => { + const codexConv = { + providerId: 'codex', + sessionId: 'thread-1', + messages: [], + } as unknown as Conversation; + + const settings: Record = { + settingsProvider: 'claude', + providerConfigs: { + codex: { + enabled: true, + discoveredModels: TEST_CODEX_CATALOG, + environmentVariables: `OPENAI_MODEL=${TEST_CODEX_MODEL}`, + }, + }, + model: 'haiku', + effortLevel: 'high', + serviceTier: 'default', + thinkingBudget: 'off', + savedProviderModel: { claude: 'haiku', codex: TEST_CODEX_MODEL }, + savedProviderEffort: { claude: 'high', codex: 'medium' }, + savedProviderServiceTier: { claude: 'default', codex: 'fast' }, + savedProviderThinkingBudget: { claude: 'off', codex: 'off' }, + }; + + const result = ProviderSettingsCoordinator.reconcileAllProviders(settings, [codexConv]); + + expect(result.changed).toBe(true); + expect(codexConv.sessionId).toBeNull(); + expect(codexConv.providerState).toBeUndefined(); + expect(settings.model).toBe('haiku'); + expect(settings.savedProviderModel).toEqual({ + claude: 'haiku', + codex: TEST_CODEX_MODEL, + }); + expect(settings.savedProviderServiceTier).toEqual({ + claude: 'default', + codex: 'fast', + }); + }); + }); +}); diff --git a/tests/unit/core/providers/ProviderWorkspaceRegistry.test.ts b/tests/unit/core/providers/ProviderWorkspaceRegistry.test.ts new file mode 100644 index 0000000..33be5d6 --- /dev/null +++ b/tests/unit/core/providers/ProviderWorkspaceRegistry.test.ts @@ -0,0 +1,84 @@ +import '@/providers'; + +import { ProviderWorkspaceRegistry } from '@/core/providers/ProviderWorkspaceRegistry'; + +describe('ProviderWorkspaceRegistry', () => { + afterEach(() => { + ProviderWorkspaceRegistry.clear(); + }); + + it('returns agent mention providers through the workspace registry', () => { + const claudeProvider = { searchAgents: jest.fn().mockReturnValue([]) }; + const codexProvider = { searchAgents: jest.fn().mockReturnValue([]) }; + + ProviderWorkspaceRegistry.setServices('claude', { + agentMentionProvider: claudeProvider as any, + }); + ProviderWorkspaceRegistry.setServices('codex', { + agentMentionProvider: codexProvider as any, + }); + + expect(ProviderWorkspaceRegistry.getAgentMentionProvider('claude')).toBe(claudeProvider); + expect(ProviderWorkspaceRegistry.getAgentMentionProvider('codex')).toBe(codexProvider); + }); + + it('refreshes agent mention state through the workspace registry', async () => { + const refreshClaude = jest.fn().mockResolvedValue(undefined); + const refreshCodex = jest.fn().mockResolvedValue(undefined); + + ProviderWorkspaceRegistry.setServices('claude', { + refreshAgentMentions: refreshClaude, + }); + ProviderWorkspaceRegistry.setServices('codex', { + refreshAgentMentions: refreshCodex, + }); + + await ProviderWorkspaceRegistry.refreshAgentMentions('codex'); + + expect(refreshClaude).not.toHaveBeenCalled(); + expect(refreshCodex).toHaveBeenCalled(); + }); + + it('returns the assigned catalog for a provider', () => { + const mockCatalog = { + listDropdownEntries: jest.fn(), + listVaultEntries: jest.fn(), + saveVaultEntry: jest.fn(), + deleteVaultEntry: jest.fn(), + setRuntimeCommands: jest.fn(), + getDropdownConfig: jest.fn(), + refresh: jest.fn(), + }; + + ProviderWorkspaceRegistry.setServices('claude', { + commandCatalog: mockCatalog as any, + }); + + expect(ProviderWorkspaceRegistry.getCommandCatalog('claude')).toBe(mockCatalog); + }); + + it('returns the runtime command loader for a provider', () => { + const runtimeCommandLoader = { + isAvailable: jest.fn().mockReturnValue(true), + loadCommands: jest.fn().mockResolvedValue([]), + }; + + ProviderWorkspaceRegistry.setServices('opencode', { + runtimeCommandLoader: runtimeCommandLoader as any, + }); + + expect(ProviderWorkspaceRegistry.getRuntimeCommandLoader('opencode')).toBe(runtimeCommandLoader); + }); + + it('returns the tab warmup policy for a provider', () => { + const tabWarmupPolicy = { + resolveMode: jest.fn().mockReturnValue('commands'), + }; + + ProviderWorkspaceRegistry.setServices('opencode', { + tabWarmupPolicy: tabWarmupPolicy as any, + }); + + expect(ProviderWorkspaceRegistry.getTabWarmupPolicy('opencode')).toBe(tabWarmupPolicy); + }); +}); diff --git a/tests/unit/core/providers/modelRouting.test.ts b/tests/unit/core/providers/modelRouting.test.ts new file mode 100644 index 0000000..65d1e30 --- /dev/null +++ b/tests/unit/core/providers/modelRouting.test.ts @@ -0,0 +1,92 @@ +import '@/providers'; + +import { TEST_CODEX_MODEL } from '@test/helpers/codexModels'; + +import { getEnabledProviderForModel, getProviderForModel } from '@/core/providers/modelRouting'; + +describe('getProviderForModel', () => { + it('routes Claude default models to claude', () => { + expect(getProviderForModel('haiku')).toBe('claude'); + expect(getProviderForModel('sonnet')).toBe('claude'); + expect(getProviderForModel('opus')).toBe('claude'); + }); + + it('routes Claude extended models to claude', () => { + expect(getProviderForModel('claude-sonnet-4-5-20250514')).toBe('claude'); + expect(getProviderForModel('claude-opus-4-6-20250616')).toBe('claude'); + }); + + it('routes Codex default models to codex', () => { + expect(getProviderForModel(TEST_CODEX_MODEL)).toBe('codex'); + }); + + it('routes unknown models to claude (default)', () => { + expect(getProviderForModel('some-unknown-model')).toBe('claude'); + }); + + it('routes models starting with gpt- to codex', () => { + expect(getProviderForModel('gpt-4o')).toBe('codex'); + expect(getProviderForModel('gpt-custom')).toBe('codex'); + }); + + it('routes models starting with o prefix to codex', () => { + expect(getProviderForModel('o3')).toBe('codex'); + expect(getProviderForModel('o4-mini')).toBe('codex'); + }); + + it('routes custom OPENAI_MODEL to codex when settings are provided', () => { + const settings = { environmentVariables: 'OPENAI_MODEL=my-custom-model' }; + expect(getProviderForModel('my-custom-model', settings)).toBe('codex'); + }); + + it('routes provider-qualified custom model ids without raw name collisions', () => { + const settings = { + providerConfigs: { + claude: { + customModels: 'deepseek-v4-pro', + }, + codex: { + enabled: true, + customModels: 'deepseek-v4-pro', + }, + }, + }; + + expect(getProviderForModel('claude-code/deepseek-v4-pro', settings)).toBe('claude'); + expect(getProviderForModel('openai-codex/deepseek-v4-pro', settings)).toBe('codex'); + }); + + it('routes settings-defined custom Codex models to codex when settings are provided', () => { + const settings = { + providerConfigs: { + codex: { + enabled: true, + customModels: 'my-custom-model', + }, + }, + }; + + expect(getProviderForModel('my-custom-model', settings)).toBe('codex'); + }); + + it('routes custom OPENAI_MODEL to claude without settings (no context)', () => { + expect(getProviderForModel('my-custom-model')).toBe('claude'); + }); + + it('can resolve blank-tab routing within enabled providers only', () => { + const settings = { + settingsProvider: 'claude', + providerConfigs: { + claude: { + environmentVariables: `ANTHROPIC_MODEL=${TEST_CODEX_MODEL}`, + }, + codex: { + enabled: false, + }, + }, + }; + + expect(getProviderForModel(TEST_CODEX_MODEL, settings)).toBe('codex'); + expect(getEnabledProviderForModel(TEST_CODEX_MODEL, settings)).toBe('claude'); + }); +}); diff --git a/tests/unit/core/providers/modelSelection.test.ts b/tests/unit/core/providers/modelSelection.test.ts new file mode 100644 index 0000000..695b27b --- /dev/null +++ b/tests/unit/core/providers/modelSelection.test.ts @@ -0,0 +1,155 @@ +import { + decodeProviderModelSelectionId, + encodeProviderModelSelectionId, + getProviderModelSelectionPrefix, + isProviderModelSelectionId, + toProviderRuntimeModelId, +} from '@/core/providers/modelSelection'; + +describe('model selection namespacing', () => { + describe('getProviderModelSelectionPrefix', () => { + it('returns the registered prefix for each known provider', () => { + expect(getProviderModelSelectionPrefix('claude')).toBe('claude-code/'); + expect(getProviderModelSelectionPrefix('codex')).toBe('openai-codex/'); + expect(getProviderModelSelectionPrefix('opencode')).toBe('opencode/'); + expect(getProviderModelSelectionPrefix('pi')).toBe('pi/'); + }); + + it('returns null for a provider with no registered prefix', () => { + expect(getProviderModelSelectionPrefix('unknown-provider')).toBeNull(); + }); + }); + + describe('encodeProviderModelSelectionId', () => { + it('prefixes a bare model id with the provider namespace', () => { + expect(encodeProviderModelSelectionId('claude', 'deepseek-v4-pro')).toBe('claude-code/deepseek-v4-pro'); + expect(encodeProviderModelSelectionId('codex', 'gpt-5-custom')).toBe('openai-codex/gpt-5-custom'); + }); + + it('is idempotent: an already-namespaced id is returned unchanged', () => { + const namespaced = 'claude-code/deepseek-v4-pro'; + expect(encodeProviderModelSelectionId('claude', namespaced)).toBe(namespaced); + }); + + it('trims surrounding whitespace before prefixing', () => { + expect(encodeProviderModelSelectionId('claude', ' deepseek-v4-pro ')).toBe('claude-code/deepseek-v4-pro'); + }); + + it('returns an empty string for empty or whitespace-only input', () => { + expect(encodeProviderModelSelectionId('claude', '')).toBe(''); + expect(encodeProviderModelSelectionId('claude', ' ')).toBe(''); + }); + + // encode only guards against its OWN prefix, so a value that already carries a + // different provider's namespace is treated as opaque and re-prefixed. This is + // acceptable because callers only ever encode bare ids they own. + it('re-prefixes a value that carries another provider namespace', () => { + expect(encodeProviderModelSelectionId('claude', 'openai-codex/gpt-5')).toBe('claude-code/openai-codex/gpt-5'); + }); + + it('leaves the id untouched when the provider has no registered prefix', () => { + expect(encodeProviderModelSelectionId('unknown-provider', 'deepseek-v4-pro')).toBe('deepseek-v4-pro'); + }); + }); + + describe('decodeProviderModelSelectionId', () => { + it('decodes a namespaced id into its provider and model id', () => { + expect(decodeProviderModelSelectionId('claude-code/deepseek-v4-pro')).toEqual({ + providerId: 'claude', + modelId: 'deepseek-v4-pro', + }); + expect(decodeProviderModelSelectionId('openai-codex/gpt-5')).toEqual({ + providerId: 'codex', + modelId: 'gpt-5', + }); + expect(decodeProviderModelSelectionId('opencode/qwen')).toEqual({ + providerId: 'opencode', + modelId: 'qwen', + }); + expect(decodeProviderModelSelectionId('pi/assistant')).toEqual({ + providerId: 'pi', + modelId: 'assistant', + }); + }); + + it('returns null for empty or whitespace-only input', () => { + expect(decodeProviderModelSelectionId('')).toBeNull(); + expect(decodeProviderModelSelectionId(' ')).toBeNull(); + }); + + it('returns null for a non-namespaced model id', () => { + expect(decodeProviderModelSelectionId('deepseek-v4-pro')).toBeNull(); + expect(decodeProviderModelSelectionId('sonnet')).toBeNull(); + }); + + it('returns null when only the prefix is present (no model id)', () => { + expect(decodeProviderModelSelectionId('claude-code/')).toBeNull(); + expect(decodeProviderModelSelectionId('openai-codex/ ')).toBeNull(); + }); + + it('trims surrounding whitespace before decoding', () => { + expect(decodeProviderModelSelectionId(' claude-code/deepseek-v4-pro ')).toEqual({ + providerId: 'claude', + modelId: 'deepseek-v4-pro', + }); + }); + }); + + describe('isProviderModelSelectionId', () => { + it('is true for a value carrying the given provider namespace', () => { + expect(isProviderModelSelectionId('claude', 'claude-code/deepseek-v4-pro')).toBe(true); + expect(isProviderModelSelectionId('codex', 'openai-codex/gpt-5')).toBe(true); + }); + + // The cross-provider check is the core invariant that lets identically-named + // custom models coexist: a claude-namespaced id must NOT be claimed by codex. + it('is false for a value carrying a different provider namespace', () => { + expect(isProviderModelSelectionId('codex', 'claude-code/deepseek-v4-pro')).toBe(false); + expect(isProviderModelSelectionId('claude', 'openai-codex/gpt-5')).toBe(false); + }); + + it('is false for a bare model id and for empty input', () => { + expect(isProviderModelSelectionId('claude', 'deepseek-v4-pro')).toBe(false); + expect(isProviderModelSelectionId('claude', '')).toBe(false); + }); + }); + + describe('toProviderRuntimeModelId', () => { + it('strips the namespace when the value belongs to the given provider', () => { + expect(toProviderRuntimeModelId('claude', 'claude-code/deepseek-v4-pro')).toBe('deepseek-v4-pro'); + expect(toProviderRuntimeModelId('codex', 'openai-codex/gpt-5')).toBe('gpt-5'); + }); + + it('leaves a bare model id unchanged', () => { + expect(toProviderRuntimeModelId('claude', 'deepseek-v4-pro')).toBe('deepseek-v4-pro'); + }); + + // Never strip another provider's namespace: handing it through verbatim is what + // keeps a stray cross-provider id from being misrouted at the runtime seam. + it('leaves a value unchanged when it carries another provider namespace', () => { + expect(toProviderRuntimeModelId('codex', 'claude-code/deepseek-v4-pro')).toBe('claude-code/deepseek-v4-pro'); + }); + + it('returns an empty string unchanged', () => { + expect(toProviderRuntimeModelId('claude', '')).toBe(''); + }); + }); + + describe('encode/decode round-trip', () => { + it.each([ + ['claude', 'claude-code/', 'deepseek-v4-pro'], + ['codex', 'openai-codex/', 'gpt-5-custom'], + ['opencode', 'opencode/', 'qwen-max'], + ['pi', 'pi/', 'assistant-1'], + ] as const)('round-trips a %s model id through encode and toRuntimeModelId', (providerId, prefix, modelId) => { + const encoded = encodeProviderModelSelectionId(providerId, modelId); + expect(encoded).toBe(`${prefix}${modelId}`); + // Stripping the runtime id must recover the original bare model id. + expect(toProviderRuntimeModelId(providerId, encoded)).toBe(modelId); + // Encoding is idempotent, so re-encoding never double-prefixes. + expect(encodeProviderModelSelectionId(providerId, encoded)).toBe(encoded); + // Decoding must attribute the id back to the owning provider. + expect(decodeProviderModelSelectionId(encoded)).toEqual({ providerId, modelId }); + }); + }); +}); diff --git a/tests/unit/core/providers/providerEnvironment.test.ts b/tests/unit/core/providers/providerEnvironment.test.ts new file mode 100644 index 0000000..0646489 --- /dev/null +++ b/tests/unit/core/providers/providerEnvironment.test.ts @@ -0,0 +1,162 @@ +import '@/providers'; + +import { + classifyEnvironmentVariablesByOwnership, + getEnvironmentReviewKeysForScope, + getEnvironmentScopeUpdates, + getProviderEnvironmentVariables, + getRuntimeEnvironmentText, + getSharedEnvironmentVariables, + inferEnvironmentSnippetScope, + resolveEnvironmentSnippetScope, + setProviderEnvironmentVariables, + setSharedEnvironmentVariables, +} from '@/core/providers/providerEnvironment'; + +describe('providerEnvironment', () => { + describe('classifyEnvironmentVariablesByOwnership', () => { + it('splits shared, Claude, and Codex vars by ownership', () => { + const result = classifyEnvironmentVariablesByOwnership([ + 'PATH=/usr/local/bin', + 'ANTHROPIC_API_KEY=claude-key', + 'OPENAI_API_KEY=codex-key', + 'CODEX_SANDBOX=workspace-write', + 'CUSTOM_FLAG=1', + ].join('\n')); + + expect(result.shared).toBe(['PATH=/usr/local/bin', 'CUSTOM_FLAG=1'].join('\n')); + expect(result.providers.claude).toBe('ANTHROPIC_API_KEY=claude-key'); + expect(result.providers.codex).toBe([ + 'OPENAI_API_KEY=codex-key', + 'CODEX_SANDBOX=workspace-write', + ].join('\n')); + expect(result.reviewKeys).toEqual(['CUSTOM_FLAG']); + }); + + it('keeps comments attached to the next owned variable when migrating', () => { + const result = classifyEnvironmentVariablesByOwnership([ + '# shared comment', + 'PATH=/usr/local/bin', + '', + '# claude comment', + 'ANTHROPIC_MODEL=claude-custom', + ].join('\n')); + + expect(result.shared).toBe(['# shared comment', 'PATH=/usr/local/bin'].join('\n')); + expect(result.providers.claude).toBe(['', '# claude comment', 'ANTHROPIC_MODEL=claude-custom'].join('\n')); + }); + }); + + describe('runtime env accessors', () => { + it('reads split shared/provider env from settings', () => { + const settings: Record = { + sharedEnvironmentVariables: 'PATH=/usr/local/bin', + providerConfigs: { + claude: { environmentVariables: 'ANTHROPIC_MODEL=custom-model' }, + }, + }; + + expect(getSharedEnvironmentVariables(settings)).toBe('PATH=/usr/local/bin'); + expect(getProviderEnvironmentVariables(settings, 'claude')).toBe('ANTHROPIC_MODEL=custom-model'); + expect(getRuntimeEnvironmentText(settings, 'claude')).toBe([ + 'PATH=/usr/local/bin', + 'ANTHROPIC_MODEL=custom-model', + ].join('\n')); + }); + + it('falls back to classifying legacy single-bag env settings', () => { + const settings: Record = { + environmentVariables: [ + 'PATH=/usr/local/bin', + 'ANTHROPIC_MODEL=claude-custom', + 'OPENAI_MODEL=gpt-custom', + ].join('\n'), + }; + + expect(getSharedEnvironmentVariables(settings)).toBe('PATH=/usr/local/bin'); + expect(getProviderEnvironmentVariables(settings, 'claude')).toBe('ANTHROPIC_MODEL=claude-custom'); + expect(getProviderEnvironmentVariables(settings, 'codex')).toBe('OPENAI_MODEL=gpt-custom'); + }); + + it('updates split env settings through scoped setters', () => { + const settings: Record = {}; + + setSharedEnvironmentVariables(settings, 'PATH=/usr/local/bin'); + setProviderEnvironmentVariables(settings, 'codex', 'OPENAI_API_KEY=test-key'); + + expect(settings.sharedEnvironmentVariables).toBe('PATH=/usr/local/bin'); + expect(settings.providerConfigs).toEqual({ + codex: { environmentVariables: 'OPENAI_API_KEY=test-key' }, + }); + }); + }); + + describe('getEnvironmentReviewKeysForScope', () => { + it('flags unknown keys left in shared env for manual review', () => { + const reviewKeys = getEnvironmentReviewKeysForScope([ + 'PATH=/usr/local/bin', + 'CUSTOM_FLAG=1', + ].join('\n'), 'shared'); + + expect(reviewKeys).toEqual(['CUSTOM_FLAG']); + }); + + it('flags shared and foreign-provider keys in provider env sections', () => { + const reviewKeys = getEnvironmentReviewKeysForScope([ + 'PATH=/usr/local/bin', + 'OPENAI_API_KEY=test-key', + 'CUSTOM_FLAG=1', + ].join('\n'), 'provider:claude'); + + expect(reviewKeys).toEqual(['PATH', 'OPENAI_API_KEY', 'CUSTOM_FLAG']); + }); + }); + + describe('inferEnvironmentSnippetScope', () => { + it('returns shared for neutral-only snippets', () => { + expect(inferEnvironmentSnippetScope('PATH=/usr/local/bin')).toBe('shared'); + }); + + it('returns provider scope for single-provider snippets', () => { + expect(inferEnvironmentSnippetScope('OPENAI_MODEL=gpt-custom')).toBe('provider:codex'); + }); + + it('keeps mixed-ownership legacy snippets unscoped', () => { + expect(inferEnvironmentSnippetScope([ + 'PATH=/usr/local/bin', + 'ANTHROPIC_MODEL=claude-custom', + ].join('\n'))).toBeUndefined(); + }); + }); + + describe('resolveEnvironmentSnippetScope', () => { + it('normalizes mixed snippets back to unscoped even if a stale scope was saved', () => { + expect(resolveEnvironmentSnippetScope([ + 'PATH=/usr/local/bin', + 'ANTHROPIC_MODEL=claude-custom', + ].join('\n'), 'shared')).toBeUndefined(); + }); + + it('keeps the fallback scope only for empty snippets', () => { + expect(resolveEnvironmentSnippetScope('', 'provider:codex')).toBe('provider:codex'); + }); + }); + + describe('getEnvironmentScopeUpdates', () => { + it('reclassifies mixed snippets into separate scope updates', () => { + expect(getEnvironmentScopeUpdates([ + 'PATH=/usr/local/bin', + 'ANTHROPIC_MODEL=claude-custom', + ].join('\n'), 'shared')).toEqual([ + { scope: 'shared', envText: 'PATH=/usr/local/bin' }, + { scope: 'provider:claude', envText: 'ANTHROPIC_MODEL=claude-custom' }, + ]); + }); + + it('uses the fallback scope only when there is no inferable content', () => { + expect(getEnvironmentScopeUpdates('', 'provider:claude')).toEqual([ + { scope: 'provider:claude', envText: '' }, + ]); + }); + }); +}); diff --git a/tests/unit/core/providers/tabLifecycle.test.ts b/tests/unit/core/providers/tabLifecycle.test.ts new file mode 100644 index 0000000..4d7aae5 --- /dev/null +++ b/tests/unit/core/providers/tabLifecycle.test.ts @@ -0,0 +1,218 @@ +import '@/providers'; + +import { TEST_CODEX_MODEL } from '@test/helpers/codexModels'; + +/** + * Tests for the model-driven multi-provider tab lifecycle. + * + * Covers transition-heavy cases: + * - blank → first send + * - blank → history bind + * - bound_cold → send + * - active → close + * - restore/switch staying cold + * - Codex-disable fallback + * - duplicate-owner prevention + * - provider lock after bind + */ +import { getProviderForModel } from '@/core/providers/modelRouting'; + +describe('Tab Lifecycle - Model-Driven Provider Routing', () => { + describe('getProviderForModel', () => { + it('derives claude from Claude model names', () => { + expect(getProviderForModel('haiku')).toBe('claude'); + expect(getProviderForModel('sonnet')).toBe('claude'); + expect(getProviderForModel('opus')).toBe('claude'); + expect(getProviderForModel('claude-sonnet-4-5-20250514')).toBe('claude'); + }); + + it('derives codex from Codex model names', () => { + expect(getProviderForModel(TEST_CODEX_MODEL)).toBe('codex'); + expect(getProviderForModel('gpt-4o')).toBe('codex'); + expect(getProviderForModel('o3')).toBe('codex'); + expect(getProviderForModel('o4-mini')).toBe('codex'); + }); + + it('defaults unknown models to claude', () => { + expect(getProviderForModel('custom-model')).toBe('claude'); + expect(getProviderForModel('')).toBe('claude'); + }); + + it('does not route "obsidian" to codex (no digit after o)', () => { + expect(getProviderForModel('obsidian')).toBe('claude'); + }); + }); +}); + +describe('Tab Lifecycle - Blank Tab Behavior', () => { + it('blank tabs start in blank lifecycle state with draft model', () => { + // Simulated tab state (not importing Tab module to keep this unit-level) + const tab = { + lifecycleState: 'blank' as const, + draftModel: 'haiku', + conversationId: null, + service: null, + serviceInitialized: false, + }; + + expect(tab.lifecycleState).toBe('blank'); + expect(tab.draftModel).toBe('haiku'); + expect(tab.conversationId).toBeNull(); + expect(tab.service).toBeNull(); + expect(tab.serviceInitialized).toBe(false); + }); + + it('blank tabs derive provider from draft model selection', () => { + expect(getProviderForModel(TEST_CODEX_MODEL)).toBe('codex'); + expect(getProviderForModel('sonnet')).toBe('claude'); + }); + + it('blank tab with Codex draft model should fall back when Codex is disabled', () => { + const draftModel = TEST_CODEX_MODEL; + const draftProvider = getProviderForModel(draftModel); + + // Verify the draft is Codex + expect(draftProvider).toBe('codex'); + + // When Codex disabled, fallback model should route to Claude + const fallbackModel = 'haiku'; + const fallbackProvider = getProviderForModel(fallbackModel); + expect(fallbackProvider).toBe('claude'); + }); +}); + +describe('Tab Lifecycle - Provider Lock After Bind', () => { + it('cross-provider model change should be rejected on bound sessions', () => { + const boundProvider = 'claude'; + const requestedModel = TEST_CODEX_MODEL; + const requestedProvider = getProviderForModel(requestedModel); + + // Provider mismatch should be detected + expect(requestedProvider).not.toBe(boundProvider); + }); + + it('same-provider model change should be allowed on bound sessions', () => { + const boundProvider = 'claude'; + const requestedModel = 'sonnet'; + const requestedProvider = getProviderForModel(requestedModel); + + expect(requestedProvider).toBe(boundProvider); + }); + + it('bound-cold sessions should accept same-provider model changes locally', () => { + const tab = { + lifecycleState: 'bound_cold' as const, + providerId: 'claude' as const, + serviceInitialized: false, + service: null, + }; + + // Changing model within same provider should not require runtime + const newModel = 'opus'; + expect(getProviderForModel(newModel)).toBe(tab.providerId); + expect(tab.serviceInitialized).toBe(false); // Should stay cold + }); +}); + +describe('Tab Lifecycle - History Bind', () => { + it('history selection should bind tab to persisted provider without starting runtime', () => { + // Simulated conversation from history + const conversation = { + id: 'conv-1', + providerId: 'codex' as const, + messages: [{ id: 'msg-1', role: 'user' as const, content: 'test' }], + }; + + // After bind, tab should be bound_cold with conversation's provider + const tab = { + lifecycleState: 'bound_cold' as const, + providerId: conversation.providerId, + conversationId: conversation.id, + draftModel: null, + service: null, + serviceInitialized: false, + }; + + expect(tab.lifecycleState).toBe('bound_cold'); + expect(tab.providerId).toBe('codex'); + expect(tab.conversationId).toBe('conv-1'); + expect(tab.draftModel).toBeNull(); + expect(tab.service).toBeNull(); + expect(tab.serviceInitialized).toBe(false); + }); +}); + +describe('Tab Lifecycle - Close Semantics', () => { + it('closing a blank tab should have no runtime to clean up', () => { + const tab = { + lifecycleState: 'blank' as const, + service: null, + serviceInitialized: false, + }; + + expect(tab.service).toBeNull(); + // No runtime teardown needed + }); + + it('closing a bound_cold tab should not start a runtime', () => { + const tab = { + lifecycleState: 'bound_cold' as const, + service: null, + serviceInitialized: false, + }; + + expect(tab.service).toBeNull(); + // Should not create service during close + }); + + it('closing a bound_active tab should clean up the runtime', () => { + const mockCleanup = jest.fn(); + const tab = { + lifecycleState: 'bound_active' as const, + service: { cleanup: mockCleanup }, + serviceInitialized: true, + }; + + // Simulate close behavior + tab.service.cleanup(); + expect(mockCleanup).toHaveBeenCalled(); + }); +}); + +describe('Tab Lifecycle - Codex Disable Fallback', () => { + it('existing Codex sessions should remain valid when Codex is disabled', () => { + const codexSession = { + id: 'conv-codex-1', + providerId: 'codex' as const, + messages: [{ id: 'msg-1', role: 'user' as const, content: 'hello' }], + }; + + // Disabling Codex should not invalidate this session + const codexEnabled = false; + + // Session should still be loadable + expect(codexSession.providerId).toBe('codex'); + expect(codexEnabled).toBe(false); + // The session provider is preserved regardless of codexEnabled setting + }); + + it('blank tab with Codex draft falls back to Claude default when Codex disabled', () => { + const tab = { + lifecycleState: 'blank' as const, + draftModel: TEST_CODEX_MODEL, + providerId: 'codex' as const, + }; + + const codexEnabled = false; + const draftProvider = getProviderForModel(tab.draftModel); + + if (!codexEnabled && draftProvider === 'codex') { + // Should fall back + tab.draftModel = 'haiku'; + tab.providerId = getProviderForModel('haiku') as any; + } + + expect(tab.draftModel).toBe('haiku'); + expect(tab.providerId).toBe('claude'); + }); +}); diff --git a/tests/unit/core/security/ApprovalManager.test.ts b/tests/unit/core/security/ApprovalManager.test.ts new file mode 100644 index 0000000..64bcd1f --- /dev/null +++ b/tests/unit/core/security/ApprovalManager.test.ts @@ -0,0 +1,152 @@ + +import { + getActionDescription, + getActionPattern, + matchesRulePattern, +} from '../../../../src/core/security/ApprovalManager'; + +describe('getActionPattern', () => { + it('extracts command from Bash tool input', () => { + expect(getActionPattern('Bash', { command: 'git status' })).toBe('git status'); + }); + + it('trims whitespace from Bash commands', () => { + expect(getActionPattern('Bash', { command: ' git status ' })).toBe('git status'); + }); + + it('returns empty string for non-string Bash command', () => { + expect(getActionPattern('Bash', { command: 123 })).toBe(''); + }); + + it('extracts file_path for Read/Write/Edit tools', () => { + expect(getActionPattern('Read', { file_path: '/test/file.md' })).toBe('/test/file.md'); + expect(getActionPattern('Write', { file_path: '/test/output.md' })).toBe('/test/output.md'); + expect(getActionPattern('Edit', { file_path: '/test/edit.md' })).toBe('/test/edit.md'); + }); + + it('returns null when file_path is missing', () => { + expect(getActionPattern('Read', {})).toBeNull(); + }); + + it('extracts notebook_path for NotebookEdit tool', () => { + expect(getActionPattern('NotebookEdit', { notebook_path: '/test/notebook.ipynb' })).toBe('/test/notebook.ipynb'); + }); + + it('falls back to file_path for NotebookEdit when notebook_path is missing', () => { + expect(getActionPattern('NotebookEdit', { file_path: '/test/notebook.ipynb' })).toBe('/test/notebook.ipynb'); + }); + + it('returns null for NotebookEdit when both paths are missing', () => { + expect(getActionPattern('NotebookEdit', {})).toBeNull(); + }); + + it('returns null when file_path is empty string', () => { + expect(getActionPattern('Read', { file_path: '' })).toBeNull(); + }); + + it('extracts pattern for Glob/Grep tools', () => { + expect(getActionPattern('Glob', { pattern: '**/*.md' })).toBe('**/*.md'); + expect(getActionPattern('Grep', { pattern: 'TODO' })).toBe('TODO'); + }); + + it('returns JSON for unknown tools', () => { + expect(getActionPattern('UnknownTool', { foo: 'bar' })).toBe('{"foo":"bar"}'); + }); +}); + +describe('getActionDescription', () => { + it('describes Bash tool actions', () => { + expect(getActionDescription('Bash', { command: 'git status' })).toBe('Run command: git status'); + }); + + it('describes file tool actions', () => { + expect(getActionDescription('Read', { file_path: '/f.md' })).toBe('Read file: /f.md'); + expect(getActionDescription('Write', { file_path: '/f.md' })).toBe('Write to file: /f.md'); + expect(getActionDescription('Edit', { file_path: '/f.md' })).toBe('Edit file: /f.md'); + }); + + it('describes search tool actions', () => { + expect(getActionDescription('Glob', { pattern: '*.md' })).toBe('Search files matching: *.md'); + expect(getActionDescription('Grep', { pattern: 'TODO' })).toBe('Search content matching: TODO'); + }); + + it('describes unknown tools with JSON', () => { + expect(getActionDescription('Custom', { a: 1 })).toBe('Custom: {"a":1}'); + }); +}); + +describe('matchesRulePattern', () => { + it('matches when no rule pattern is provided', () => { + expect(matchesRulePattern('Bash', 'git status', undefined)).toBe(true); + }); + + it('matches wildcard rule', () => { + expect(matchesRulePattern('Bash', 'anything', '*')).toBe(true); + }); + + it('matches exact rule', () => { + expect(matchesRulePattern('Bash', 'git status', 'git status')).toBe(true); + }); + + it('rejects non-matching Bash rule without wildcard', () => { + expect(matchesRulePattern('Bash', 'git status', 'git commit')).toBe(false); + }); + + it('matches Bash wildcard prefix', () => { + expect(matchesRulePattern('Bash', 'git status', 'git *')).toBe(true); + expect(matchesRulePattern('Bash', 'git commit', 'git *')).toBe(true); + expect(matchesRulePattern('Bash', 'npm install', 'git *')).toBe(false); + }); + + it('matches Bash CC-format colon wildcard', () => { + expect(matchesRulePattern('Bash', 'npm install', 'npm:*')).toBe(true); + expect(matchesRulePattern('Bash', 'npm run build', 'npm run:*')).toBe(true); + expect(matchesRulePattern('Bash', 'yarn install', 'npm:*')).toBe(false); + }); + + it('does not allow Bash prefix collisions without a separator', () => { + expect(matchesRulePattern('Bash', 'github status', 'git:*')).toBe(false); + expect(matchesRulePattern('Bash', 'npmish install', 'npm:*')).toBe(false); + expect(matchesRulePattern('Bash', 'npm runner build', 'npm run:*')).toBe(false); + }); + + it('matches file path prefix for Read tool', () => { + expect(matchesRulePattern('Read', '/test/vault/notes/file.md', '/test/vault/')).toBe(true); + expect(matchesRulePattern('Read', '/other/path/file.md', '/test/vault/')).toBe(false); + }); + + it('respects path segment boundaries', () => { + expect(matchesRulePattern('Read', '/test/vault/notes/file.md', '/test/vault/notes')).toBe(true); + expect(matchesRulePattern('Read', '/test/vault/notes2/file.md', '/test/vault/notes')).toBe(false); + }); + + it('matches exact file path (same length, no trailing slash)', () => { + expect(matchesRulePattern('Read', '/test/vault/file.md', '/test/vault/file.md')).toBe(true); + }); + + it('matches file path with backslash normalization for same-length paths', () => { + expect(matchesRulePattern('Write', '/test/vault\\file.md', '/test/vault/file.md')).toBe(true); + }); + + it('allows simple prefix matching for non-file, non-bash tools', () => { + expect(matchesRulePattern('Glob', '**/*.md', '**/*')).toBe(true); + expect(matchesRulePattern('Grep', 'TODO in file', 'TODO')).toBe(true); + }); + + it('returns false for non-file, non-bash tools when prefix does not match', () => { + expect(matchesRulePattern('Glob', 'src/**', 'tests/**')).toBe(false); + }); + + it('matches exact Bash prefix without trailing space/wildcard via CC format', () => { + expect(matchesRulePattern('Bash', 'npm', 'npm:*')).toBe(true); + }); + + it('does not match when action pattern is null', () => { + expect(matchesRulePattern('Read', null, '/test/vault/')).toBe(false); + expect(matchesRulePattern('Read', null, '*')).toBe(false); + }); + + it('still matches when no rule pattern and action is null', () => { + expect(matchesRulePattern('Read', null, undefined)).toBe(true); + }); +}); diff --git a/tests/unit/core/storage/VaultFileAdapter.test.ts b/tests/unit/core/storage/VaultFileAdapter.test.ts new file mode 100644 index 0000000..29c6fe6 --- /dev/null +++ b/tests/unit/core/storage/VaultFileAdapter.test.ts @@ -0,0 +1,535 @@ +import type { App } from 'obsidian'; + +import { VaultFileAdapter } from '@/core/storage/VaultFileAdapter'; + +describe('VaultFileAdapter', () => { + let mockAdapter: jest.Mocked; + let vaultAdapter: VaultFileAdapter; + + const mockApp: Partial = { + vault: {} as any, + }; + + beforeEach(() => { + mockAdapter = { + exists: jest.fn(), + read: jest.fn(), + write: jest.fn(), + remove: jest.fn(), + rename: jest.fn(), + list: jest.fn(), + mkdir: jest.fn(), + stat: jest.fn(), + }; + + mockApp.vault = { adapter: mockAdapter } as any; + vaultAdapter = new VaultFileAdapter(mockApp as App); + }); + + describe('exists', () => { + it('delegates to vault adapter', async () => { + mockAdapter.exists.mockResolvedValue(true); + + const result = await vaultAdapter.exists('test/path.md'); + + expect(result).toBe(true); + expect(mockAdapter.exists).toHaveBeenCalledWith('test/path.md'); + }); + + it('delegates to vault adapter with false', async () => { + mockAdapter.exists.mockResolvedValue(false); + + const result = await vaultAdapter.exists('test/path.md'); + + expect(result).toBe(false); + }); + }); + + describe('read', () => { + it('delegates to vault adapter', async () => { + mockAdapter.read.mockResolvedValue('file content'); + + const result = await vaultAdapter.read('test/path.md'); + + expect(result).toBe('file content'); + expect(mockAdapter.read).toHaveBeenCalledWith('test/path.md'); + }); + }); + + describe('write', () => { + it('writes file when folder exists', async () => { + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.write.mockResolvedValue(); + + await vaultAdapter.write('folder/file.md', 'content'); + + expect(mockAdapter.exists).toHaveBeenCalledWith('folder'); + expect(mockAdapter.write).toHaveBeenCalledWith('folder/file.md', 'content'); + expect(mockAdapter.mkdir).not.toHaveBeenCalled(); + }); + + it('creates parent folder when it does not exist', async () => { + mockAdapter.exists.mockImplementation((path: string) => Promise.resolve(path !== 'folder')); + mockAdapter.mkdir.mockResolvedValue(); + mockAdapter.write.mockResolvedValue(); + + await vaultAdapter.write('folder/file.md', 'content'); + + expect(mockAdapter.mkdir).toHaveBeenCalledWith('folder'); + expect(mockAdapter.write).toHaveBeenCalledWith('folder/file.md', 'content'); + }); + + it('handles file in root (no folder)', async () => { + mockAdapter.write.mockResolvedValue(); + + await vaultAdapter.write('file.md', 'content'); + + expect(mockAdapter.exists).not.toHaveBeenCalled(); + expect(mockAdapter.mkdir).not.toHaveBeenCalled(); + expect(mockAdapter.write).toHaveBeenCalledWith('file.md', 'content'); + }); + + it('handles deeply nested paths', async () => { + mockAdapter.exists.mockResolvedValue(false); + mockAdapter.mkdir.mockResolvedValue(); + mockAdapter.write.mockResolvedValue(); + + await vaultAdapter.write('level1/level2/level3/file.md', 'content'); + + expect(mockAdapter.mkdir).toHaveBeenCalledWith('level1'); + expect(mockAdapter.mkdir).toHaveBeenCalledWith('level1/level2'); + expect(mockAdapter.mkdir).toHaveBeenCalledWith('level1/level2/level3'); + expect(mockAdapter.write).toHaveBeenCalledWith('level1/level2/level3/file.md', 'content'); + }); + }); + + describe('append', () => { + it('creates new file if it does not exist', async () => { + // All existence checks return false: folder doesn't exist, file doesn't exist + mockAdapter.exists.mockResolvedValue(false); + mockAdapter.mkdir.mockResolvedValue(); + mockAdapter.write.mockResolvedValue(); + + await vaultAdapter.append('folder/file.md', 'new content'); + + expect(mockAdapter.mkdir).toHaveBeenCalled(); + expect(mockAdapter.write).toHaveBeenCalledWith('folder/file.md', 'new content'); + expect(mockAdapter.read).not.toHaveBeenCalled(); + }); + + it('appends to existing file', async () => { + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.read.mockResolvedValue('existing content'); + mockAdapter.write.mockResolvedValue(); + + await vaultAdapter.append('file.md', '\nmore content'); + + expect(mockAdapter.read).toHaveBeenCalledWith('file.md'); + expect(mockAdapter.write).toHaveBeenCalledWith('file.md', 'existing content\nmore content'); + expect(mockAdapter.mkdir).not.toHaveBeenCalled(); + }); + + it('creates parent folder for new file', async () => { + mockAdapter.exists.mockResolvedValueOnce(false).mockResolvedValueOnce(false); + mockAdapter.mkdir.mockResolvedValue(); + mockAdapter.write.mockResolvedValue(); + + await vaultAdapter.append('folder/file.md', 'content'); + + expect(mockAdapter.mkdir).toHaveBeenCalledWith('folder'); + }); + + it('handles file in root', async () => { + mockAdapter.write.mockResolvedValue(); + + await vaultAdapter.append('file.md', 'content'); + + expect(mockAdapter.mkdir).not.toHaveBeenCalled(); + expect(mockAdapter.write).toHaveBeenCalledWith('file.md', 'content'); + }); + + it('appends empty string', async () => { + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.read.mockResolvedValue('existing'); + mockAdapter.write.mockResolvedValue(); + + await vaultAdapter.append('file.md', ''); + + expect(mockAdapter.write).toHaveBeenCalledWith('file.md', 'existing'); + }); + }); + + describe('delete', () => { + it('deletes file when it exists', async () => { + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.remove.mockResolvedValue(); + + await vaultAdapter.delete('file.md'); + + expect(mockAdapter.exists).toHaveBeenCalledWith('file.md'); + expect(mockAdapter.remove).toHaveBeenCalledWith('file.md'); + }); + + it('does nothing when file does not exist', async () => { + mockAdapter.exists.mockResolvedValue(false); + + await vaultAdapter.delete('file.md'); + + expect(mockAdapter.exists).toHaveBeenCalledWith('file.md'); + expect(mockAdapter.remove).not.toHaveBeenCalled(); + }); + + it('deletes nested file', async () => { + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.remove.mockResolvedValue(); + + await vaultAdapter.delete('folder/subfolder/file.md'); + + expect(mockAdapter.remove).toHaveBeenCalledWith('folder/subfolder/file.md'); + }); + }); + + describe('deleteFolder', () => { + it('deletes folder when it exists', async () => { + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.rmdir = jest.fn().mockResolvedValue(undefined); + + await vaultAdapter.deleteFolder('empty-folder'); + + expect(mockAdapter.exists).toHaveBeenCalledWith('empty-folder'); + expect(mockAdapter.rmdir).toHaveBeenCalledWith('empty-folder', false); + }); + + it('does nothing when folder does not exist', async () => { + mockAdapter.exists.mockResolvedValue(false); + mockAdapter.rmdir = jest.fn(); + + await vaultAdapter.deleteFolder('nonexistent-folder'); + + expect(mockAdapter.rmdir).not.toHaveBeenCalled(); + }); + + it('silently handles rmdir error (non-empty folder)', async () => { + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.rmdir = jest.fn().mockRejectedValue(new Error('Directory not empty')); + + await expect(vaultAdapter.deleteFolder('non-empty-folder')).resolves.toBeUndefined(); + }); + }); + + describe('listFiles', () => { + it('lists files in existing folder', async () => { + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.list.mockResolvedValue({ + files: ['file1.md', 'file2.md'], + folders: ['subfolder'], + }); + + const result = await vaultAdapter.listFiles('folder'); + + expect(result).toEqual(['file1.md', 'file2.md']); + expect(mockAdapter.list).toHaveBeenCalledWith('folder'); + }); + + it('returns empty array when folder does not exist', async () => { + mockAdapter.exists.mockResolvedValue(false); + + const result = await vaultAdapter.listFiles('folder'); + + expect(result).toEqual([]); + expect(mockAdapter.list).not.toHaveBeenCalled(); + }); + + it('returns empty array when no files exist', async () => { + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.list.mockResolvedValue({ + files: [], + folders: [], + }); + + const result = await vaultAdapter.listFiles('folder'); + + expect(result).toEqual([]); + }); + + it('handles folder with only subfolders', async () => { + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.list.mockResolvedValue({ + files: [], + folders: ['sub1', 'sub2'], + }); + + const result = await vaultAdapter.listFiles('folder'); + + expect(result).toEqual([]); + }); + }); + + describe('listFolders', () => { + it('lists folders in existing directory', async () => { + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.list.mockResolvedValue({ + files: ['file.md'], + folders: ['folder1', 'folder2'], + }); + + const result = await vaultAdapter.listFolders('folder'); + + expect(result).toEqual(['folder1', 'folder2']); + }); + + it('returns empty array when folder does not exist', async () => { + mockAdapter.exists.mockResolvedValue(false); + + const result = await vaultAdapter.listFolders('folder'); + + expect(result).toEqual([]); + expect(mockAdapter.list).not.toHaveBeenCalled(); + }); + + it('returns empty array when no folders exist', async () => { + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.list.mockResolvedValue({ + files: ['file.md'], + folders: [], + }); + + const result = await vaultAdapter.listFolders('folder'); + + expect(result).toEqual([]); + }); + }); + + describe('listFilesRecursive', () => { + it('lists all files in nested structure', async () => { + const mockList = jest.fn(); + mockList + .mockResolvedValueOnce({ files: ['root.md'], folders: ['folder1', 'folder2'] }) + .mockResolvedValueOnce({ files: ['folder1/f1.md'], folders: ['folder1/sub'] }) + .mockResolvedValueOnce({ files: ['folder1/sub/f2.md'], folders: [] }) + .mockResolvedValueOnce({ files: ['folder2/f3.md'], folders: [] }); + + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.list.mockImplementation((path: string) => mockList(path)); + + const result = await vaultAdapter.listFilesRecursive('root'); + + expect(result).toEqual([ + 'root.md', + 'folder1/f1.md', + 'folder1/sub/f2.md', + 'folder2/f3.md', + ]); + }); + + it('returns empty array for non-existent folder', async () => { + mockAdapter.exists.mockResolvedValue(false); + + const result = await vaultAdapter.listFilesRecursive('nonexistent'); + + expect(result).toEqual([]); + }); + + it('handles empty folder', async () => { + mockAdapter.exists.mockResolvedValue(true); + mockAdapter.list.mockResolvedValue({ files: [], folders: [] }); + + const result = await vaultAdapter.listFilesRecursive('empty'); + + expect(result).toEqual([]); + }); + + it('handles folder with only subfolders and no files', async () => { + mockAdapter.exists.mockResolvedValue(true); + const mockList = jest.fn(); + mockList + .mockResolvedValueOnce({ files: [], folders: ['sub'] }) + .mockResolvedValueOnce({ files: [], folders: [] }); + + mockAdapter.list.mockImplementation((path: string) => mockList(path)); + + const result = await vaultAdapter.listFilesRecursive('root'); + + expect(result).toEqual([]); + }); + + it('handles deeply nested structure', async () => { + mockAdapter.exists.mockResolvedValue(true); + const mockList = jest.fn(); + mockList + .mockResolvedValueOnce({ files: ['a.txt'], folders: ['b'] }) + .mockResolvedValueOnce({ files: ['b/b.txt'], folders: ['b/c'] }) + .mockResolvedValueOnce({ files: ['b/c/c.txt'], folders: ['b/c/d'] }) + .mockResolvedValueOnce({ files: ['b/c/d/d.txt'], folders: [] }); + + mockAdapter.list.mockImplementation((path: string) => mockList(path)); + + const result = await vaultAdapter.listFilesRecursive('root'); + + expect(result).toHaveLength(4); + expect(result).toContain('a.txt'); + expect(result).toContain('b/b.txt'); + expect(result).toContain('b/c/c.txt'); + expect(result).toContain('b/c/d/d.txt'); + }); + + it('handles multiple subfolders at same level', async () => { + mockAdapter.exists.mockResolvedValue(true); + const mockList = jest.fn(); + mockList + .mockResolvedValueOnce({ files: ['root.md'], folders: ['a', 'b', 'c'] }) + .mockResolvedValueOnce({ files: ['a/a.txt'], folders: [] }) + .mockResolvedValueOnce({ files: ['b/b.txt'], folders: [] }) + .mockResolvedValueOnce({ files: ['c/c.txt'], folders: [] }); + + mockAdapter.list.mockImplementation((path: string) => mockList(path)); + + const result = await vaultAdapter.listFilesRecursive('root'); + + expect(result).toHaveLength(4); + }); + }); + + describe('ensureFolder', () => { + it('returns early when folder exists', async () => { + mockAdapter.exists.mockResolvedValue(true); + + await vaultAdapter.ensureFolder('existing/folder'); + + expect(mockAdapter.exists).toHaveBeenCalledWith('existing/folder'); + expect(mockAdapter.mkdir).not.toHaveBeenCalled(); + }); + + it('creates folder when it does not exist', async () => { + mockAdapter.exists.mockResolvedValueOnce(false); + mockAdapter.mkdir.mockResolvedValue(); + + await vaultAdapter.ensureFolder('new/folder'); + + expect(mockAdapter.exists).toHaveBeenCalledWith('new/folder'); + expect(mockAdapter.mkdir).toHaveBeenCalledWith('new/folder'); + }); + + it('creates nested folders', async () => { + mockAdapter.exists.mockResolvedValue(false); + mockAdapter.mkdir.mockResolvedValue(); + + await vaultAdapter.ensureFolder('a/b/c'); + + expect(mockAdapter.mkdir).toHaveBeenCalledTimes(3); + expect(mockAdapter.mkdir).toHaveBeenCalledWith('a'); + expect(mockAdapter.mkdir).toHaveBeenCalledWith('a/b'); + expect(mockAdapter.mkdir).toHaveBeenCalledWith('a/b/c'); + }); + + it('handles folder with trailing slash', async () => { + mockAdapter.exists.mockResolvedValueOnce(false); + mockAdapter.mkdir.mockResolvedValue(); + + await vaultAdapter.ensureFolder('folder/'); + + expect(mockAdapter.mkdir).toHaveBeenCalledWith('folder'); + }); + + it('handles root folder', async () => { + mockAdapter.exists.mockResolvedValueOnce(false); + mockAdapter.mkdir.mockResolvedValue(); + + await vaultAdapter.ensureFolder('folder'); + + expect(mockAdapter.mkdir).toHaveBeenCalledWith('folder'); + }); + + it('skips creating intermediate folders that exist', async () => { + mockAdapter.exists.mockImplementation((path: string) => Promise.resolve( + path !== 'existing/intermediate/new' + )); + mockAdapter.mkdir.mockResolvedValue(); + + await vaultAdapter.ensureFolder('existing/intermediate/new'); + + expect(mockAdapter.mkdir).toHaveBeenCalledTimes(1); + expect(mockAdapter.mkdir).toHaveBeenCalledWith('existing/intermediate/new'); + }); + + it('handles folder with empty segments', async () => { + mockAdapter.exists.mockResolvedValueOnce(false); + mockAdapter.mkdir.mockResolvedValue(); + + await vaultAdapter.ensureFolder('folder//nested'); + + expect(mockAdapter.mkdir).toHaveBeenCalledTimes(2); + expect(mockAdapter.mkdir).toHaveBeenCalledWith('folder'); + expect(mockAdapter.mkdir).toHaveBeenCalledWith('folder/nested'); + }); + }); + + describe('rename', () => { + it('delegates to vault adapter rename', async () => { + mockAdapter.rename.mockResolvedValue(); + + await vaultAdapter.rename('old.md', 'new.md'); + + expect(mockAdapter.rename).toHaveBeenCalledWith('old.md', 'new.md'); + }); + + it('renames nested file', async () => { + mockAdapter.rename.mockResolvedValue(); + + await vaultAdapter.rename('folder/old.md', 'folder/new.md'); + + expect(mockAdapter.rename).toHaveBeenCalledWith('folder/old.md', 'folder/new.md'); + }); + + it('moves file across folders', async () => { + mockAdapter.rename.mockResolvedValue(); + + await vaultAdapter.rename('folder1/file.md', 'folder2/file.md'); + + expect(mockAdapter.rename).toHaveBeenCalledWith('folder1/file.md', 'folder2/file.md'); + }); + }); + + describe('stat', () => { + it('returns file stats for existing file', async () => { + mockAdapter.stat.mockResolvedValue({ mtime: 1234567890, size: 1024 }); + + const result = await vaultAdapter.stat('file.md'); + + expect(result).toEqual({ mtime: 1234567890, size: 1024 }); + expect(mockAdapter.stat).toHaveBeenCalledWith('file.md'); + }); + + it('returns null when stat returns null', async () => { + mockAdapter.stat.mockResolvedValue(null); + + const result = await vaultAdapter.stat('file.md'); + + expect(result).toBeNull(); + }); + + it('returns null on stat error', async () => { + mockAdapter.stat.mockRejectedValue(new Error('Stat error')); + + const result = await vaultAdapter.stat('file.md'); + + expect(result).toBeNull(); + }); + + it('handles nested file path', async () => { + mockAdapter.stat.mockResolvedValue({ mtime: 9876543210, size: 2048 }); + + const result = await vaultAdapter.stat('folder/subfolder/file.md'); + + expect(result).toEqual({ mtime: 9876543210, size: 2048 }); + }); + + it('handles zero-sized file', async () => { + mockAdapter.stat.mockResolvedValue({ mtime: 1234567890, size: 0 }); + + const result = await vaultAdapter.stat('empty.md'); + + expect(result).toEqual({ mtime: 1234567890, size: 0 }); + }); + }); +}); diff --git a/tests/unit/core/storage/pathContainment.test.ts b/tests/unit/core/storage/pathContainment.test.ts new file mode 100644 index 0000000..30fa8a4 --- /dev/null +++ b/tests/unit/core/storage/pathContainment.test.ts @@ -0,0 +1,35 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { isPathWithinRoot } from '@/core/storage/pathContainment'; + +describe('isPathWithinRoot', () => { + it('uses path segments instead of string prefixes', () => { + expect(isPathWithinRoot('/home/user/.codex/sessions/a.jsonl', '/home/user/.codex/sessions')).toBe(true); + expect(isPathWithinRoot('/home/user/.codex/sessions-evil/a.jsonl', '/home/user/.codex/sessions')).toBe(false); + expect(isPathWithinRoot('/home/user/.codex/sessions/../secrets/a.jsonl', '/home/user/.codex/sessions')).toBe(false); + }); + + it('supports Windows drive and UNC paths independently of the host platform', () => { + expect(isPathWithinRoot('C:\\Users\\me\\.codex\\sessions\\a.jsonl', 'C:\\Users\\me\\.codex\\sessions')).toBe(true); + expect(isPathWithinRoot('D:\\Users\\me\\.codex\\sessions\\a.jsonl', 'C:\\Users\\me\\.codex\\sessions')).toBe(false); + expect(isPathWithinRoot('\\\\wsl$\\Ubuntu\\home\\me\\.codex\\sessions\\a.jsonl', '\\\\wsl$\\Ubuntu\\home\\me\\.codex\\sessions')).toBe(true); + expect(isPathWithinRoot('\\\\wsl$\\Other\\home\\me\\.codex\\sessions\\a.jsonl', '\\\\wsl$\\Ubuntu\\home\\me\\.codex\\sessions')).toBe(false); + }); + + it('rejects an existing symlink that escapes the trusted root', () => { + if (process.platform === 'win32') return; + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claudian-path-trust-')); + const root = path.join(tempDir, 'root'); + const outside = path.join(tempDir, 'outside'); + fs.mkdirSync(root); + fs.mkdirSync(outside); + fs.writeFileSync(path.join(outside, 'session.jsonl'), '{}'); + fs.symlinkSync(outside, path.join(root, 'escape')); + + expect(isPathWithinRoot(path.join(root, 'escape', 'session.jsonl'), root)).toBe(false); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); +}); diff --git a/tests/unit/core/tools/todo.test.ts b/tests/unit/core/tools/todo.test.ts new file mode 100644 index 0000000..049d882 --- /dev/null +++ b/tests/unit/core/tools/todo.test.ts @@ -0,0 +1,227 @@ +import { extractLastTodosFromMessages, parseTodoInput } from '@/core/tools/todo'; +import { TOOL_TODO_WRITE } from '@/core/tools/toolNames'; + +describe('parseTodoInput', () => { + it('should parse valid todo items', () => { + const input = { + todos: [ + { content: 'Run tests', status: 'pending', activeForm: 'Running tests' }, + { content: 'Fix bug', status: 'in_progress', activeForm: 'Fixing bug' }, + { content: 'Deploy', status: 'completed', activeForm: 'Deploying' }, + ], + }; + + const result = parseTodoInput(input); + + expect(result).toHaveLength(3); + expect(result![0]).toEqual({ content: 'Run tests', status: 'pending', activeForm: 'Running tests' }); + expect(result![1].status).toBe('in_progress'); + expect(result![2].status).toBe('completed'); + }); + + it('should return null when todos key is missing', () => { + expect(parseTodoInput({})).toBeNull(); + }); + + it('should return null when todos is not an array', () => { + expect(parseTodoInput({ todos: 'not an array' })).toBeNull(); + expect(parseTodoInput({ todos: 42 })).toBeNull(); + expect(parseTodoInput({ todos: null })).toBeNull(); + }); + + it('should filter out invalid items', () => { + const input = { + todos: [ + { content: 'Valid', status: 'pending', activeForm: 'Working' }, + { content: '', status: 'pending', activeForm: 'Working' }, // empty content + { content: 'No status', activeForm: 'Working' }, // missing status + { content: 'Bad status', status: 'unknown', activeForm: 'Working' }, // invalid status + null, + 42, + 'string', + ], + }; + + const result = parseTodoInput(input); + + expect(result).toHaveLength(1); + expect(result![0].content).toBe('Valid'); + }); + + it('should return null when all items are invalid', () => { + const input = { + todos: [ + { content: '', status: 'pending', activeForm: 'Working' }, + null, + { status: 'pending' }, // missing content and activeForm + ], + }; + + expect(parseTodoInput(input)).toBeNull(); + }); + + it('should return null for empty todos array', () => { + expect(parseTodoInput({ todos: [] })).toBeNull(); + }); + + it('should reject items with missing activeForm', () => { + const input = { + todos: [ + { content: 'Task', status: 'pending' }, // no activeForm + ], + }; + + expect(parseTodoInput(input)).toBeNull(); + }); + + it('should reject items with empty activeForm', () => { + const input = { + todos: [ + { content: 'Task', status: 'pending', activeForm: '' }, + ], + }; + + expect(parseTodoInput(input)).toBeNull(); + }); + + it('should reject non-object items', () => { + const input = { + todos: [undefined, false, 0], + }; + + expect(parseTodoInput(input)).toBeNull(); + }); +}); + +describe('extractLastTodosFromMessages', () => { + it('should extract todos from the last TodoWrite tool call', () => { + const messages = [ + { + role: 'user', + content: 'Do something', + }, + { + role: 'assistant', + toolCalls: [ + { + name: TOOL_TODO_WRITE, + input: { + todos: [ + { content: 'First', status: 'completed' as const, activeForm: 'First-ing' }, + ], + }, + }, + ], + }, + { + role: 'assistant', + toolCalls: [ + { + name: TOOL_TODO_WRITE, + input: { + todos: [ + { content: 'Second', status: 'pending' as const, activeForm: 'Second-ing' }, + ], + }, + }, + ], + }, + ]; + + const result = extractLastTodosFromMessages(messages); + + expect(result).toHaveLength(1); + expect(result![0].content).toBe('Second'); + }); + + it('should return null when no messages exist', () => { + expect(extractLastTodosFromMessages([])).toBeNull(); + }); + + it('should return null when no assistant messages have tool calls', () => { + const messages = [ + { role: 'user' }, + { role: 'assistant' }, + ]; + + expect(extractLastTodosFromMessages(messages)).toBeNull(); + }); + + it('should return null when no TodoWrite tool calls exist', () => { + const messages = [ + { + role: 'assistant', + toolCalls: [ + { name: 'Read', input: { file_path: '/test.txt' } }, + ], + }, + ]; + + expect(extractLastTodosFromMessages(messages)).toBeNull(); + }); + + it('should skip user messages', () => { + const messages = [ + { + role: 'user', + toolCalls: [ + { + name: TOOL_TODO_WRITE, + input: { + todos: [{ content: 'Should not find', status: 'pending', activeForm: 'Nope' }], + }, + }, + ], + }, + ]; + + expect(extractLastTodosFromMessages(messages)).toBeNull(); + }); + + it('should pick the last TodoWrite within a message with multiple tool calls', () => { + const messages = [ + { + role: 'assistant', + toolCalls: [ + { + name: TOOL_TODO_WRITE, + input: { + todos: [{ content: 'Earlier', status: 'pending' as const, activeForm: 'Earlier-ing' }], + }, + }, + { + name: 'Read', + input: { file_path: '/test.txt' }, + }, + { + name: TOOL_TODO_WRITE, + input: { + todos: [{ content: 'Later', status: 'in_progress' as const, activeForm: 'Later-ing' }], + }, + }, + ], + }, + ]; + + const result = extractLastTodosFromMessages(messages); + + expect(result).toHaveLength(1); + expect(result![0].content).toBe('Later'); + }); + + it('should return null when TodoWrite has invalid input', () => { + const messages = [ + { + role: 'assistant', + toolCalls: [ + { + name: TOOL_TODO_WRITE, + input: { todos: 'not-an-array' }, + }, + ], + }, + ]; + + expect(extractLastTodosFromMessages(messages)).toBeNull(); + }); +}); diff --git a/tests/unit/core/tools/toolIcons.test.ts b/tests/unit/core/tools/toolIcons.test.ts new file mode 100644 index 0000000..5becb7e --- /dev/null +++ b/tests/unit/core/tools/toolIcons.test.ts @@ -0,0 +1,75 @@ +import { getToolIcon, MCP_ICON_MARKER } from '@/core/tools/toolIcons'; +import { + TOOL_AGENT_OUTPUT, + TOOL_ASK_USER_QUESTION, + TOOL_BASH, + TOOL_BASH_OUTPUT, + TOOL_EDIT, + TOOL_GLOB, + TOOL_GREP, + TOOL_KILL_SHELL, + TOOL_LIST_MCP_RESOURCES, + TOOL_LS, + TOOL_MCP, + TOOL_NOTEBOOK_EDIT, + TOOL_READ, + TOOL_READ_MCP_RESOURCE, + TOOL_SKILL, + TOOL_SUBAGENT_LEGACY, + TOOL_TASK, + TOOL_TODO_WRITE, + TOOL_WEB_FETCH, + TOOL_WEB_SEARCH, + TOOL_WRITE, +} from '@/core/tools/toolNames'; + +describe('MCP_ICON_MARKER', () => { + it('should be defined as a special marker string', () => { + expect(MCP_ICON_MARKER).toBe('__mcp_icon__'); + }); +}); + +describe('getToolIcon', () => { + it.each([ + [TOOL_READ, 'file-text'], + [TOOL_WRITE, 'file-plus'], + [TOOL_EDIT, 'file-pen'], + [TOOL_NOTEBOOK_EDIT, 'file-pen'], + [TOOL_BASH, 'terminal'], + [TOOL_BASH_OUTPUT, 'terminal'], + [TOOL_KILL_SHELL, 'terminal'], + [TOOL_GLOB, 'folder-search'], + [TOOL_GREP, 'search'], + [TOOL_LS, 'list'], + [TOOL_TODO_WRITE, 'list-checks'], + [TOOL_TASK, 'bot'], + [TOOL_SUBAGENT_LEGACY, 'bot'], + [TOOL_LIST_MCP_RESOURCES, 'list'], + [TOOL_READ_MCP_RESOURCE, 'file-text'], + [TOOL_MCP, 'wrench'], + [TOOL_WEB_SEARCH, 'globe'], + [TOOL_WEB_FETCH, 'download'], + [TOOL_AGENT_OUTPUT, 'bot'], + [TOOL_ASK_USER_QUESTION, 'help-circle'], + [TOOL_SKILL, 'zap'], + ])('should return "%s" icon for %s tool', (tool, expectedIcon) => { + expect(getToolIcon(tool)).toBe(expectedIcon); + }); + + it('should return MCP_ICON_MARKER for mcp__ prefixed tools', () => { + expect(getToolIcon('mcp__server__tool')).toBe(MCP_ICON_MARKER); + expect(getToolIcon('mcp__github__search')).toBe(MCP_ICON_MARKER); + expect(getToolIcon('mcp__')).toBe(MCP_ICON_MARKER); + }); + + it('should return fallback wrench icon for unknown tools', () => { + expect(getToolIcon('UnknownTool')).toBe('wrench'); + expect(getToolIcon('')).toBe('wrench'); + expect(getToolIcon('SomeCustomTool')).toBe('wrench'); + }); + + it('should not match partial mcp prefix', () => { + expect(getToolIcon('mcpTool')).toBe('wrench'); + expect(getToolIcon('mcp_single_underscore')).toBe('wrench'); + }); +}); diff --git a/tests/unit/core/tools/toolInput.test.ts b/tests/unit/core/tools/toolInput.test.ts new file mode 100644 index 0000000..6fb3e32 --- /dev/null +++ b/tests/unit/core/tools/toolInput.test.ts @@ -0,0 +1,350 @@ +import { + extractResolvedAnswers, + extractResolvedAnswersFromResultText, + getPathFromToolInput, +} from '@/core/tools/toolInput'; + +describe('extractResolvedAnswers', () => { + it('returns undefined when result is not an object', () => { + expect(extractResolvedAnswers('bad')).toBeUndefined(); + expect(extractResolvedAnswers(123)).toBeUndefined(); + expect(extractResolvedAnswers(undefined)).toBeUndefined(); + expect(extractResolvedAnswers(null)).toBeUndefined(); + }); + + it('returns undefined when answers is missing', () => { + expect(extractResolvedAnswers({})).toBeUndefined(); + }); + + it('returns undefined when answers is not an object', () => { + expect(extractResolvedAnswers({ answers: 'bad' })).toBeUndefined(); + expect(extractResolvedAnswers({ answers: null })).toBeUndefined(); + expect(extractResolvedAnswers({ answers: [] })).toBeUndefined(); + }); + + it('normalizes structured answers', () => { + const answers = { foo: 'bar', baz: 1, ok: true, choices: ['A', 'B'] }; + expect(extractResolvedAnswers({ answers })).toEqual({ + foo: 'bar', + baz: '1', + ok: 'true', + choices: ['A', 'B'], + }); + }); + + it('excludes empty-string answers', () => { + expect(extractResolvedAnswers({ answers: { q1: 'yes', q2: '' } })).toEqual({ q1: 'yes' }); + }); + + it('returns undefined when all answers are empty strings', () => { + expect(extractResolvedAnswers({ answers: { q1: '', q2: '' } })).toBeUndefined(); + }); +}); + +describe('extractResolvedAnswersFromResultText', () => { + it('returns undefined for non-string or empty values', () => { + expect(extractResolvedAnswersFromResultText(undefined)).toBeUndefined(); + expect(extractResolvedAnswersFromResultText(null)).toBeUndefined(); + expect(extractResolvedAnswersFromResultText(123)).toBeUndefined(); + expect(extractResolvedAnswersFromResultText(' ')).toBeUndefined(); + }); + + it('extracts answers from quoted key-value pairs', () => { + expect(extractResolvedAnswersFromResultText('"Color?"="Blue" "Size?"="M"')).toEqual({ + 'Color?': 'Blue', + 'Size?': 'M', + }); + }); + + it('extracts answers from JSON object text', () => { + expect(extractResolvedAnswersFromResultText('{"Color?":"Blue","Fast?":true}')).toEqual({ + 'Color?': 'Blue', + 'Fast?': 'true', + }); + }); + + it('extracts nested Codex answer objects from JSON result text', () => { + expect(extractResolvedAnswersFromResultText('{"answers":{"q1":{"answers":["yes"]}}}')).toEqual({ + q1: 'yes', + }); + }); + + it('preserves multi-answer arrays from JSON result text', () => { + expect(extractResolvedAnswersFromResultText('{"answers":{"q1":{"answers":["yes","later"]}}}')).toEqual({ + q1: ['yes', 'later'], + }); + }); + + it('extracts nested answer values from wrapped JSON text', () => { + expect(extractResolvedAnswersFromResultText('Result: {"answers":{"q1":{"value":"Blue"}}}')).toEqual({ + q1: 'Blue', + }); + }); + + it('returns undefined when text cannot be parsed', () => { + expect(extractResolvedAnswersFromResultText('No parsed answers here')).toBeUndefined(); + }); + + it('excludes empty-string values in JSON object text', () => { + expect(extractResolvedAnswersFromResultText('{"Color?":"Blue","Name?":""}')).toEqual({ + 'Color?': 'Blue', + }); + }); +}); + +describe('getPathFromToolInput', () => { + describe('Read tool', () => { + it('should extract file_path from Read tool input', () => { + const result = getPathFromToolInput('Read', { file_path: '/path/to/file.txt' }); + + expect(result).toBe('/path/to/file.txt'); + }); + + it('should return null when file_path is missing', () => { + const result = getPathFromToolInput('Read', {}); + + expect(result).toBeNull(); + }); + + it('should return null when file_path is empty', () => { + const result = getPathFromToolInput('Read', { file_path: '' }); + + expect(result).toBeNull(); + }); + + it('should fall back to notebook_path when file_path is empty', () => { + const result = getPathFromToolInput('Read', { file_path: '', notebook_path: '/path/to/notebook.ipynb' }); + + expect(result).toBe('/path/to/notebook.ipynb'); + }); + }); + + describe('Write tool', () => { + it('should extract file_path from Write tool input', () => { + const result = getPathFromToolInput('Write', { file_path: '/path/to/file.txt' }); + + expect(result).toBe('/path/to/file.txt'); + }); + + it('should return null when file_path is missing', () => { + const result = getPathFromToolInput('Write', { content: 'some content' }); + + expect(result).toBeNull(); + }); + + it('should fall back to notebook_path when file_path is missing', () => { + const result = getPathFromToolInput('Write', { notebook_path: '/path/to/notebook.ipynb' }); + + expect(result).toBe('/path/to/notebook.ipynb'); + }); + }); + + describe('Edit tool', () => { + it('should extract file_path from Edit tool input', () => { + const result = getPathFromToolInput('Edit', { + file_path: '/path/to/file.txt', + old_string: 'old', + new_string: 'new', + }); + + expect(result).toBe('/path/to/file.txt'); + }); + + it('should return null when file_path is missing', () => { + const result = getPathFromToolInput('Edit', { + old_string: 'old', + new_string: 'new', + }); + + expect(result).toBeNull(); + }); + + it('should fall back to notebook_path when file_path is missing', () => { + const result = getPathFromToolInput('Edit', { + notebook_path: '/path/to/notebook.ipynb', + old_string: 'old', + new_string: 'new', + }); + + expect(result).toBe('/path/to/notebook.ipynb'); + }); + }); + + describe('NotebookEdit tool', () => { + it('should extract file_path from NotebookEdit tool input', () => { + const result = getPathFromToolInput('NotebookEdit', { + file_path: '/path/to/notebook.ipynb', + }); + + expect(result).toBe('/path/to/notebook.ipynb'); + }); + + it('should extract notebook_path from NotebookEdit tool input', () => { + const result = getPathFromToolInput('NotebookEdit', { + notebook_path: '/path/to/notebook.ipynb', + }); + + expect(result).toBe('/path/to/notebook.ipynb'); + }); + + it('should prefer file_path over notebook_path', () => { + const result = getPathFromToolInput('NotebookEdit', { + file_path: '/path/via/file_path.ipynb', + notebook_path: '/path/via/notebook_path.ipynb', + }); + + expect(result).toBe('/path/via/file_path.ipynb'); + }); + + it('should return null when both paths are missing', () => { + const result = getPathFromToolInput('NotebookEdit', { cell_number: 1 }); + + expect(result).toBeNull(); + }); + }); + + describe('Glob tool', () => { + it('should extract path from Glob tool input', () => { + const result = getPathFromToolInput('Glob', { path: '/search/path' }); + + expect(result).toBe('/search/path'); + }); + + it('should extract pattern as fallback from Glob tool input', () => { + const result = getPathFromToolInput('Glob', { pattern: '**/*.ts' }); + + expect(result).toBe('**/*.ts'); + }); + + it('should fall back to pattern when path is empty', () => { + const result = getPathFromToolInput('Glob', { path: '', pattern: '**/*.ts' }); + + expect(result).toBe('**/*.ts'); + }); + + it('should prefer path over pattern', () => { + const result = getPathFromToolInput('Glob', { + path: '/explicit/path', + pattern: '**/*.ts', + }); + + expect(result).toBe('/explicit/path'); + }); + + it('should return null when both path and pattern are missing', () => { + const result = getPathFromToolInput('Glob', {}); + + expect(result).toBeNull(); + }); + }); + + describe('Grep tool', () => { + it('should extract path from Grep tool input', () => { + const result = getPathFromToolInput('Grep', { + path: '/search/path', + pattern: 'search-regex', + }); + + expect(result).toBe('/search/path'); + }); + + it('should return null when path is missing', () => { + const result = getPathFromToolInput('Grep', { pattern: 'search-regex' }); + + expect(result).toBeNull(); + }); + + it('should not use pattern as path fallback', () => { + // Unlike Glob, Grep's pattern is the search regex, not a path + const result = getPathFromToolInput('Grep', { pattern: 'search-regex' }); + + expect(result).toBeNull(); + }); + }); + + describe('LS tool', () => { + it('should extract path from LS tool input', () => { + const result = getPathFromToolInput('LS', { path: '/list/path' }); + + expect(result).toBe('/list/path'); + }); + + it('should return null when path is missing', () => { + const result = getPathFromToolInput('LS', {}); + + expect(result).toBeNull(); + }); + }); + + describe('unsupported tools', () => { + it('should return null for Bash tool', () => { + const result = getPathFromToolInput('Bash', { command: 'ls -la' }); + + expect(result).toBeNull(); + }); + + it('should return null for Task tool', () => { + const result = getPathFromToolInput('Task', { prompt: 'do something' }); + + expect(result).toBeNull(); + }); + + it('should return null for WebSearch tool', () => { + const result = getPathFromToolInput('WebSearch', { query: 'search term' }); + + expect(result).toBeNull(); + }); + + it('should return null for WebFetch tool', () => { + const result = getPathFromToolInput('WebFetch', { url: 'https://example.com' }); + + expect(result).toBeNull(); + }); + + it('should return null for unknown tool', () => { + const result = getPathFromToolInput('UnknownTool', { file_path: '/path' }); + + expect(result).toBeNull(); + }); + + it('should return null for empty tool name', () => { + const result = getPathFromToolInput('', { file_path: '/path' }); + + expect(result).toBeNull(); + }); + }); + + describe('edge cases', () => { + it('should handle paths with spaces', () => { + const result = getPathFromToolInput('Read', { + file_path: '/path/with spaces/file.txt', + }); + + expect(result).toBe('/path/with spaces/file.txt'); + }); + + it('should handle Windows-style paths', () => { + const result = getPathFromToolInput('Write', { + file_path: 'C:\\Users\\test\\file.txt', + }); + + expect(result).toBe('C:\\Users\\test\\file.txt'); + }); + + it('should handle relative paths', () => { + const result = getPathFromToolInput('Edit', { + file_path: './relative/path.txt', + }); + + expect(result).toBe('./relative/path.txt'); + }); + + it('should handle paths starting with tilde', () => { + const result = getPathFromToolInput('Read', { + file_path: '~/Documents/file.txt', + }); + + expect(result).toBe('~/Documents/file.txt'); + }); + }); +}); diff --git a/tests/unit/core/tools/toolNames.test.ts b/tests/unit/core/tools/toolNames.test.ts new file mode 100644 index 0000000..6d6a820 --- /dev/null +++ b/tests/unit/core/tools/toolNames.test.ts @@ -0,0 +1,464 @@ +import { + // Tool arrays + AGENT_LIFECYCLE_TOOLS, + BASH_TOOLS, + EDIT_TOOLS, + FILE_TOOLS, + isAgentLifecycleTool, + isBashTool, + // Type guards + isEditTool, + isFileTool, + isMcpTool, + isReadOnlyTool, + isSubagentToolName, + isWriteEditTool, + MCP_TOOLS, + READ_ONLY_TOOLS, + skipsBlockedDetection, + SUBAGENT_TOOL_NAMES, + // Constants + TOOL_AGENT_OUTPUT, + TOOL_ASK_USER_QUESTION, + TOOL_BASH, + TOOL_BASH_OUTPUT, + TOOL_CLOSE_AGENT, + TOOL_EDIT, + TOOL_ENTER_PLAN_MODE, + TOOL_EXIT_PLAN_MODE, + TOOL_GLOB, + TOOL_GREP, + TOOL_KILL_SHELL, + TOOL_LIST_MCP_RESOURCES, + TOOL_LS, + TOOL_MCP, + TOOL_NOTEBOOK_EDIT, + TOOL_READ, + TOOL_READ_MCP_RESOURCE, + TOOL_RESUME_AGENT, + TOOL_SEND_INPUT, + TOOL_SKILL, + TOOL_SPAWN_AGENT, + TOOL_SUBAGENT_LEGACY, + TOOL_TASK, + TOOL_TODO_WRITE, + TOOL_TOOL_SEARCH, + TOOL_WAIT, + TOOL_WAIT_AGENT, + TOOL_WEB_FETCH, + TOOL_WEB_SEARCH, + TOOL_WRITE, + TOOLS_SKIP_BLOCKED_DETECTION, + WRITE_EDIT_TOOLS, +} from '@/core/tools/toolNames'; + +describe('Tool Constants', () => { + it('should export all tool name constants', () => { + expect(TOOL_AGENT_OUTPUT).toBe('TaskOutput'); + expect(TOOL_BASH).toBe('Bash'); + expect(TOOL_BASH_OUTPUT).toBe('BashOutput'); + expect(TOOL_EDIT).toBe('Edit'); + expect(TOOL_GLOB).toBe('Glob'); + expect(TOOL_GREP).toBe('Grep'); + expect(TOOL_KILL_SHELL).toBe('KillShell'); + expect(TOOL_LS).toBe('LS'); + expect(TOOL_LIST_MCP_RESOURCES).toBe('ListMcpResources'); + expect(TOOL_MCP).toBe('Mcp'); + expect(TOOL_NOTEBOOK_EDIT).toBe('NotebookEdit'); + expect(TOOL_READ).toBe('Read'); + expect(TOOL_READ_MCP_RESOURCE).toBe('ReadMcpResource'); + expect(TOOL_SKILL).toBe('Skill'); + expect(TOOL_TASK).toBe('Agent'); + expect(TOOL_SUBAGENT_LEGACY).toBe('Task'); + expect(TOOL_TODO_WRITE).toBe('TodoWrite'); + expect(TOOL_WEB_FETCH).toBe('WebFetch'); + expect(TOOL_WEB_SEARCH).toBe('WebSearch'); + expect(TOOL_TOOL_SEARCH).toBe('ToolSearch'); + expect(TOOL_WRITE).toBe('Write'); + }); +}); + +describe('SUBAGENT_TOOL_NAMES', () => { + it('should include both canonical and legacy subagent tool names', () => { + expect(SUBAGENT_TOOL_NAMES).toEqual(['Agent', 'Task']); + }); +}); + +describe('AGENT_LIFECYCLE_TOOLS', () => { + it('should expose provider-neutral lifecycle names', () => { + expect(AGENT_LIFECYCLE_TOOLS).toEqual([ + TOOL_SPAWN_AGENT, + TOOL_SEND_INPUT, + TOOL_WAIT, + TOOL_WAIT_AGENT, + TOOL_RESUME_AGENT, + TOOL_CLOSE_AGENT, + ]); + }); +}); + +describe('isAgentLifecycleTool', () => { + it('should return true for runtime lifecycle tools only', () => { + expect(isAgentLifecycleTool(TOOL_SPAWN_AGENT)).toBe(true); + expect(isAgentLifecycleTool(TOOL_SEND_INPUT)).toBe(true); + expect(isAgentLifecycleTool(TOOL_WAIT)).toBe(true); + expect(isAgentLifecycleTool(TOOL_WAIT_AGENT)).toBe(true); + expect(isAgentLifecycleTool(TOOL_RESUME_AGENT)).toBe(true); + expect(isAgentLifecycleTool(TOOL_CLOSE_AGENT)).toBe(true); + expect(isAgentLifecycleTool(TOOL_BASH)).toBe(false); + expect(isAgentLifecycleTool(TOOL_TASK)).toBe(false); + }); +}); + +describe('isSubagentToolName', () => { + it('should return true for Agent', () => { + expect(isSubagentToolName('Agent')).toBe(true); + }); + + it('should return true for legacy Task', () => { + expect(isSubagentToolName('Task')).toBe(true); + }); + + it('should return false for non-subagent tools', () => { + expect(isSubagentToolName('TaskOutput')).toBe(false); + expect(isSubagentToolName('TodoWrite')).toBe(false); + }); +}); + +describe('Tool Arrays', () => { + describe('EDIT_TOOLS', () => { + it('should contain Write, Edit, and NotebookEdit', () => { + expect(EDIT_TOOLS).toContain('Write'); + expect(EDIT_TOOLS).toContain('Edit'); + expect(EDIT_TOOLS).toContain('NotebookEdit'); + expect(EDIT_TOOLS).toHaveLength(3); + }); + }); + + describe('WRITE_EDIT_TOOLS', () => { + it('should contain Write and Edit only', () => { + expect(WRITE_EDIT_TOOLS).toContain('Write'); + expect(WRITE_EDIT_TOOLS).toContain('Edit'); + expect(WRITE_EDIT_TOOLS).toHaveLength(2); + }); + }); + + describe('BASH_TOOLS', () => { + it('should contain Bash, BashOutput, and KillShell', () => { + expect(BASH_TOOLS).toContain('Bash'); + expect(BASH_TOOLS).toContain('BashOutput'); + expect(BASH_TOOLS).toContain('KillShell'); + expect(BASH_TOOLS).toHaveLength(3); + }); + }); + + describe('FILE_TOOLS', () => { + it('should contain all file-related tools', () => { + expect(FILE_TOOLS).toContain('Read'); + expect(FILE_TOOLS).toContain('Write'); + expect(FILE_TOOLS).toContain('Edit'); + expect(FILE_TOOLS).toContain('Glob'); + expect(FILE_TOOLS).toContain('Grep'); + expect(FILE_TOOLS).toContain('LS'); + expect(FILE_TOOLS).toContain('NotebookEdit'); + expect(FILE_TOOLS).toContain('Bash'); + expect(FILE_TOOLS).toHaveLength(8); + }); + }); + + describe('MCP_TOOLS', () => { + it('should contain all MCP-related tools', () => { + expect(MCP_TOOLS).toContain('ListMcpResources'); + expect(MCP_TOOLS).toContain('ReadMcpResource'); + expect(MCP_TOOLS).toContain('Mcp'); + expect(MCP_TOOLS).toHaveLength(3); + }); + }); + + describe('READ_ONLY_TOOLS', () => { + it('should contain all read-only tools', () => { + expect(READ_ONLY_TOOLS).toContain('Read'); + expect(READ_ONLY_TOOLS).toContain('Grep'); + expect(READ_ONLY_TOOLS).toContain('Glob'); + expect(READ_ONLY_TOOLS).toContain('LS'); + expect(READ_ONLY_TOOLS).toContain('WebSearch'); + expect(READ_ONLY_TOOLS).toContain('WebFetch'); + expect(READ_ONLY_TOOLS).toHaveLength(6); + }); + + it('should not contain write tools', () => { + expect(READ_ONLY_TOOLS).not.toContain('Write'); + expect(READ_ONLY_TOOLS).not.toContain('Edit'); + expect(READ_ONLY_TOOLS).not.toContain('Bash'); + }); + }); +}); + +describe('isEditTool', () => { + it('should return true for Edit tool', () => { + expect(isEditTool('Edit')).toBe(true); + }); + + it('should return true for Write tool', () => { + expect(isEditTool('Write')).toBe(true); + }); + + it('should return true for NotebookEdit tool', () => { + expect(isEditTool('NotebookEdit')).toBe(true); + }); + + it('should return false for Read tool', () => { + expect(isEditTool('Read')).toBe(false); + }); + + it('should return false for Bash tool', () => { + expect(isEditTool('Bash')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isEditTool('')).toBe(false); + }); + + it('should return false for unknown tool', () => { + expect(isEditTool('UnknownTool')).toBe(false); + }); + + it('should be case-sensitive', () => { + expect(isEditTool('edit')).toBe(false); + expect(isEditTool('EDIT')).toBe(false); + }); +}); + +describe('isWriteEditTool', () => { + it('should return true for Write tool', () => { + expect(isWriteEditTool('Write')).toBe(true); + }); + + it('should return true for Edit tool', () => { + expect(isWriteEditTool('Edit')).toBe(true); + }); + + it('should return false for NotebookEdit tool', () => { + expect(isWriteEditTool('NotebookEdit')).toBe(false); + }); + + it('should return false for Read tool', () => { + expect(isWriteEditTool('Read')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isWriteEditTool('')).toBe(false); + }); + + it('should return false for unknown tool', () => { + expect(isWriteEditTool('UnknownTool')).toBe(false); + }); +}); + +describe('isFileTool', () => { + it('should return true for Read tool', () => { + expect(isFileTool('Read')).toBe(true); + }); + + it('should return true for Write tool', () => { + expect(isFileTool('Write')).toBe(true); + }); + + it('should return true for Edit tool', () => { + expect(isFileTool('Edit')).toBe(true); + }); + + it('should return true for Glob tool', () => { + expect(isFileTool('Glob')).toBe(true); + }); + + it('should return true for Grep tool', () => { + expect(isFileTool('Grep')).toBe(true); + }); + + it('should return true for LS tool', () => { + expect(isFileTool('LS')).toBe(true); + }); + + it('should return true for NotebookEdit tool', () => { + expect(isFileTool('NotebookEdit')).toBe(true); + }); + + it('should return true for Bash tool', () => { + expect(isFileTool('Bash')).toBe(true); + }); + + it('should return false for WebSearch tool', () => { + expect(isFileTool('WebSearch')).toBe(false); + }); + + it('should return false for Task tool', () => { + expect(isFileTool('Task')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isFileTool('')).toBe(false); + }); + + it('should return false for unknown tool', () => { + expect(isFileTool('UnknownTool')).toBe(false); + }); +}); + +describe('isBashTool', () => { + it('should return true for Bash tool', () => { + expect(isBashTool('Bash')).toBe(true); + }); + + it('should return true for BashOutput tool', () => { + expect(isBashTool('BashOutput')).toBe(true); + }); + + it('should return true for KillShell tool', () => { + expect(isBashTool('KillShell')).toBe(true); + }); + + it('should return false for Read tool', () => { + expect(isBashTool('Read')).toBe(false); + }); + + it('should return false for Task tool', () => { + expect(isBashTool('Task')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isBashTool('')).toBe(false); + }); + + it('should return false for unknown tool', () => { + expect(isBashTool('UnknownTool')).toBe(false); + }); + + it('should be case-sensitive', () => { + expect(isBashTool('bash')).toBe(false); + expect(isBashTool('BASH')).toBe(false); + }); +}); + +describe('isMcpTool', () => { + it('should return true for ListMcpResources tool', () => { + expect(isMcpTool('ListMcpResources')).toBe(true); + }); + + it('should return true for ReadMcpResource tool', () => { + expect(isMcpTool('ReadMcpResource')).toBe(true); + }); + + it('should return true for Mcp tool', () => { + expect(isMcpTool('Mcp')).toBe(true); + }); + + it('should return false for Read tool', () => { + expect(isMcpTool('Read')).toBe(false); + }); + + it('should return false for Bash tool', () => { + expect(isMcpTool('Bash')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isMcpTool('')).toBe(false); + }); + + it('should return false for unknown tool', () => { + expect(isMcpTool('UnknownTool')).toBe(false); + }); + + it('should return false for mcp-prefixed tool name (not in MCP_TOOLS)', () => { + // MCP tools invoked via SDK have mcp__ prefix but are not in MCP_TOOLS + expect(isMcpTool('mcp__server__tool')).toBe(false); + }); +}); + +describe('isReadOnlyTool', () => { + it('should return true for Read tool', () => { + expect(isReadOnlyTool('Read')).toBe(true); + }); + + it('should return true for Grep tool', () => { + expect(isReadOnlyTool('Grep')).toBe(true); + }); + + it('should return true for Glob tool', () => { + expect(isReadOnlyTool('Glob')).toBe(true); + }); + + it('should return true for LS tool', () => { + expect(isReadOnlyTool('LS')).toBe(true); + }); + + it('should return true for WebSearch tool', () => { + expect(isReadOnlyTool('WebSearch')).toBe(true); + }); + + it('should return true for WebFetch tool', () => { + expect(isReadOnlyTool('WebFetch')).toBe(true); + }); + + it('should return false for Write tool', () => { + expect(isReadOnlyTool('Write')).toBe(false); + }); + + it('should return false for Edit tool', () => { + expect(isReadOnlyTool('Edit')).toBe(false); + }); + + it('should return false for Bash tool', () => { + expect(isReadOnlyTool('Bash')).toBe(false); + }); + + it('should return false for Task tool', () => { + expect(isReadOnlyTool('Task')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isReadOnlyTool('')).toBe(false); + }); + + it('should return false for unknown tool', () => { + expect(isReadOnlyTool('UnknownTool')).toBe(false); + }); +}); + +describe('TOOLS_SKIP_BLOCKED_DETECTION', () => { + it('should contain EnterPlanMode, ExitPlanMode, and AskUserQuestion', () => { + expect(TOOLS_SKIP_BLOCKED_DETECTION).toContain(TOOL_ENTER_PLAN_MODE); + expect(TOOLS_SKIP_BLOCKED_DETECTION).toContain(TOOL_EXIT_PLAN_MODE); + expect(TOOLS_SKIP_BLOCKED_DETECTION).toContain(TOOL_ASK_USER_QUESTION); + expect(TOOLS_SKIP_BLOCKED_DETECTION).toHaveLength(3); + }); +}); + +describe('skipsBlockedDetection', () => { + it('should return true for EnterPlanMode', () => { + expect(skipsBlockedDetection('EnterPlanMode')).toBe(true); + }); + + it('should return true for ExitPlanMode', () => { + expect(skipsBlockedDetection('ExitPlanMode')).toBe(true); + }); + + it('should return true for AskUserQuestion', () => { + expect(skipsBlockedDetection('AskUserQuestion')).toBe(true); + }); + + it('should return false for regular tools', () => { + expect(skipsBlockedDetection('Read')).toBe(false); + expect(skipsBlockedDetection('Bash')).toBe(false); + expect(skipsBlockedDetection('Write')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(skipsBlockedDetection('')).toBe(false); + }); + + it('should be case-sensitive', () => { + expect(skipsBlockedDetection('enterplanmode')).toBe(false); + expect(skipsBlockedDetection('EXITPLANMODE')).toBe(false); + }); +}); diff --git a/tests/unit/core/types/mcp.test.ts b/tests/unit/core/types/mcp.test.ts new file mode 100644 index 0000000..05b8a12 --- /dev/null +++ b/tests/unit/core/types/mcp.test.ts @@ -0,0 +1,115 @@ +import { + DEFAULT_MCP_SERVER, + getMcpServerType, + isValidMcpServerConfig, + type McpServerConfig, +} from '@/core/types/mcp'; + +describe('getMcpServerType', () => { + it('should return "sse" for SSE config', () => { + const config: McpServerConfig = { type: 'sse', url: 'https://example.com/sse' }; + + expect(getMcpServerType(config)).toBe('sse'); + }); + + it('should return "http" for HTTP config', () => { + const config: McpServerConfig = { type: 'http', url: 'https://example.com/api' }; + + expect(getMcpServerType(config)).toBe('http'); + }); + + it('should return "http" for URL config without explicit type', () => { + // URL-based config without type field defaults to http + const config = { url: 'https://example.com/api' } as McpServerConfig; + + expect(getMcpServerType(config)).toBe('http'); + }); + + it('should return "stdio" for command-based config', () => { + const config: McpServerConfig = { command: 'node server.js' }; + + expect(getMcpServerType(config)).toBe('stdio'); + }); + + it('should return "stdio" for command-based config with explicit type', () => { + const config: McpServerConfig = { type: 'stdio', command: 'node server.js' }; + + expect(getMcpServerType(config)).toBe('stdio'); + }); + + it('should return "stdio" for config with args and env', () => { + const config: McpServerConfig = { + command: 'node', + args: ['server.js'], + env: { PORT: '3000' }, + }; + + expect(getMcpServerType(config)).toBe('stdio'); + }); +}); + +describe('isValidMcpServerConfig', () => { + it('should return true for stdio config with command', () => { + expect(isValidMcpServerConfig({ command: 'node server.js' })).toBe(true); + }); + + it('should return true for url-based config', () => { + expect(isValidMcpServerConfig({ url: 'https://example.com' })).toBe(true); + }); + + it('should return true for SSE config', () => { + expect(isValidMcpServerConfig({ type: 'sse', url: 'https://example.com/sse' })).toBe(true); + }); + + it('should return true for HTTP config', () => { + expect(isValidMcpServerConfig({ type: 'http', url: 'https://example.com/api' })).toBe(true); + }); + + it('should return false for null', () => { + expect(isValidMcpServerConfig(null)).toBe(false); + }); + + it('should return false for undefined', () => { + expect(isValidMcpServerConfig(undefined)).toBe(false); + }); + + it('should return false for non-object', () => { + expect(isValidMcpServerConfig('string')).toBe(false); + expect(isValidMcpServerConfig(42)).toBe(false); + expect(isValidMcpServerConfig(true)).toBe(false); + }); + + it('should return false for empty object', () => { + expect(isValidMcpServerConfig({})).toBe(false); + }); + + it('should return false for non-string command', () => { + expect(isValidMcpServerConfig({ command: 42 })).toBe(false); + expect(isValidMcpServerConfig({ command: null })).toBe(false); + expect(isValidMcpServerConfig({ command: true })).toBe(false); + }); + + it('should return false for non-string url', () => { + expect(isValidMcpServerConfig({ url: 42 })).toBe(false); + expect(isValidMcpServerConfig({ url: null })).toBe(false); + expect(isValidMcpServerConfig({ url: true })).toBe(false); + }); + + it('should return false for empty command string', () => { + expect(isValidMcpServerConfig({ command: '' })).toBe(false); + }); + + it('should return false for empty url string', () => { + expect(isValidMcpServerConfig({ url: '' })).toBe(false); + }); +}); + +describe('DEFAULT_MCP_SERVER', () => { + it('should have enabled set to true', () => { + expect(DEFAULT_MCP_SERVER.enabled).toBe(true); + }); + + it('should have contextSaving set to true', () => { + expect(DEFAULT_MCP_SERVER.contextSaving).toBe(true); + }); +}); diff --git a/tests/unit/features/chat/ClaudianView.test.ts b/tests/unit/features/chat/ClaudianView.test.ts new file mode 100644 index 0000000..55036a4 --- /dev/null +++ b/tests/unit/features/chat/ClaudianView.test.ts @@ -0,0 +1,460 @@ +import { createMockEl } from '@test/helpers/mockElement'; +import { Platform, Scope } from 'obsidian'; + +import { ClaudianView } from '@/features/chat/ClaudianView'; + +const MockScope = Scope as typeof Scope & { instances: Scope[] }; + +function createViewHarness(options: { + canCreateTab: boolean; + tabCount?: number; +}): { + newTabButtonEl: ReturnType; + view: any; +} { + const newTabButtonEl = createMockEl(); + const view = Object.create(ClaudianView.prototype) as any; + + view.plugin = { + settings: {}, + }; + view.tabManager = { + canCreateTab: jest.fn().mockReturnValue(options.canCreateTab), + getTabCount: jest.fn().mockReturnValue(options.tabCount ?? 1), + }; + view.tabBarContainerEl = createMockEl(); + view.logoEl = createMockEl(); + view.newTabButtonEl = newTabButtonEl; + + return { newTabButtonEl, view }; +} + +describe('ClaudianView tab controls', () => { + it('hides the new-tab button when the tab manager is at capacity', () => { + const { newTabButtonEl, view } = createViewHarness({ canCreateTab: false }); + + view.refreshTabControls(); + + expect(newTabButtonEl.hasClass('claudian-hidden')).toBe(true); + expect(newTabButtonEl.getAttribute('aria-disabled')).toBe('true'); + expect(newTabButtonEl.getAttribute('aria-hidden')).toBe('true'); + }); + + it('shows the new-tab button when another tab can be created', () => { + const { newTabButtonEl, view } = createViewHarness({ canCreateTab: true }); + newTabButtonEl.addClass('claudian-hidden'); + newTabButtonEl.setAttribute('aria-disabled', 'true'); + newTabButtonEl.setAttribute('aria-hidden', 'true'); + + view.refreshTabControls(); + + expect(newTabButtonEl.hasClass('claudian-hidden')).toBe(false); + expect(newTabButtonEl.getAttribute('aria-disabled')).toBeNull(); + expect(newTabButtonEl.getAttribute('aria-hidden')).toBeNull(); + }); + + it('keeps tab controls in the view-owned input row', () => { + const navRowContent = createMockEl(); + const inputNavRowHostEl = createMockEl(); + const view = Object.create(ClaudianView.prototype) as any; + + view.containerEl = createMockEl(); + view.navRowContent = navRowContent; + view.inputNavRowHostEl = inputNavRowHostEl; + view.tabBar = { + captureScrollPosition: jest.fn(), + restoreScrollPosition: jest.fn(), + }; + + view.attachNavRowContentToInputFooter(); + + expect(inputNavRowHostEl.children).toContain(navRowContent); + expect(view.tabBar.captureScrollPosition).toHaveBeenCalledTimes(1); + expect(view.tabBar.restoreScrollPosition).toHaveBeenCalledTimes(1); + }); + + it('moves only the active tab input into the stable input slot', () => { + const activeInputSlotEl = createMockEl(); + const tab1 = { + id: 'tab-1', + dom: { + contentEl: createMockEl(), + inputComposerEl: createMockEl(), + inputContainerEl: createMockEl(), + }, + }; + const tab2 = { + id: 'tab-2', + dom: { + contentEl: createMockEl(), + inputComposerEl: createMockEl(), + inputContainerEl: createMockEl(), + }, + }; + const view = Object.create(ClaudianView.prototype) as any; + + view.activeInputSlotEl = activeInputSlotEl; + view.tabManager = { + getActiveTab: jest.fn() + .mockReturnValueOnce(tab1) + .mockReturnValueOnce(tab2), + getTab: jest.fn((id: string) => id === 'tab-1' ? tab1 : tab2), + }; + + view.updateInputLocation(); + view.updateInputLocation(); + + expect(activeInputSlotEl.children).toContain(tab2.dom.inputComposerEl); + expect(activeInputSlotEl.children).not.toContain(tab1.dom.inputComposerEl); + expect(tab1.dom.contentEl.children).toContain(tab1.dom.inputComposerEl); + }); + + it('preserves active pending prompt siblings during same-tab input updates', () => { + const activeInputSlotEl = createMockEl(); + const inputComposerEl = activeInputSlotEl.createDiv(); + const pendingPromptEl = inputComposerEl.createDiv({ cls: 'claudian-ask-question-inline' }); + const tab = { + id: 'tab-1', + dom: { + contentEl: createMockEl(), + inputComposerEl, + inputContainerEl: inputComposerEl.createDiv({ cls: 'claudian-input-container' }), + }, + }; + const view = Object.create(ClaudianView.prototype) as any; + + Object.defineProperty(inputComposerEl, 'parentElement', { + configurable: true, + get: () => activeInputSlotEl, + }); + view.activeInputTabId = 'tab-1'; + view.activeInputSlotEl = activeInputSlotEl; + view.tabManager = { + getActiveTab: jest.fn().mockReturnValue(tab), + getTab: jest.fn().mockReturnValue(tab), + }; + + view.updateInputLocation(); + + expect(activeInputSlotEl.children).toContain(inputComposerEl); + expect(inputComposerEl.children).toContain(pendingPromptEl); + }); + + it('clears the stable input slot when no tab is active', () => { + const activeInputSlotEl = createMockEl(); + const staleInputEl = activeInputSlotEl.createDiv(); + const view = Object.create(ClaudianView.prototype) as any; + + view.activeInputTabId = 'tab-1'; + view.activeInputSlotEl = activeInputSlotEl; + view.tabManager = { + getActiveTab: jest.fn().mockReturnValue(null), + }; + + view.updateInputLocation(); + + expect(activeInputSlotEl.children).not.toContain(staleInputEl); + expect(view.activeInputTabId).toBeNull(); + }); + + it('toggles the history dropdown when the history button is clicked', () => { + const historyDropdown = createMockEl(); + const view = Object.create(ClaudianView.prototype) as any; + + view.historyDropdown = historyDropdown; + view.tabManager = { + getActiveTab: jest.fn().mockReturnValue(null), + }; + + view.toggleHistoryDropdown(); + + expect(historyDropdown.hasClass('visible')).toBe(true); + + view.toggleHistoryDropdown(); + + expect(historyDropdown.hasClass('visible')).toBe(false); + }); + + it('persists expanded title tab ids with the tab layout snapshot', () => { + const view = Object.create(ClaudianView.prototype) as any; + + view.tabManager = { + getPersistedState: jest.fn().mockReturnValue({ + openTabs: [ + { tabId: 'tab-1', conversationId: null }, + { tabId: 'tab-2', conversationId: 'conv-2' }, + ], + activeTabId: 'tab-2', + }), + }; + view.tabBar = { + getExpandedTitleTabIds: jest.fn().mockReturnValue(['tab-2', 'closed-tab']), + }; + + expect(view.getPersistedTabState()).toEqual({ + openTabs: [ + { tabId: 'tab-1', conversationId: null }, + { tabId: 'tab-2', conversationId: 'conv-2' }, + ], + activeTabId: 'tab-2', + expandedTitleTabIds: ['tab-2'], + }); + }); + + it('restores expanded title tab ids after restoring tabs', async () => { + const persistedState = { + openTabs: [{ tabId: 'tab-1', conversationId: null }], + activeTabId: 'tab-1', + expandedTitleTabIds: ['tab-1'], + }; + const view = Object.create(ClaudianView.prototype) as any; + + view.plugin = { + storage: { + getTabManagerState: jest.fn().mockResolvedValue(persistedState), + }, + }; + view.tabManager = { + restoreState: jest.fn().mockResolvedValue(undefined), + createTab: jest.fn(), + }; + view.tabBar = { + setExpandedTitleTabIds: jest.fn(), + }; + view.updateTabBar = jest.fn(); + + await view.restoreOrCreateTabs(); + + expect(view.tabManager.restoreState).toHaveBeenCalledWith(persistedState); + expect(view.tabBar.setExpandedTitleTabIds).toHaveBeenCalledWith(['tab-1']); + expect(view.updateTabBar).toHaveBeenCalledTimes(1); + expect(view.tabManager.createTab).not.toHaveBeenCalled(); + }); +}); + +describe('ClaudianView Escape handling', () => { + beforeEach(() => { + MockScope.instances.length = 0; + }); + + function createEscapeHarness(options: { + isStreaming: boolean; + }): { + cancelStreaming: jest.Mock; + eventRefs: unknown[]; + view: any; + } { + const cancelStreaming = jest.fn(); + const eventRefs: unknown[] = []; + const parentScope = new Scope(); + const view = Object.create(ClaudianView.prototype) as any; + + view.app = { scope: parentScope }; + view.containerEl = createMockEl(); + view.historyDropdown = createMockEl(); + view.registerDomEvent = jest.fn(); + view.registerEvent = jest.fn(); + view.eventRefs = eventRefs; + view.plugin = { + app: { + vault: { + on: jest.fn((_event: string, handler: unknown) => { + const ref = { handler }; + eventRefs.push(ref); + return ref; + }), + }, + workspace: { + on: jest.fn((_event: string, handler: unknown) => { + const ref = { handler }; + eventRefs.push(ref); + return ref; + }), + }, + }, + }; + view.tabManager = { + getActiveTab: jest.fn().mockReturnValue({ + state: { isStreaming: options.isStreaming }, + controllers: { + inputController: { cancelStreaming }, + }, + ui: { + fileContextManager: { + markFileCacheDirty: jest.fn(), + markFolderCacheDirty: jest.fn(), + handleFileOpen: jest.fn(), + handleClickOutside: jest.fn(), + }, + }, + }), + }; + + return { cancelStreaming, eventRefs, view }; + } + + function createScopedSendHarness(options: { + inputFocused: boolean; + }): { + inputEl: HTMLTextAreaElement; + sendMessage: jest.Mock; + view: any; + } { + const sendMessage = jest.fn(); + const inputEl = createMockEl('textarea') as unknown as HTMLTextAreaElement; + Object.defineProperty(inputEl.ownerDocument, 'activeElement', { + configurable: true, + get: () => options.inputFocused ? inputEl : null, + }); + const eventRefs: unknown[] = []; + const parentScope = new Scope(); + const view = Object.create(ClaudianView.prototype) as any; + + view.app = { scope: parentScope }; + view.containerEl = createMockEl(); + view.historyDropdown = createMockEl(); + view.registerDomEvent = jest.fn(); + view.registerEvent = jest.fn(); + view.eventRefs = eventRefs; + view.plugin = { + app: { + vault: { + on: jest.fn((_event: string, handler: unknown) => { + const ref = { handler }; + eventRefs.push(ref); + return ref; + }), + }, + workspace: { + on: jest.fn((_event: string, handler: unknown) => { + const ref = { handler }; + eventRefs.push(ref); + return ref; + }), + }, + }, + }; + view.tabManager = { + getActiveTab: jest.fn().mockReturnValue({ + state: { isStreaming: false }, + dom: { inputEl }, + controllers: { + inputController: { sendMessage }, + }, + ui: { + fileContextManager: { + markFileCacheDirty: jest.fn(), + markFolderCacheDirty: jest.fn(), + handleFileOpen: jest.fn(), + handleClickOutside: jest.fn(), + }, + }, + }), + }; + + return { inputEl, sendMessage, view }; + } + + it('registers Escape on the Obsidian view scope instead of document keydown capture', () => { + const { view } = createEscapeHarness({ isStreaming: true }); + + view.wireEventHandlers(); + + expect(view.scope).toBeInstanceOf(Scope); + expect(view.scope.parent).toBe(view.app.scope); + expect(view.scope.register).toHaveBeenCalledWith([], 'Escape', expect.any(Function)); + expect(view.registerDomEvent).not.toHaveBeenCalledWith( + expect.anything(), + 'keydown', + expect.any(Function), + { capture: true } + ); + }); + + it('cancels streaming and consumes scoped Escape', () => { + const { cancelStreaming, view } = createEscapeHarness({ isStreaming: true }); + + view.wireEventHandlers(); + const escapeHandler = view.scope.handlers.find((handler: any) => handler.key === 'Escape'); + const result = escapeHandler.func({ key: 'Escape', isComposing: false } as KeyboardEvent); + + expect(cancelStreaming).toHaveBeenCalledTimes(1); + expect(result).toBe(false); + }); + + it('consumes scoped Escape without cancelling when not streaming', () => { + const { cancelStreaming, view } = createEscapeHarness({ isStreaming: false }); + + view.wireEventHandlers(); + const escapeHandler = view.scope.handlers.find((handler: any) => handler.key === 'Escape'); + const result = escapeHandler.func({ key: 'Escape', isComposing: false } as KeyboardEvent); + + expect(cancelStreaming).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('consumes already handled scoped Escape without cancelling again', () => { + const { cancelStreaming, view } = createEscapeHarness({ isStreaming: true }); + + view.wireEventHandlers(); + const escapeHandler = view.scope.handlers.find((handler: any) => handler.key === 'Escape'); + const result = escapeHandler.func({ + key: 'Escape', + isComposing: false, + defaultPrevented: true, + } as KeyboardEvent); + + expect(cancelStreaming).not.toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it('sends from focused composer through scoped Mod+Enter', () => { + Platform.isMacOS = true; + const { sendMessage, view } = createScopedSendHarness({ inputFocused: true }); + + view.wireEventHandlers(); + const sendHandler = view.scope.handlers.find( + (handler: any) => handler.key === 'Enter' && handler.modifiers?.includes('Mod') + ); + const event = { + key: 'Enter', + shiftKey: false, + ctrlKey: false, + metaKey: true, + altKey: false, + isComposing: false, + defaultPrevented: false, + preventDefault: jest.fn(), + } as unknown as KeyboardEvent; + const result = sendHandler.func(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(result).toBe(false); + }); + + it('ignores scoped Mod+Enter when composer is not focused', () => { + Platform.isMacOS = true; + const { sendMessage, view } = createScopedSendHarness({ inputFocused: false }); + + view.wireEventHandlers(); + const sendHandler = view.scope.handlers.find( + (handler: any) => handler.key === 'Enter' && handler.modifiers?.includes('Mod') + ); + const event = { + key: 'Enter', + shiftKey: false, + ctrlKey: false, + metaKey: true, + altKey: false, + isComposing: false, + defaultPrevented: false, + preventDefault: jest.fn(), + } as unknown as KeyboardEvent; + const result = sendHandler.func(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(sendMessage).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); +}); diff --git a/tests/unit/features/chat/controllers/BrowserSelectionController.test.ts b/tests/unit/features/chat/controllers/BrowserSelectionController.test.ts new file mode 100644 index 0000000..f699660 --- /dev/null +++ b/tests/unit/features/chat/controllers/BrowserSelectionController.test.ts @@ -0,0 +1,160 @@ +/** @jest-environment jsdom */ + +import { BrowserSelectionController } from '@/features/chat/controllers/BrowserSelectionController'; + +function createMockContextTray() { + return { + setItems: jest.fn(), + clearItems: jest.fn(), + }; +} + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('BrowserSelectionController', () => { + let controller: BrowserSelectionController; + let app: any; + let contextTray: ReturnType; + let inputEl: HTMLTextAreaElement; + let containerEl: HTMLElement; + let selectionText = 'selected web snippet'; + let getSelectionSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + selectionText = 'selected web snippet'; + + contextTray = createMockContextTray(); + inputEl = document.createElement('textarea'); + document.body.appendChild(inputEl); + containerEl = document.createElement('div'); + const selectionAnchor = document.createElement('span'); + containerEl.appendChild(selectionAnchor); + + getSelectionSpy = jest.spyOn(document, 'getSelection').mockImplementation(() => ({ + toString: () => selectionText, + anchorNode: selectionAnchor, + focusNode: selectionAnchor, + } as unknown as Selection)); + + const view = { + getViewType: () => 'surfing-view', + getDisplayText: () => 'Surfing', + containerEl, + currentUrl: 'https://example.com', + }; + + app = { + workspace: { + activeLeaf: { view }, + getMostRecentLeaf: jest.fn(() => ({ view })), + }, + }; + + controller = new BrowserSelectionController(app, contextTray as any, inputEl); + }); + + afterEach(() => { + controller.stop(); + inputEl.remove(); + getSelectionSpy.mockRestore(); + jest.useRealTimers(); + }); + + it('captures browser selection and updates indicator', async () => { + controller.start(); + jest.advanceTimersByTime(250); + await flushMicrotasks(); + + expect(controller.getContext()).toEqual({ + source: 'browser:https://example.com', + selectedText: 'selected web snippet', + title: 'Surfing', + url: 'https://example.com', + }); + expect(contextTray.setItems).toHaveBeenLastCalledWith('browser-selection', [ + expect.objectContaining({ + label: '1 line selected', + }), + ]); + expect(contextTray.setItems.mock.calls[0][1][0]).not.toHaveProperty('title'); + }); + + it('shows line-based indicator text for multi-line browser selection', async () => { + selectionText = 'line 1\nline 2'; + controller.start(); + jest.advanceTimersByTime(250); + await flushMicrotasks(); + + expect(contextTray.setItems).toHaveBeenLastCalledWith('browser-selection', [ + expect.objectContaining({ label: '2 lines selected' }), + ]); + }); + + it('clears selection when text is deselected and input is not focused', async () => { + controller.start(); + jest.advanceTimersByTime(250); + await flushMicrotasks(); + expect(controller.hasSelection()).toBe(true); + + selectionText = ''; + jest.advanceTimersByTime(250); + await flushMicrotasks(); + + expect(controller.hasSelection()).toBe(false); + expect(contextTray.clearItems).toHaveBeenCalledWith('browser-selection'); + }); + + it('keeps selection while input is focused', async () => { + controller.start(); + jest.advanceTimersByTime(250); + await flushMicrotasks(); + expect(controller.hasSelection()).toBe(true); + + selectionText = ''; + inputEl.focus(); + jest.advanceTimersByTime(250); + await flushMicrotasks(); + + expect(controller.hasSelection()).toBe(true); + }); + + it('clears selection when clear is called', async () => { + controller.start(); + jest.advanceTimersByTime(250); + await flushMicrotasks(); + expect(controller.hasSelection()).toBe(true); + + controller.clear(); + + expect(controller.hasSelection()).toBe(false); + expect(contextTray.clearItems).toHaveBeenCalledWith('browser-selection'); + }); + + it('clears selection from the tray remove action', async () => { + controller.start(); + jest.advanceTimersByTime(250); + await flushMicrotasks(); + + const items = contextTray.setItems.mock.calls[0][1]; + items[0].onRemove(); + + expect(controller.hasSelection()).toBe(false); + expect(contextTray.clearItems).toHaveBeenCalledWith('browser-selection'); + }); + + it('handles polling errors without unhandled rejection', async () => { + const extractSpy = jest.spyOn(controller as any, 'extractSelectedText') + .mockRejectedValueOnce(new Error('poll failed')); + + controller.start(); + jest.advanceTimersByTime(250); + await flushMicrotasks(); + + expect(extractSpy).toHaveBeenCalled(); + expect(controller.hasSelection()).toBe(false); + }); +}); diff --git a/tests/unit/features/chat/controllers/CanvasSelectionController.test.ts b/tests/unit/features/chat/controllers/CanvasSelectionController.test.ts new file mode 100644 index 0000000..86f7f20 --- /dev/null +++ b/tests/unit/features/chat/controllers/CanvasSelectionController.test.ts @@ -0,0 +1,197 @@ +import { createMockEl } from '@test/helpers/mockElement'; + +import { CanvasSelectionController } from '@/features/chat/controllers/CanvasSelectionController'; + +function createMockContextTray() { + return { + setItems: jest.fn(), + clearItems: jest.fn(), + }; +} + +function createMockCanvasNode(id: string) { + return { id }; +} + +describe('CanvasSelectionController', () => { + let controller: CanvasSelectionController; + let app: any; + let contextTray: ReturnType; + let inputEl: any; + let canvasView: any; + let originalDocument: any; + + beforeEach(() => { + jest.useFakeTimers(); + + contextTray = createMockContextTray(); + inputEl = createMockEl(); + + const node1 = createMockCanvasNode('abc123'); + const node2 = createMockCanvasNode('def456'); + + canvasView = { + getViewType: () => 'canvas', + canvas: { + selection: new Set([node1, node2]), + }, + file: { path: 'my-canvas.canvas' }, + }; + + app = { + workspace: { + getActiveViewOfType: jest.fn().mockReturnValue(null), + getMostRecentLeaf: jest.fn().mockReturnValue({ view: canvasView }), + getLeavesOfType: jest.fn().mockReturnValue([{ view: canvasView }]), + }, + }; + + controller = new CanvasSelectionController(app, contextTray as any, inputEl); + + originalDocument = (global as any).document; + (global as any).document = { activeElement: null }; + }); + + afterEach(() => { + controller.stop(); + jest.useRealTimers(); + (global as any).document = originalDocument; + }); + + it('captures canvas selection and updates indicator', () => { + controller.start(); + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(true); + expect(controller.getContext()).toEqual({ + canvasPath: 'my-canvas.canvas', + nodeIds: expect.arrayContaining(['abc123', 'def456']), + }); + expect(contextTray.setItems).toHaveBeenLastCalledWith('canvas-selection', [ + expect.objectContaining({ label: '2 nodes selected' }), + ]); + expect(contextTray.setItems.mock.calls[0][1][0]).not.toHaveProperty('title'); + }); + + it('shows node ID for single selection', () => { + const singleNode = createMockCanvasNode('single1'); + canvasView.canvas.selection = new Set([singleNode]); + + controller.start(); + jest.advanceTimersByTime(250); + + expect(controller.getContext()?.nodeIds).toEqual(['single1']); + expect(contextTray.setItems).toHaveBeenLastCalledWith('canvas-selection', [ + expect.objectContaining({ label: '1 node selected' }), + ]); + }); + + it('clears selection when no nodes selected and input not focused', () => { + controller.start(); + jest.advanceTimersByTime(250); + expect(controller.hasSelection()).toBe(true); + + canvasView.canvas.selection = new Set(); + (global as any).document.activeElement = null; + + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(false); + expect(contextTray.clearItems).toHaveBeenCalledWith('canvas-selection'); + }); + + it('preserves selection when input is focused (sticky)', () => { + controller.start(); + jest.advanceTimersByTime(250); + expect(controller.hasSelection()).toBe(true); + + canvasView.canvas.selection = new Set(); + (global as any).document.activeElement = inputEl; + + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(true); + expect(contextTray.clearItems).not.toHaveBeenCalledWith('canvas-selection'); + }); + + it('returns null context when no selection', () => { + canvasView.canvas.selection = new Set(); + controller.start(); + jest.advanceTimersByTime(250); + + expect(controller.getContext()).toBeNull(); + }); + + it('does not update when selection unchanged', () => { + controller.start(); + jest.advanceTimersByTime(250); + + contextTray.setItems.mockClear(); + + jest.advanceTimersByTime(250); + + expect(contextTray.setItems).not.toHaveBeenCalled(); + }); + + it('prefers active canvas leaf when multiple canvases are open', () => { + const activeNode = createMockCanvasNode('active-node'); + const inactiveNode = createMockCanvasNode('inactive-node'); + const inactiveCanvasView = { + getViewType: () => 'canvas', + canvas: { selection: new Set([inactiveNode]) }, + file: { path: 'inactive.canvas' }, + }; + const activeCanvasView = { + getViewType: () => 'canvas', + canvas: { selection: new Set([activeNode]) }, + file: { path: 'active.canvas' }, + }; + + app.workspace.getLeavesOfType.mockReturnValue([ + { view: inactiveCanvasView }, + { view: activeCanvasView }, + ]); + app.workspace.getMostRecentLeaf.mockReturnValue({ view: activeCanvasView }); + + controller.start(); + jest.advanceTimersByTime(250); + + expect(controller.getContext()).toEqual({ + canvasPath: 'active.canvas', + nodeIds: ['active-node'], + }); + }); + + it('handles no canvas view gracefully', () => { + app.workspace.getMostRecentLeaf.mockReturnValue(null); + app.workspace.getLeavesOfType.mockReturnValue([]); + + controller.start(); + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(false); + expect(controller.getContext()).toBeNull(); + }); + + it('clear() resets state and indicator', () => { + controller.start(); + jest.advanceTimersByTime(250); + expect(controller.hasSelection()).toBe(true); + + controller.clear(); + + expect(controller.hasSelection()).toBe(false); + expect(contextTray.clearItems).toHaveBeenCalledWith('canvas-selection'); + }); + + it('clears selection from the tray remove action', () => { + controller.start(); + jest.advanceTimersByTime(250); + + const items = contextTray.setItems.mock.calls[0][1]; + items[0].onRemove(); + + expect(controller.hasSelection()).toBe(false); + expect(contextTray.clearItems).toHaveBeenCalledWith('canvas-selection'); + }); +}); diff --git a/tests/unit/features/chat/controllers/ConversationController.test.ts b/tests/unit/features/chat/controllers/ConversationController.test.ts new file mode 100644 index 0000000..35852e0 --- /dev/null +++ b/tests/unit/features/chat/controllers/ConversationController.test.ts @@ -0,0 +1,2795 @@ +import { createMockEl } from '@test/helpers/mockElement'; +import { Menu, Notice } from 'obsidian'; + +import { ConversationController, type ConversationControllerDeps } from '@/features/chat/controllers/ConversationController'; +import { ChatState } from '@/features/chat/state/ChatState'; +import { confirm } from '@/shared/modals/ConfirmModal'; + +jest.mock('@/shared/modals/ConfirmModal', () => ({ + confirm: jest.fn().mockResolvedValue(true), +})); + +const mockNotice = Notice as jest.Mock; + +function createMockDeps(overrides: Partial = {}): ConversationControllerDeps { + const state = new ChatState(); + const inputEl = { value: '', focus: jest.fn() } as unknown as HTMLTextAreaElement; + const historyDropdown = createMockEl(); + let welcomeEl: any = createMockEl(); + const messagesEl = createMockEl(); + + const fileContextManager = { + resetForNewConversation: jest.fn(), + resetForLoadedConversation: jest.fn(), + autoAttachActiveFile: jest.fn(), + setCurrentNote: jest.fn(), + getCurrentNotePath: jest.fn().mockReturnValue(null), + }; + + return { + plugin: { + createConversation: jest.fn().mockResolvedValue({ + id: 'new-conv', + title: 'New Conversation', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }), + switchConversation: jest.fn().mockResolvedValue({ + id: 'switched-conv', + title: 'Switched Conversation', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }), + getConversationById: jest.fn().mockResolvedValue(null), + getConversationSync: jest.fn().mockReturnValue(null), + getConversationList: jest.fn().mockReturnValue([]), + findEmptyConversation: jest.fn().mockResolvedValue(null), + updateConversation: jest.fn().mockResolvedValue(undefined), + renameConversation: jest.fn().mockResolvedValue(undefined), + deleteConversation: jest.fn().mockResolvedValue(undefined), + agentService: { + getSessionId: jest.fn().mockResolvedValue(null), + setSessionId: jest.fn(), + }, + settings: { + userName: '', + enableAutoTitleGeneration: true, + permissionMode: 'yolo', + }, + } as any, + state, + renderer: { + renderMessages: jest.fn().mockReturnValue(createMockEl()), + } as any, + subagentManager: { + orphanAllActive: jest.fn(), + clear: jest.fn(), + } as any, + getHistoryDropdown: () => historyDropdown as any, + getWelcomeEl: () => welcomeEl, + setWelcomeEl: (el: any) => { welcomeEl = el; }, + getMessagesEl: () => messagesEl as any, + getInputEl: () => inputEl, + getFileContextManager: () => fileContextManager as any, + getImageContextManager: () => ({ + clearImages: jest.fn(), + }) as any, + getMcpServerSelector: () => ({ + clearEnabled: jest.fn(), + getEnabledServers: jest.fn().mockResolvedValue(new Set()), + setEnabledServers: jest.fn(), + }) as any, + getExternalContextSelector: () => ({ + getExternalContexts: jest.fn().mockReturnValue([]), + setExternalContexts: jest.fn(), + clearExternalContexts: jest.fn(), + }) as any, + clearQueuedMessage: jest.fn(), + getTitleGenerationService: () => null, + getStatusPanel: () => ({ + remount: jest.fn(), + }) as any, + ...overrides, + }; +} + +describe('ConversationController', () => { + let controller: ConversationController; + let deps: ConversationControllerDeps; + + beforeEach(() => { + jest.clearAllMocks(); + (Menu as typeof Menu & { instances: unknown[] }).instances.length = 0; + deps = createMockDeps(); + controller = new ConversationController(deps); + }); + + describe('Queue Management', () => { + describe('Creating new conversation', () => { + it('should clear queued message on new conversation', async () => { + deps.state.queuedMessage = { content: 'test', images: undefined, editorContext: null, canvasContext: null }; + deps.state.isStreaming = false; + + await controller.createNew(); + + expect(deps.clearQueuedMessage).toHaveBeenCalled(); + }); + + it('should not create new conversation while streaming', async () => { + deps.state.isStreaming = true; + + await controller.createNew(); + + expect(deps.plugin.createConversation).not.toHaveBeenCalled(); + }); + + it('should save current conversation before creating new one', async () => { + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + deps.state.currentConversationId = 'old-conv'; + + await controller.createNew(); + + expect(deps.plugin.updateConversation).toHaveBeenCalledWith('old-conv', expect.any(Object)); + }); + + it('should reset file context for new conversation', async () => { + const fileContextManager = deps.getFileContextManager()!; + + await controller.createNew(); + + expect(fileContextManager.resetForNewConversation).toHaveBeenCalled(); + expect(fileContextManager.autoAttachActiveFile).toHaveBeenCalled(); + }); + + it('should clear todos for new conversation', async () => { + deps.state.currentTodos = [ + { content: 'Existing todo', status: 'pending', activeForm: 'Doing existing todo' } + ]; + expect(deps.state.currentTodos).not.toBeNull(); + + await controller.createNew(); + + expect(deps.state.currentTodos).toBeNull(); + }); + + it('should reset to entry point state (null conversationId) instead of creating conversation', async () => { + // Entry point model: createNew() resets to blank state without creating conversation + // Conversation is created lazily on first message send + await controller.createNew(); + + expect((deps.plugin as unknown as { findEmptyConversation: jest.Mock }).findEmptyConversation) + .not.toHaveBeenCalled(); + expect(deps.plugin.createConversation).not.toHaveBeenCalled(); + expect(deps.plugin.switchConversation).not.toHaveBeenCalled(); + expect(deps.state.currentConversationId).toBeNull(); + }); + + it('should clear messages and reset state when creating new', async () => { + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + deps.state.currentConversationId = 'old-conv'; + + const clearMessagesSpy = jest.spyOn(deps.state, 'clearMessages'); + + await controller.createNew(); + + expect(clearMessagesSpy).toHaveBeenCalled(); + expect(deps.state.currentConversationId).toBeNull(); + + clearMessagesSpy.mockRestore(); + }); + }); + + describe('Switching conversations', () => { + it('should clear queued message on conversation switch', async () => { + deps.state.currentConversationId = 'old-conv'; + deps.state.queuedMessage = { content: 'test', images: undefined, editorContext: null, canvasContext: null }; + + await controller.switchTo('new-conv'); + + expect(deps.clearQueuedMessage).toHaveBeenCalled(); + }); + + it('should not switch while streaming', async () => { + deps.state.isStreaming = true; + deps.state.currentConversationId = 'old-conv'; + + await controller.switchTo('new-conv'); + + expect(deps.plugin.switchConversation).not.toHaveBeenCalled(); + }); + + it('should not switch to current conversation', async () => { + deps.state.currentConversationId = 'same-conv'; + + await controller.switchTo('same-conv'); + + expect(deps.plugin.switchConversation).not.toHaveBeenCalled(); + }); + + it('should reset file context when switching conversations', async () => { + deps.state.currentConversationId = 'old-conv'; + const fileContextManager = deps.getFileContextManager()!; + + await controller.switchTo('new-conv'); + + expect(fileContextManager.resetForLoadedConversation).toHaveBeenCalled(); + }); + + it('should clear input value on switch', async () => { + deps.state.currentConversationId = 'old-conv'; + const inputEl = deps.getInputEl(); + inputEl.value = 'some input'; + + await controller.switchTo('new-conv'); + + expect(inputEl.value).toBe(''); + }); + + it('should hide history dropdown after switch', async () => { + deps.state.currentConversationId = 'old-conv'; + const dropdown = deps.getHistoryDropdown()!; + dropdown.addClass('visible'); + + await controller.switchTo('new-conv'); + + expect(dropdown.hasClass('visible')).toBe(false); + }); + }); + + describe('Welcome visibility', () => { + it('should hide welcome when messages exist', () => { + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + const welcomeEl = deps.getWelcomeEl()!; + + controller.updateWelcomeVisibility(); + + expect(welcomeEl.style.display).toBe('none'); + }); + + it('should show welcome when no messages exist', () => { + deps.state.messages = []; + const welcomeEl = deps.getWelcomeEl()!; + + controller.updateWelcomeVisibility(); + + // When no messages, welcome should not be 'none' (either 'block' or empty string) + expect(welcomeEl.style.display).not.toBe('none'); + }); + + it('should update welcome visibility after switching to conversation with messages', async () => { + deps.state.currentConversationId = 'old-conv'; + deps.state.messages = []; + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ + id: 'new-conv', + messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], + sessionId: null, + }); + + await controller.switchTo('new-conv'); + + expect(deps.state.messages.length).toBe(1); + const welcomeEl = deps.getWelcomeEl()!; + expect(welcomeEl.style.display).toBe('none'); + }); + }); + }); + + describe('initializeWelcome', () => { + it('should initialize file context for new tab', () => { + const fileContextManager = deps.getFileContextManager()!; + + controller.initializeWelcome(); + + expect(fileContextManager.resetForNewConversation).toHaveBeenCalled(); + expect(fileContextManager.autoAttachActiveFile).toHaveBeenCalled(); + }); + + it('should not throw if welcomeEl is null', () => { + const depsWithNullWelcome = createMockDeps({ + getWelcomeEl: () => null, + }); + const controllerWithNullWelcome = new ConversationController(depsWithNullWelcome); + + expect(() => controllerWithNullWelcome.initializeWelcome()).not.toThrow(); + }); + + it('should only add greeting if not already present', () => { + const welcomeEl = deps.getWelcomeEl()!; + const createDivSpy = jest.spyOn(welcomeEl, 'createDiv'); + + // First call should add greeting + controller.initializeWelcome(); + expect(createDivSpy).toHaveBeenCalledTimes(1); + + // Mock querySelector to return an element (greeting already exists) + welcomeEl.querySelector = jest.fn().mockReturnValue(createMockEl()); + + // Second call should not add another greeting + controller.initializeWelcome(); + expect(createDivSpy).toHaveBeenCalledTimes(1); // Still 1, not 2 + }); + }); + + describe('formatDate', () => { + it('should return time format for today', () => { + const now = new Date(); + const result = controller.formatDate(now.getTime()); + + expect(result).toMatch(/^\d{2}:\d{2}$/); + }); + + it('should return month/day format for a past date', () => { + const pastDate = new Date(2023, 0, 15).getTime(); + const result = controller.formatDate(pastDate); + + expect(result).toContain('15'); + expect(result.length).toBeGreaterThan(0); + }); + + it('should return month/day format for yesterday', () => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const result = controller.formatDate(yesterday.getTime()); + + expect(result).not.toMatch(/^\d{2}:\d{2}$/); + }); + }); + + describe('toggleHistoryDropdown', () => { + it('should add visible class when dropdown is hidden', () => { + const dropdown = deps.getHistoryDropdown()!; + expect(dropdown.hasClass('visible')).toBe(false); + + controller.toggleHistoryDropdown(); + + expect(dropdown.hasClass('visible')).toBe(true); + }); + + it('should remove visible class when dropdown is visible', () => { + const dropdown = deps.getHistoryDropdown()!; + dropdown.addClass('visible'); + + controller.toggleHistoryDropdown(); + + expect(dropdown.hasClass('visible')).toBe(false); + }); + + it('should not throw when dropdown is null', () => { + const depsNullDropdown = createMockDeps({ + getHistoryDropdown: () => null, + }); + const ctrl = new ConversationController(depsNullDropdown); + + expect(() => ctrl.toggleHistoryDropdown()).not.toThrow(); + }); + }); + + describe('save edge cases', () => { + it('should return early when no conversationId and no messages', async () => { + deps.state.currentConversationId = null; + deps.state.messages = []; + + await controller.save(); + + expect(deps.plugin.updateConversation).not.toHaveBeenCalled(); + expect(deps.plugin.createConversation).not.toHaveBeenCalled(); + }); + + it('should lazily create conversation when entry point has messages', async () => { + deps.state.currentConversationId = null; + deps.state.messages = [{ id: '1', role: 'user', content: 'hello', timestamp: Date.now() }]; + + (deps.plugin.createConversation as jest.Mock).mockResolvedValue({ + id: 'lazy-conv', + title: 'New Conversation', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + await controller.save(); + + expect(deps.plugin.createConversation).toHaveBeenCalled(); + expect(deps.state.currentConversationId).toBe('lazy-conv'); + expect(deps.plugin.updateConversation).toHaveBeenCalledWith( + 'lazy-conv', + expect.any(Object) + ); + }); + + it('should preserve the active runtime provider when lazily creating a conversation', async () => { + deps = createMockDeps({ + getAgentService: () => ({ + providerId: 'codex', + getSessionId: jest.fn().mockReturnValue('session-codex'), + consumeSessionInvalidation: jest.fn().mockReturnValue(false), + buildSessionUpdates: jest.fn().mockReturnValue({ updates: {} }), + syncConversationState: jest.fn(), + }) as any, + }); + controller = new ConversationController(deps); + deps.state.currentConversationId = null; + deps.state.messages = [{ id: '1', role: 'user', content: 'hello', timestamp: Date.now() }]; + + (deps.plugin.createConversation as jest.Mock).mockResolvedValue({ + id: 'lazy-codex-conv', + providerId: 'codex', + title: 'Codex Conversation', + messages: [], + sessionId: 'session-codex', + createdAt: Date.now(), + updatedAt: Date.now(), + }); + + await controller.save(); + + expect(deps.plugin.createConversation).toHaveBeenCalledWith({ + providerId: 'codex', + sessionId: 'session-codex', + }); + }); + + it('should set lastResponseAt when updateLastResponse is true', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + + const beforeCall = Date.now(); + + await controller.save(true); + + const call = (deps.plugin.updateConversation as jest.Mock).mock.calls[0]; + const updates = call[1]; + expect(updates.lastResponseAt).toBeDefined(); + expect(updates.lastResponseAt).toBeGreaterThanOrEqual(beforeCall); + expect(updates.lastResponseAt).toBeLessThanOrEqual(Date.now()); + }); + + it('should NOT clear resumeAtMessageId when updateLastResponse is true (caller must pass extraUpdates)', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + + await controller.save(true); + + const call = (deps.plugin.updateConversation as jest.Mock).mock.calls[0]; + const updates = call[1]; + expect(updates).not.toHaveProperty('resumeAtMessageId'); + }); + + it('should clear resumeAtMessageId when passed via extraUpdates', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + + await controller.save(true, { resumeAtMessageId: undefined }); + + const call = (deps.plugin.updateConversation as jest.Mock).mock.calls[0]; + const updates = call[1]; + expect(updates.resumeAtMessageId).toBeUndefined(); + // Verify it's explicitly set (not just missing) + expect('resumeAtMessageId' in updates).toBe(true); + }); + + it('should not clear resumeAtMessageId when updateLastResponse is false', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + + await controller.save(false); + + const call = (deps.plugin.updateConversation as jest.Mock).mock.calls[0]; + const updates = call[1]; + expect(updates).not.toHaveProperty('resumeAtMessageId'); + }); + + it('should clear pending conversation save state after persisting', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + deps.state.hasPendingConversationSave = true; + + await controller.save(); + + expect(deps.state.hasPendingConversationSave).toBe(false); + }); + }); + + describe('loadActive with existing conversation', () => { + it('should restore currentNote when conversation has one', async () => { + const fileContextManager = deps.getFileContextManager()!; + deps.state.currentConversationId = 'conv-with-note'; + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: 'conv-with-note', + messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], + sessionId: null, + currentNote: 'notes/my-note.md', + }); + + await controller.loadActive(); + + expect(fileContextManager.setCurrentNote).toHaveBeenCalledWith('notes/my-note.md'); + }); + + it('should auto-attach active file when no currentNote and no messages', async () => { + const fileContextManager = deps.getFileContextManager()!; + deps.state.currentConversationId = 'empty-conv'; + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: 'empty-conv', + messages: [], + sessionId: null, + currentNote: undefined, + }); + + await controller.loadActive(); + + expect(fileContextManager.autoAttachActiveFile).toHaveBeenCalled(); + expect(fileContextManager.setCurrentNote).not.toHaveBeenCalled(); + }); + + it('should call renderer.renderMessages with greeting callback', async () => { + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: 'conv-1', + messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], + sessionId: null, + }); + + await controller.loadActive(); + + expect(deps.renderer.renderMessages).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Function) + ); + + const greetingFn = (deps.renderer.renderMessages as jest.Mock).mock.calls[0][1]; + expect(greetingFn().length).toBeGreaterThan(0); + }); + }); + + describe('switchTo with currentNote', () => { + it('should set currentNote when switched conversation has one', async () => { + const fileContextManager = deps.getFileContextManager()!; + deps.state.currentConversationId = 'old-conv'; + + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ + id: 'new-conv', + messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], + sessionId: null, + currentNote: 'docs/readme.md', + }); + + await controller.switchTo('new-conv'); + + expect(fileContextManager.setCurrentNote).toHaveBeenCalledWith('docs/readme.md'); + }); + + it('should not set currentNote when switched conversation has none', async () => { + const fileContextManager = deps.getFileContextManager()!; + deps.state.currentConversationId = 'old-conv'; + + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ + id: 'new-conv', + messages: [], + sessionId: null, + currentNote: undefined, + }); + + await controller.switchTo('new-conv'); + + expect(fileContextManager.setCurrentNote).not.toHaveBeenCalled(); + }); + + it('should call renderer.renderMessages with greeting callback on switch', async () => { + deps.state.currentConversationId = 'old-conv'; + + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ + id: 'new-conv', + messages: [], + sessionId: null, + }); + + await controller.switchTo('new-conv'); + + expect(deps.renderer.renderMessages).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Function) + ); + + const greetingFn = (deps.renderer.renderMessages as jest.Mock).mock.calls[0][1]; + expect(greetingFn().length).toBeGreaterThan(0); + }); + }); + + describe('History Rendering', () => { + let dropdown: any; + + beforeEach(() => { + dropdown = createMockEl(); + deps.getHistoryDropdown = () => dropdown; + }); + + describe('updateHistoryDropdown with conversations', () => { + it('should render conversation items when conversations exist', () => { + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'First Conversation', createdAt: 1000, lastResponseAt: 3000 }, + { id: 'conv-2', title: 'Second Conversation', createdAt: 2000, lastResponseAt: 2000 }, + ]); + + controller.updateHistoryDropdown(); + + expect(dropdown.children.length).toBe(2); + const list = dropdown.children[1]; + expect(list.hasClass('claudian-history-list')).toBe(true); + expect(list.children.length).toBe(2); + }); + + it('should show "No conversations" when list is empty', () => { + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([]); + + controller.updateHistoryDropdown(); + + const list = dropdown.children[1]; + expect(list.children[0].hasClass('claudian-history-empty')).toBe(true); + }); + + it('should sort conversations by lastResponseAt descending', () => { + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-old', title: 'Old', createdAt: 1000, lastResponseAt: 1000 }, + { id: 'conv-new', title: 'New', createdAt: 2000, lastResponseAt: 5000 }, + { id: 'conv-mid', title: 'Mid', createdAt: 3000, lastResponseAt: 3000 }, + ]); + + controller.updateHistoryDropdown(); + + const list = dropdown.children[1]; + const firstTitle = list.children[0].querySelector('.claudian-history-item-title'); + expect(firstTitle?.textContent).toBe('New'); + }); + + it('should mark current conversation as active', () => { + deps.state.currentConversationId = 'conv-1'; + + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 1000 }, + { id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 2000 }, + ]); + + controller.updateHistoryDropdown(); + + const list = dropdown.children[1]; + const items = list.children; + const activeItem = items.find((item: any) => item.hasClass('active')); + expect(activeItem).toBeDefined(); + }); + + it('should show loading indicator for pending title generation', () => { + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Generating...', createdAt: 1000, lastResponseAt: 1000, titleGenerationStatus: 'pending' }, + ]); + + controller.updateHistoryDropdown(); + + const list = dropdown.children[1]; + const item = list.children[0]; + const loadingEl = item.querySelector('.claudian-action-loading'); + expect(loadingEl).toBeTruthy(); + }); + + it('should show regenerate button for failed title generation', () => { + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Fallback Title', createdAt: 1000, lastResponseAt: 1000, titleGenerationStatus: 'failed' }, + ]); + + controller.updateHistoryDropdown(); + + const list = dropdown.children[1]; + const item = list.children[0]; + const actions = item.querySelector('.claudian-history-item-actions'); + expect(actions).toBeTruthy(); + // regenerate button + rename button + delete button = 3 children + expect(actions!.children.length).toBe(3); + }); + + it('should not show select click handler on current conversation', () => { + deps.state.currentConversationId = 'conv-1'; + + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 1000 }, + ]); + + controller.updateHistoryDropdown(); + + const list = dropdown.children[1]; + const item = list.children[0]; + const content = item.querySelector('.claudian-history-item-content'); + const listeners = content?._eventListeners?.get('click'); + expect(listeners).toBeUndefined(); + }); + + it('should attach select click handler on non-current conversations', () => { + deps.state.currentConversationId = 'conv-1'; + + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 }, + ]); + + controller.updateHistoryDropdown(); + + const list = dropdown.children[1]; + // conv-2 is the non-current one (sorted second by lastResponseAt) + const otherItem = list.children[1]; + const content = otherItem.querySelector('.claudian-history-item-content'); + const listeners = content?._eventListeners?.get('click'); + expect(listeners).toBeDefined(); + expect(listeners!.length).toBe(1); + }); + + it('should not delete while streaming', async () => { + deps.state.isStreaming = true; + + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Test', createdAt: 1000, lastResponseAt: 1000 }, + ]); + + controller.updateHistoryDropdown(); + + const list = dropdown.children[1]; + const item = list.children[0]; + const deleteBtn = item.querySelector('.claudian-delete-btn'); + expect(deleteBtn).toBeTruthy(); + + const clickHandlers = deleteBtn!._eventListeners?.get('click'); + expect(clickHandlers).toBeDefined(); + await clickHandlers![0]({ stopPropagation: jest.fn() }); + + expect(deps.plugin.deleteConversation).not.toHaveBeenCalled(); + }); + }); + + describe('renderHistoryDropdown', () => { + it('should render history items to provided container', () => { + const container = createMockEl(); + const onSelectConversation = jest.fn(); + + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Test', createdAt: 1000, lastResponseAt: 1000 }, + ]); + + controller.renderHistoryDropdown(container, { onSelectConversation }); + + expect(container.children.length).toBe(2); // header + list + }); + + it('should highlight conversations already open in a tab', () => { + const container = createMockEl(); + + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Open elsewhere', createdAt: 2000, lastResponseAt: 1000 }, + ]); + + controller.renderHistoryDropdown(container, { + onSelectConversation: jest.fn(), + getConversationOpenState: (id) => id === 'conv-2' ? 'open' : 'current', + }); + + const list = container.children[1]; + const openItem = list.children[1]; + const openItemDate = openItem.querySelector('.claudian-history-item-date'); + + expect(openItem.hasClass('open')).toBe(true); + expect(openItem.hasClass('active')).toBe(false); + expect(openItem.getAttribute('data-open-state')).toBe('open'); + expect(openItemDate?.textContent).toBe('Open in tab'); + }); + + it('should display the current tab number when available', () => { + const container = createMockEl(); + + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + ]); + + controller.renderHistoryDropdown(container, { + onSelectConversation: jest.fn(), + getConversationStatus: () => ({ + openState: 'current', + isRunning: false, + location: 'current-view', + tabIndex: 1, + }), + }); + + const list = container.children[1]; + const currentItem = list.children[0]; + const currentItemDate = currentItem.querySelector('.claudian-history-item-date'); + + expect(currentItem.getAttribute('data-tab-index')).toBe('1'); + expect(currentItem.getAttribute('data-tab-location')).toBe('current-view'); + expect(currentItemDate?.textContent).toBe('Current tab 1'); + }); + + it('should display the open tab number when available', () => { + const container = createMockEl(); + + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Open elsewhere', createdAt: 2000, lastResponseAt: 1000 }, + ]); + + controller.renderHistoryDropdown(container, { + onSelectConversation: jest.fn(), + getConversationStatus: (id) => id === 'conv-2' + ? { openState: 'open', isRunning: false, location: 'current-view', tabIndex: 2 } + : { openState: 'current', isRunning: false, location: 'current-view', tabIndex: 1 }, + }); + + const list = container.children[1]; + const openItem = list.children[1]; + const openItemDate = openItem.querySelector('.claudian-history-item-date'); + + expect(openItem.getAttribute('data-tab-index')).toBe('2'); + expect(openItem.getAttribute('data-tab-location')).toBe('current-view'); + expect(openItemDate?.textContent).toBe('Open in tab 2'); + }); + + it('should display running status for the current conversation', () => { + const container = createMockEl(); + + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + ]); + + controller.renderHistoryDropdown(container, { + onSelectConversation: jest.fn(), + getConversationStatus: () => ({ + openState: 'current', + isRunning: true, + }), + }); + + const list = container.children[1]; + const currentItem = list.children[0]; + const currentItemDate = currentItem.querySelector('.claudian-history-item-date'); + + expect(currentItem.hasClass('active')).toBe(true); + expect(currentItem.hasClass('running')).toBe(true); + expect(currentItem.getAttribute('data-running')).toBe('true'); + expect(currentItemDate?.textContent).toBe('Running in current tab'); + }); + + it('should display running status for a conversation open in another tab', () => { + const container = createMockEl(); + + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Running elsewhere', createdAt: 2000, lastResponseAt: 1000 }, + ]); + + controller.renderHistoryDropdown(container, { + onSelectConversation: jest.fn(), + getConversationStatus: (id) => id === 'conv-2' + ? { openState: 'open', isRunning: true, location: 'current-view', tabIndex: 2 } + : { openState: 'current', isRunning: false }, + }); + + const list = container.children[1]; + const runningItem = list.children[1]; + const runningItemDate = runningItem.querySelector('.claudian-history-item-date'); + + expect(runningItem.hasClass('open')).toBe(true); + expect(runningItem.hasClass('running')).toBe(true); + expect(runningItem.getAttribute('data-open-state')).toBe('open'); + expect(runningItem.getAttribute('data-running')).toBe('true'); + expect(runningItemDate?.textContent).toBe('Running in tab 2'); + }); + + it('should display another-pane status without a local tab number', () => { + const container = createMockEl(); + + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Open elsewhere', createdAt: 2000, lastResponseAt: 1000 }, + { id: 'conv-3', title: 'Running elsewhere', createdAt: 3000, lastResponseAt: 500 }, + ]); + + controller.renderHistoryDropdown(container, { + onSelectConversation: jest.fn(), + getConversationStatus: (id) => { + if (id === 'conv-2') { + return { openState: 'open', isRunning: false, location: 'other-view' }; + } + if (id === 'conv-3') { + return { openState: 'open', isRunning: true, location: 'other-view' }; + } + return { openState: 'current', isRunning: false, location: 'current-view', tabIndex: 1 }; + }, + }); + + const list = container.children[1]; + const openOtherPaneItem = list.children[1]; + const runningOtherPaneItem = list.children[2]; + const runningOtherPaneDate = runningOtherPaneItem.querySelector('.claudian-history-item-date'); + const openOtherPaneDate = openOtherPaneItem.querySelector('.claudian-history-item-date'); + + expect(runningOtherPaneItem.getAttribute('data-tab-location')).toBe('other-view'); + expect(runningOtherPaneItem.getAttribute('data-tab-index')).toBeNull(); + expect(runningOtherPaneDate?.textContent).toBe('Running in another pane'); + expect(openOtherPaneDate?.textContent).toBe('Open in another pane'); + }); + + it('should render a new-tab button for closed conversations', async () => { + const container = createMockEl(); + const onSelectConversation = jest.fn(); + const onOpenConversationInNewTab = jest.fn().mockResolvedValue(undefined); + + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Closed', createdAt: 2000, lastResponseAt: 1000 }, + ]); + + controller.renderHistoryDropdown(container, { + onSelectConversation, + onOpenConversationInNewTab, + getConversationOpenState: (id) => id === 'conv-2' ? 'closed' : 'current', + }); + + const list = container.children[1]; + const closedItem = list.children[1]; + const openInNewTabBtn = closedItem.querySelector('.claudian-open-new-tab-btn'); + const clickHandlers = openInNewTabBtn?._eventListeners?.get('click'); + + expect(openInNewTabBtn).toBeTruthy(); + expect(clickHandlers).toBeDefined(); + + await clickHandlers![0]({ stopPropagation: jest.fn() }); + + expect(onOpenConversationInNewTab).toHaveBeenCalledWith('conv-2', true); + expect(onSelectConversation).not.toHaveBeenCalled(); + }); + + it('should not render a new-tab button for already-open conversations', () => { + const container = createMockEl(); + + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Open elsewhere', createdAt: 2000, lastResponseAt: 1000 }, + ]); + + controller.renderHistoryDropdown(container, { + onSelectConversation: jest.fn(), + onOpenConversationInNewTab: jest.fn().mockResolvedValue(undefined), + getConversationOpenState: (id) => id === 'conv-2' ? 'open' : 'current', + }); + + const list = container.children[1]; + const openItem = list.children[1]; + + expect(openItem.querySelector('.claudian-open-new-tab-btn')).toBeNull(); + }); + + it('should open a conversation in a new tab on modifier click when supported', async () => { + const container = createMockEl(); + const onSelectConversation = jest.fn(); + const onOpenConversationInNewTab = jest.fn().mockResolvedValue(undefined); + + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 }, + ]); + + controller.renderHistoryDropdown(container, { + onSelectConversation, + onOpenConversationInNewTab, + getConversationOpenState: () => 'closed', + }); + + const list = container.children[1]; + const otherItem = list.children[1]; + const content = otherItem.querySelector('.claudian-history-item-content'); + const clickHandlers = content?._eventListeners?.get('click'); + expect(clickHandlers).toBeDefined(); + + await clickHandlers![0]({ + stopPropagation: jest.fn(), + preventDefault: jest.fn(), + metaKey: true, + ctrlKey: false, + shiftKey: false, + altKey: false, + }); + + expect(onOpenConversationInNewTab).toHaveBeenCalledWith('conv-2', true); + expect(onSelectConversation).not.toHaveBeenCalled(); + }); + + it('should open a conversation in a new tab on middle click when supported', async () => { + const container = createMockEl(); + const onSelectConversation = jest.fn(); + const onOpenConversationInNewTab = jest.fn().mockResolvedValue(undefined); + + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 }, + ]); + + controller.renderHistoryDropdown(container, { + onSelectConversation, + onOpenConversationInNewTab, + getConversationOpenState: () => 'closed', + }); + + const list = container.children[1]; + const otherItem = list.children[1]; + const content = otherItem.querySelector('.claudian-history-item-content'); + const auxClickHandlers = content?._eventListeners?.get('auxclick'); + expect(auxClickHandlers).toBeDefined(); + + await auxClickHandlers![0]({ + button: 1, + stopPropagation: jest.fn(), + preventDefault: jest.fn(), + }); + + expect(onOpenConversationInNewTab).toHaveBeenCalledWith('conv-2', true); + expect(onSelectConversation).not.toHaveBeenCalled(); + }); + + it('should show new-tab actions in the context menu for closed conversations', () => { + const container = createMockEl(); + + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 }, + ]); + + controller.renderHistoryDropdown(container, { + onSelectConversation: jest.fn(), + onOpenConversationInNewTab: jest.fn().mockResolvedValue(undefined), + getConversationOpenState: () => 'closed', + }); + + const list = container.children[1]; + const otherItem = list.children[1]; + otherItem.dispatchEvent({ + type: 'contextmenu', + stopPropagation: jest.fn(), + preventDefault: jest.fn(), + }); + + const menu = (Menu as typeof Menu & { instances: Array<{ items: Array<{ title: string }> }> }).instances[0]; + expect(menu.items.map(item => item.title)).toEqual([ + 'Open in new tab', + 'Open in background tab', + 'Rename', + 'Delete', + ]); + }); + + it('should show switch action in the context menu for already-open conversations', () => { + const container = createMockEl(); + + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 }, + ]); + + controller.renderHistoryDropdown(container, { + onSelectConversation: jest.fn(), + onOpenConversationInNewTab: jest.fn().mockResolvedValue(undefined), + getConversationOpenState: () => 'open', + }); + + const list = container.children[1]; + const otherItem = list.children[1]; + otherItem.dispatchEvent({ + type: 'contextmenu', + stopPropagation: jest.fn(), + preventDefault: jest.fn(), + }); + + const menu = (Menu as typeof Menu & { instances: Array<{ items: Array<{ title: string }> }> }).instances[0]; + expect(menu.items.map(item => item.title)).toEqual([ + 'Switch to open session', + 'Rename', + 'Delete', + ]); + }); + + it('should derive context menu open state from conversation status', () => { + const container = createMockEl(); + + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 }, + ]); + + controller.renderHistoryDropdown(container, { + onSelectConversation: jest.fn(), + onOpenConversationInNewTab: jest.fn().mockResolvedValue(undefined), + getConversationStatus: (id) => id === 'conv-2' + ? { openState: 'open', isRunning: false, location: 'current-view', tabIndex: 2 } + : { openState: 'current', isRunning: false, location: 'current-view', tabIndex: 1 }, + }); + + const list = container.children[1]; + const otherItem = list.children[1]; + otherItem.dispatchEvent({ + type: 'contextmenu', + stopPropagation: jest.fn(), + preventDefault: jest.fn(), + }); + + const menu = (Menu as typeof Menu & { instances: Array<{ items: Array<{ title: string }> }> }).instances[0]; + expect(menu.items.map(item => item.title)).toEqual([ + 'Switch to open session', + 'Rename', + 'Delete', + ]); + }); + }); + }); + + describe('History Item Interactions', () => { + let dropdown: any; + + beforeEach(() => { + dropdown = createMockEl(); + deps.getHistoryDropdown = () => dropdown; + }); + + it('should switch conversation when clicking a non-current item content', async () => { + deps.state.currentConversationId = 'conv-1'; + + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 }, + ]); + + controller.updateHistoryDropdown(); + + const list = dropdown.children[1]; + const otherItem = list.children[1]; + const content = otherItem.querySelector('.claudian-history-item-content'); + const clickHandlers = content?._eventListeners?.get('click'); + expect(clickHandlers).toBeDefined(); + + await clickHandlers![0]({ stopPropagation: jest.fn() }); + await Promise.resolve(); + + expect(deps.plugin.switchConversation).toHaveBeenCalledWith('conv-2'); + }); + + it('should call regenerateTitle when clicking regenerate button on failed item', async () => { + const mockTitleService = { + generateTitle: jest.fn().mockResolvedValue(undefined), + cancel: jest.fn(), + }; + deps.getTitleGenerationService = () => mockTitleService as any; + + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Failed', createdAt: 1000, lastResponseAt: 1000, titleGenerationStatus: 'failed' }, + ]); + + controller.updateHistoryDropdown(); + + const list = dropdown.children[1]; + const item = list.children[0]; + const actions = item.querySelector('.claudian-history-item-actions'); + // First child is the regenerate button + const regenerateBtn = actions!.children[0]; + const clickHandlers = regenerateBtn._eventListeners?.get('click'); + expect(clickHandlers).toBeDefined(); + + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: 'conv-1', + title: 'Failed', + messages: [{ role: 'user', content: 'Hello' }], + }); + + await clickHandlers![0]({ stopPropagation: jest.fn() }); + + expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { + titleGenerationStatus: 'pending', + }); + }); + + it('should invoke rename handler when clicking rename button', () => { + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Test Title', createdAt: 1000, lastResponseAt: 1000 }, + ]); + + controller.updateHistoryDropdown(); + + const list = dropdown.children[1]; + const item = list.children[0]; + const actions = item.querySelector('.claudian-history-item-actions'); + expect(actions).toBeTruthy(); + // For non-failed items: rename is children[0], delete is children[1] + const rBtn = actions!.children[0]; + expect(rBtn).toBeTruthy(); + const clickHandlers = rBtn._eventListeners?.get('click'); + expect(clickHandlers).toBeDefined(); + + const mockInput = createMockEl(); + (mockInput as any).type = ''; + (mockInput as any).className = ''; + (mockInput as any).value = ''; + (mockInput as any).focus = jest.fn(); + (mockInput as any).select = jest.fn(); + + const titleEl = item.querySelector('.claudian-history-item-title'); + if (titleEl) { + (titleEl as any).replaceWith = jest.fn(); + } + + const origDocument = global.document; + global.document = { createElement: jest.fn().mockReturnValue(mockInput) } as any; + + try { + clickHandlers![0]({ stopPropagation: jest.fn() }); + + expect(global.document.createElement).toHaveBeenCalledWith('input'); + expect((mockInput as any).value).toBe('Test Title'); + expect(titleEl!.replaceWith).toHaveBeenCalledWith(mockInput); + } finally { + global.document = origDocument; + } + }); + + it('should delete conversation and reload active when deleting current conversation', async () => { + deps.state.currentConversationId = 'conv-1'; + + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 1000 }, + ]); + + controller.updateHistoryDropdown(); + + const list = dropdown.children[1]; + const item = list.children[0]; + const deleteBtn = item.querySelector('.claudian-delete-btn'); + expect(deleteBtn).toBeTruthy(); + + const clickHandlers = deleteBtn!._eventListeners?.get('click'); + expect(clickHandlers).toBeDefined(); + + await clickHandlers![0]({ stopPropagation: jest.fn() }); + + expect(deps.plugin.deleteConversation).toHaveBeenCalledWith('conv-1'); + }); + + it('should delete non-current conversation without calling loadActive', async () => { + deps.state.currentConversationId = 'conv-1'; + + (deps.plugin.getConversationList as jest.Mock).mockReturnValue([ + { id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 }, + { id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 }, + ]); + + controller.updateHistoryDropdown(); + + const list = dropdown.children[1]; + const otherItem = list.children[1]; // conv-2 + const deleteBtn = otherItem.querySelector('.claudian-delete-btn'); + const clickHandlers = deleteBtn!._eventListeners?.get('click'); + + await clickHandlers![0]({ stopPropagation: jest.fn() }); + + expect(deps.plugin.deleteConversation).toHaveBeenCalledWith('conv-2'); + // Should not have called switchConversation (which is used in loadActive path) + // The key check is that deleteConversation was called with conv-2 + }); + }); + + describe('loadActive with greeting', () => { + it('should show welcome and return early when no conversation exists', async () => { + deps.state.currentConversationId = null; + + await controller.loadActive(); + + const welcomeEl = deps.getWelcomeEl(); + expect(welcomeEl?.style.display).not.toBe('none'); + }); + }); + + describe('Greeting Time Branches', () => { + it.each([ + { name: 'morning (5-12)', hour: 9, day: 1, patterns: ['morning', 'Coffee'] }, + { name: 'afternoon (12-18)', hour: 14, day: 2, patterns: ['afternoon'] }, + { name: 'evening (18-22)', hour: 20, day: 3, patterns: ['evening', 'Evening', 'your day'] }, + { name: 'night owl (22+)', hour: 23, day: 4, patterns: ['night owl', 'Evening'] }, + { name: 'early morning night owl (0-4)', hour: 2, day: 0, patterns: ['night owl', 'Evening'] }, + ])('should include $name greetings', ({ hour, day, patterns }) => { + jest.spyOn(Date.prototype, 'getHours').mockReturnValue(hour); + jest.spyOn(Date.prototype, 'getDay').mockReturnValue(day); + + const greetings = new Set(); + for (let i = 0; i < 50; i++) { + jest.spyOn(Math, 'random').mockReturnValue(i / 50); + greetings.add(controller.getGreeting()); + } + + const hasTimeBased = [...greetings].some(g => + patterns.some(p => g.includes(p)) + ); + expect(hasTimeBased).toBe(true); + + jest.restoreAllMocks(); + }); + }); +}); + +describe('ConversationController - Callbacks', () => { + it('should call onNewConversation callback', async () => { + const onNewConversation = jest.fn(); + const deps = createMockDeps(); + const controller = new ConversationController(deps, { onNewConversation }); + + await controller.createNew(); + + expect(onNewConversation).toHaveBeenCalled(); + }); + + it('should call onConversationSwitched callback', async () => { + const onConversationSwitched = jest.fn(); + const deps = createMockDeps(); + deps.state.currentConversationId = 'old-conv'; + const controller = new ConversationController(deps, { onConversationSwitched }); + + await controller.switchTo('new-conv'); + + expect(onConversationSwitched).toHaveBeenCalled(); + }); + + it('should call onConversationLoaded callback', async () => { + const onConversationLoaded = jest.fn(); + const deps = createMockDeps(); + const controller = new ConversationController(deps, { onConversationLoaded }); + + await controller.loadActive(); + + expect(onConversationLoaded).toHaveBeenCalled(); + }); +}); + +describe('ConversationController - Title Generation', () => { + let controller: ConversationController; + let deps: ConversationControllerDeps; + let mockTitleService: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockTitleService = { + generateTitle: jest.fn().mockResolvedValue(undefined), + cancel: jest.fn(), + }; + deps = createMockDeps({ + getTitleGenerationService: () => mockTitleService, + }); + controller = new ConversationController(deps); + }); + + describe('regenerateTitle', () => { + it('should not regenerate if titleService is null', async () => { + const depsNoService = createMockDeps({ + getTitleGenerationService: () => null, + }); + const controllerNoService = new ConversationController(depsNoService); + + (depsNoService.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ + id: 'conv-1', + title: 'Old Title', + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + ], + }); + + await controllerNoService.regenerateTitle('conv-1'); + + expect(depsNoService.plugin.updateConversation).not.toHaveBeenCalled(); + }); + + it('should not regenerate if enableAutoTitleGeneration is false', async () => { + deps.plugin.settings.enableAutoTitleGeneration = false; + (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ + id: 'conv-1', + title: 'Old Title', + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + ], + }); + + await controller.regenerateTitle('conv-1'); + + expect(mockTitleService.generateTitle).not.toHaveBeenCalled(); + expect(deps.plugin.updateConversation).not.toHaveBeenCalled(); + + deps.plugin.settings.enableAutoTitleGeneration = true; + }); + + it('should not regenerate if conversation not found', async () => { + (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue(null); + + await controller.regenerateTitle('non-existent'); + + expect(mockTitleService.generateTitle).not.toHaveBeenCalled(); + }); + + it('should not regenerate if conversation has no messages', async () => { + (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ + id: 'conv-1', + title: 'Title', + messages: [], + }); + + await controller.regenerateTitle('conv-1'); + + expect(mockTitleService.generateTitle).not.toHaveBeenCalled(); + }); + + it('should not regenerate if no user message found', async () => { + (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ + id: 'conv-1', + title: 'Title', + messages: [ + { role: 'assistant', content: 'Hi' }, + { role: 'assistant', content: 'There' }, + ], + }); + + await controller.regenerateTitle('conv-1'); + + expect(mockTitleService.generateTitle).not.toHaveBeenCalled(); + }); + + it('should set pending status before generating', async () => { + (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ + id: 'conv-1', + title: 'Old Title', + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' }, + ], + }); + + await controller.regenerateTitle('conv-1'); + + expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { + titleGenerationStatus: 'pending', + }); + }); + + it('should call titleService.generateTitle with correct params', async () => { + (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ + id: 'conv-1', + title: 'Old Title', + messages: [ + { role: 'user', content: 'Hello world', displayContent: 'Hello world!' }, + { role: 'assistant', content: 'Hi there!' }, + ], + }); + + await controller.regenerateTitle('conv-1'); + + expect(mockTitleService.generateTitle).toHaveBeenCalledWith( + 'conv-1', + 'Hello world!', // Uses displayContent + expect.any(Function) + ); + }); + + it('should regenerate title with only user message (no assistant yet)', async () => { + (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ + id: 'conv-1', + title: 'Old Title', + messages: [{ role: 'user', content: 'Hello world' }], + }); + + await controller.regenerateTitle('conv-1'); + + expect(mockTitleService.generateTitle).toHaveBeenCalledWith( + 'conv-1', + 'Hello world', + expect.any(Function) + ); + }); + + it('should rename conversation with generated title', async () => { + (deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({ + id: 'conv-1', + title: 'Old Title', + messages: [ + { role: 'user', content: 'Create a plan' }, + { role: 'assistant', content: 'Here is the plan...' }, + ], + }); + + mockTitleService.generateTitle.mockImplementation( + async (convId: string, _user: string, callback: any) => { + await callback(convId, { success: true, title: 'New Generated Title' }); + } + ); + + (deps.plugin.renameConversation as any) = jest.fn().mockResolvedValue(undefined); + + await controller.regenerateTitle('conv-1'); + + expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'New Generated Title'); + }); + }); + + describe('generateFallbackTitle', () => { + it('should generate title from first sentence', () => { + const title = controller.generateFallbackTitle('How do I set up React? I need help.'); + + expect(title).toBe('How do I set up React'); + }); + + it('should truncate long titles to 50 chars', () => { + const longMessage = 'A'.repeat(100); + const title = controller.generateFallbackTitle(longMessage); + + expect(title.length).toBeLessThanOrEqual(53); // 50 + '...' + expect(title).toContain('...'); + }); + + it('should handle messages with no sentence breaks', () => { + const title = controller.generateFallbackTitle('Hello world'); + + expect(title).toBe('Hello world'); + }); + }); +}); + +describe('ConversationController - MCP Server Persistence', () => { + let controller: ConversationController; + let deps: ConversationControllerDeps; + let mockMcpServerSelector: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockMcpServerSelector = { + clearEnabled: jest.fn(), + getEnabledServers: jest.fn().mockReturnValue(new Set(['mcp-server-1', 'mcp-server-2'])), + setEnabledServers: jest.fn(), + }; + deps = createMockDeps({ + getMcpServerSelector: () => mockMcpServerSelector, + }); + controller = new ConversationController(deps); + }); + + describe('save', () => { + it('should save enabled MCP servers to conversation', async () => { + deps.state.currentConversationId = 'conv-1'; + + await controller.save(); + + expect(deps.plugin.updateConversation).toHaveBeenCalledWith( + 'conv-1', + expect.objectContaining({ + enabledMcpServers: ['mcp-server-1', 'mcp-server-2'], + }) + ); + }); + + it('should save undefined when no MCP servers enabled', async () => { + mockMcpServerSelector.getEnabledServers.mockReturnValue(new Set()); + deps.state.currentConversationId = 'conv-1'; + + await controller.save(); + + expect(deps.plugin.updateConversation).toHaveBeenCalledWith( + 'conv-1', + expect.objectContaining({ + enabledMcpServers: undefined, + }) + ); + }); + }); + + describe('loadActive', () => { + it('should restore enabled MCP servers from conversation', async () => { + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: 'conv-1', + messages: [], + sessionId: null, + enabledMcpServers: ['restored-server-1', 'restored-server-2'], + }); + + await controller.loadActive(); + + expect(mockMcpServerSelector.setEnabledServers).toHaveBeenCalledWith([ + 'restored-server-1', + 'restored-server-2', + ]); + }); + + it('should clear MCP servers when conversation has none', async () => { + deps.state.currentConversationId = 'conv-1'; + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: 'conv-1', + messages: [], + sessionId: null, + enabledMcpServers: undefined, + }); + + await controller.loadActive(); + + expect(mockMcpServerSelector.clearEnabled).toHaveBeenCalled(); + }); + }); + + describe('switchTo', () => { + it('should restore enabled MCP servers when switching conversations', async () => { + deps.state.currentConversationId = 'old-conv'; + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ + id: 'new-conv', + providerId: 'claude', + messages: [], + sessionId: null, + enabledMcpServers: ['switched-server'], + }); + + await controller.switchTo('new-conv'); + + expect(mockMcpServerSelector.setEnabledServers).toHaveBeenCalledWith(['switched-server']); + }); + + it('should clear MCP servers when switching to conversation with no servers', async () => { + deps.state.currentConversationId = 'old-conv'; + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ + id: 'new-conv', + providerId: 'claude', + messages: [], + sessionId: null, + enabledMcpServers: undefined, + }); + + await controller.switchTo('new-conv'); + + expect(mockMcpServerSelector.clearEnabled).toHaveBeenCalled(); + }); + + it('should ensure the tab service matches the switched conversation provider', async () => { + const ensureServiceForConversation = jest.fn().mockResolvedValue(undefined); + const switchedConversation = { + id: 'new-conv', + providerId: 'codex', + title: 'Codex Conversation', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + deps = createMockDeps({ + ensureServiceForConversation, + plugin: { + ...createMockDeps().plugin, + switchConversation: jest.fn().mockResolvedValue(switchedConversation), + } as any, + }); + controller = new ConversationController(deps); + deps.state.currentConversationId = 'old-conv'; + + await controller.switchTo('new-conv'); + + expect(ensureServiceForConversation).toHaveBeenCalledWith(switchedConversation); + }); + }); + + describe('createNew', () => { + it('should clear enabled MCP servers for new conversation', async () => { + await controller.createNew(); + + expect(mockMcpServerSelector.clearEnabled).toHaveBeenCalled(); + }); + }); +}); + +describe('ConversationController - Race Condition Guards', () => { + let controller: ConversationController; + let deps: ConversationControllerDeps; + + beforeEach(() => { + jest.clearAllMocks(); + deps = createMockDeps(); + controller = new ConversationController(deps); + }); + + describe('createNew guards', () => { + it('should not create when isCreatingConversation is already true', async () => { + deps.state.isCreatingConversation = true; + + await controller.createNew(); + + expect(deps.plugin.createConversation).not.toHaveBeenCalled(); + expect(deps.plugin.switchConversation).not.toHaveBeenCalled(); + }); + + it('should not create when isSwitchingConversation is true', async () => { + deps.state.isSwitchingConversation = true; + + await controller.createNew(); + + expect(deps.plugin.createConversation).not.toHaveBeenCalled(); + }); + + it('should reset even when streaming if force is true', async () => { + deps.state.isStreaming = true; + deps.state.cancelRequested = false; + const initialGeneration = deps.state.streamGeneration; + + await controller.createNew({ force: true }); + + expect(deps.state.isStreaming).toBe(false); + expect(deps.state.cancelRequested).toBe(true); + expect(deps.state.streamGeneration).toBe(initialGeneration + 1); + expect(deps.state.currentConversationId).toBeNull(); + }); + + it('should set and reset isCreatingConversation flag during entry point reset', async () => { + // Entry point model: createNew() just resets state, doesn't create conversation + // But isCreatingConversation flag should still be set during the reset + let flagDuringExecution = false; + + deps.state.clearMessages = jest.fn(() => { + flagDuringExecution = deps.state.isCreatingConversation; + }); + + await controller.createNew(); + + expect(flagDuringExecution).toBe(true); + expect(deps.state.isCreatingConversation).toBe(false); + }); + }); + + describe('switchTo guards', () => { + it('should not switch when isSwitchingConversation is already true', async () => { + deps.state.currentConversationId = 'old-conv'; + deps.state.isSwitchingConversation = true; + + await controller.switchTo('new-conv'); + + expect(deps.plugin.switchConversation).not.toHaveBeenCalled(); + }); + + it('should not switch when isCreatingConversation is true', async () => { + deps.state.currentConversationId = 'old-conv'; + deps.state.isCreatingConversation = true; + + await controller.switchTo('new-conv'); + + expect(deps.plugin.switchConversation).not.toHaveBeenCalled(); + }); + + it('should reset isSwitchingConversation flag even on error', async () => { + deps.state.currentConversationId = 'old-conv'; + (deps.plugin.switchConversation as jest.Mock).mockRejectedValue(new Error('Switch failed')); + + await expect(controller.switchTo('new-conv')).rejects.toThrow('Switch failed'); + + expect(deps.state.isSwitchingConversation).toBe(false); + }); + + it('should reset isSwitchingConversation flag when conversation not found', async () => { + deps.state.currentConversationId = 'old-conv'; + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue(null); + + await controller.switchTo('non-existent'); + + expect(deps.state.isSwitchingConversation).toBe(false); + }); + + it('should set isSwitchingConversation flag during switch', async () => { + deps.state.currentConversationId = 'old-conv'; + let flagDuringSwitch = false; + (deps.plugin.switchConversation as jest.Mock).mockImplementation(async () => { + flagDuringSwitch = deps.state.isSwitchingConversation; + return { + id: 'new-conv', + title: 'New Conversation', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + }); + + await controller.switchTo('new-conv'); + + expect(flagDuringSwitch).toBe(true); + expect(deps.state.isSwitchingConversation).toBe(false); + }); + }); + + describe('mutual exclusion', () => { + it('should prevent createNew during switchTo', async () => { + deps.state.currentConversationId = 'old-conv'; + + // Simulate switchTo in progress + let switchPromiseResolve: () => void; + const switchPromise = new Promise((resolve) => { + switchPromiseResolve = resolve; + }); + + (deps.plugin.switchConversation as jest.Mock).mockImplementation(async () => { + // During switch, try to createNew + const createPromise = controller.createNew(); + + // createNew should be blocked because isSwitchingConversation is true + expect(deps.plugin.createConversation).not.toHaveBeenCalled(); + + switchPromiseResolve!(); + await createPromise; + + return { + id: 'new-conv', + messages: [], + sessionId: null, + }; + }); + + await controller.switchTo('new-conv'); + await switchPromise; + + expect(deps.plugin.createConversation).not.toHaveBeenCalled(); + }); + }); +}); + +describe('ConversationController - Persistent External Context Paths', () => { + let controller: ConversationController; + let deps: ConversationControllerDeps; + let mockExternalContextSelector: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockExternalContextSelector = { + getExternalContexts: jest.fn().mockReturnValue([]), + setExternalContexts: jest.fn(), + clearExternalContexts: jest.fn(), + }; + deps = createMockDeps({ + getExternalContextSelector: () => mockExternalContextSelector, + }); + (deps.plugin.settings as any).persistentExternalContextPaths = ['/persistent/path/a', '/persistent/path/b']; + controller = new ConversationController(deps); + }); + + describe('createNew', () => { + it('should call clearExternalContexts with persistent paths from settings', async () => { + await controller.createNew(); + + expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith( + ['/persistent/path/a', '/persistent/path/b'] + ); + }); + + it('should call clearExternalContexts with empty array if no persistent paths', async () => { + (deps.plugin.settings as any).persistentExternalContextPaths = undefined; + + await controller.createNew(); + + expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith([]); + }); + }); + + describe('loadActive', () => { + it('should use persistent paths for new conversation (no existing conversation)', async () => { + deps.state.currentConversationId = null; + + await controller.loadActive(); + + expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith( + ['/persistent/path/a', '/persistent/path/b'] + ); + }); + + it('should use persistent paths for empty conversation (msg=0)', async () => { + deps.state.currentConversationId = 'existing-conv'; + deps.plugin.getConversationById = jest.fn().mockResolvedValue({ + id: 'existing-conv', + messages: [], + sessionId: null, + }); + + await controller.loadActive(); + + expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith( + ['/persistent/path/a', '/persistent/path/b'] + ); + }); + + it('should restore saved paths for conversation with messages (msg>0)', async () => { + deps.state.currentConversationId = 'existing-conv'; + deps.plugin.getConversationById = jest.fn().mockResolvedValue({ + id: 'existing-conv', + messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], + sessionId: null, + externalContextPaths: ['/saved/path'], + }); + + await controller.loadActive(); + + expect(mockExternalContextSelector.setExternalContexts).toHaveBeenCalledWith(['/saved/path']); + expect(mockExternalContextSelector.clearExternalContexts).not.toHaveBeenCalled(); + }); + + it('should restore empty paths for conversation with messages but no saved paths', async () => { + deps.state.currentConversationId = 'existing-conv'; + deps.plugin.getConversationById = jest.fn().mockResolvedValue({ + id: 'existing-conv', + messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], + sessionId: null, + externalContextPaths: undefined, + }); + + await controller.loadActive(); + + expect(mockExternalContextSelector.setExternalContexts).toHaveBeenCalledWith([]); + }); + }); + + describe('switchTo', () => { + beforeEach(() => { + deps.state.currentConversationId = 'old-conv'; + }); + + it('should use persistent paths when switching to empty conversation (msg=0)', async () => { + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ + id: 'empty-conv', + messages: [], + sessionId: null, + externalContextPaths: ['/old/saved/path'], + }); + + await controller.switchTo('empty-conv'); + + expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith( + ['/persistent/path/a', '/persistent/path/b'] + ); + expect(mockExternalContextSelector.setExternalContexts).not.toHaveBeenCalled(); + }); + + it('should restore saved paths when switching to conversation with messages', async () => { + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ + id: 'conv-with-messages', + messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], + sessionId: null, + externalContextPaths: ['/saved/path/from/session'], + }); + + await controller.switchTo('conv-with-messages'); + + expect(mockExternalContextSelector.setExternalContexts).toHaveBeenCalledWith( + ['/saved/path/from/session'] + ); + expect(mockExternalContextSelector.clearExternalContexts).not.toHaveBeenCalled(); + }); + + it('should restore empty array for conversation with messages but no saved paths', async () => { + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ + id: 'conv-with-messages', + messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], + sessionId: null, + externalContextPaths: undefined, + }); + + await controller.switchTo('conv-with-messages'); + + expect(mockExternalContextSelector.setExternalContexts).toHaveBeenCalledWith([]); + }); + }); + + describe('Scenario: Adding persistent paths across sessions', () => { + it('should show all persistent paths when returning to empty session', async () => { + // Scenario: + // 1. User is in session 0 (empty), adds path A as persistent + // 2. User switches to session 1 (with messages), adds path B as persistent + // 3. User returns to session 0 (empty) - should see both A and B + + // Step 1: Session 0 is empty, persistent paths = [A] + (deps.plugin.settings as any).persistentExternalContextPaths = ['/path/a']; + deps.state.currentConversationId = null; + await controller.loadActive(); + + expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith(['/path/a']); + + // Step 2: User switches to session 1 and adds path B, settings now have [A, B] + deps.state.currentConversationId = 'session-0'; // Currently in session 0 + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ + id: 'session-1', + messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], + sessionId: null, + externalContextPaths: [], + }); + await controller.switchTo('session-1'); + + // User adds path B in session 1, settings now have [A, B] + (deps.plugin.settings as any).persistentExternalContextPaths = ['/path/a', '/path/b']; + + // Step 3: User returns to session 0 (empty) + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ + id: 'session-0', + messages: [], // Empty session + sessionId: null, + externalContextPaths: ['/path/a'], // Only had A when originally created + }); + + jest.clearAllMocks(); + await controller.switchTo('session-0'); + + // Should get BOTH paths because session is empty (msg=0) + expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith( + ['/path/a', '/path/b'] + ); + }); + }); +}); + +function createMockBuildSessionUpdates(mockService: any) { + return jest.fn().mockImplementation(({ conversation, sessionInvalidated }: any) => { + const sessionId = mockService.getSessionId(); + const legacyMessages = conversation?.messages ?? []; + const hasSession = !!sessionId; + const legacyCutoffAt = hasSession && !conversation?.providerSessionId + ? legacyMessages[legacyMessages.length - 1]?.timestamp + : conversation?.legacyCutoffAt; + const oldSdkSessionId = conversation?.providerSessionId; + const sessionChanged = hasSession && sessionId && oldSdkSessionId && sessionId !== oldSdkSessionId; + const previousProviderSessionIds = sessionChanged + ? [...new Set([...(conversation?.previousProviderSessionIds || []), oldSdkSessionId])] + : conversation?.previousProviderSessionIds; + const isForkSourceOnly = !!conversation?.forkSource && + !conversation?.providerSessionId && + sessionId === conversation.forkSource.sessionId; + let resolvedSessionId: string | null; + if (sessionInvalidated) { + resolvedSessionId = null; + } else if (isForkSourceOnly) { + resolvedSessionId = conversation?.sessionId ?? null; + } else { + resolvedSessionId = sessionId ?? conversation?.sessionId ?? null; + } + const updates: any = { + sessionId: resolvedSessionId, + providerSessionId: hasSession && sessionId && !isForkSourceOnly ? sessionId : conversation?.providerSessionId, + previousProviderSessionIds, + legacyCutoffAt, + }; + if (conversation?.forkSource && sessionId && sessionId !== conversation.forkSource.sessionId) { + updates.forkSource = undefined; + } + return { updates }; + }); +} + +describe('ConversationController - Previous SDK Session IDs', () => { + let controller: ConversationController; + let deps: ConversationControllerDeps; + let mockAgentService: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockAgentService = { + getSessionId: jest.fn().mockReturnValue(null), + setSessionId: jest.fn(), + consumeSessionInvalidation: jest.fn().mockReturnValue(false), + buildSessionUpdates: null as any, + }; + mockAgentService.buildSessionUpdates = createMockBuildSessionUpdates(mockAgentService); + deps = createMockDeps({ + getAgentService: () => mockAgentService, + }); + controller = new ConversationController(deps); + }); + + describe('save - session change detection', () => { + it('should accumulate old providerSessionId when SDK creates new session', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + + // Existing conversation has providerSessionId 'session-A' + (deps.plugin.getConversationSync as jest.Mock).mockReturnValue({ + id: 'conv-1', + messages: [], + providerSessionId: 'session-A', + previousProviderSessionIds: undefined, + }); + + // Agent service reports new session 'session-B' (resume failed, new session created) + mockAgentService.getSessionId.mockReturnValue('session-B'); + + await controller.save(); + + expect(deps.plugin.updateConversation).toHaveBeenCalledWith( + 'conv-1', + expect.objectContaining({ + providerSessionId: 'session-B', + previousProviderSessionIds: ['session-A'], + }) + ); + }); + + it('should preserve existing previousProviderSessionIds when session changes again', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + + // Conversation already has previous sessions [A], current is B + (deps.plugin.getConversationSync as jest.Mock).mockReturnValue({ + id: 'conv-1', + messages: [], + providerSessionId: 'session-B', + previousProviderSessionIds: ['session-A'], + }); + + // Agent service reports new session 'session-C' + mockAgentService.getSessionId.mockReturnValue('session-C'); + + await controller.save(); + + expect(deps.plugin.updateConversation).toHaveBeenCalledWith( + 'conv-1', + expect.objectContaining({ + providerSessionId: 'session-C', + previousProviderSessionIds: ['session-A', 'session-B'], + }) + ); + }); + + it('should not modify previousProviderSessionIds when session has not changed', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + + (deps.plugin.getConversationSync as jest.Mock).mockReturnValue({ + id: 'conv-1', + messages: [], + providerSessionId: 'session-A', + previousProviderSessionIds: undefined, + }); + + mockAgentService.getSessionId.mockReturnValue('session-A'); + + await controller.save(); + + expect(deps.plugin.updateConversation).toHaveBeenCalledWith( + 'conv-1', + expect.objectContaining({ + providerSessionId: 'session-A', + previousProviderSessionIds: undefined, + }) + ); + }); + + it('should deduplicate session IDs to prevent duplicates from race conditions', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + + // Simulate a race condition where session-A is already in previousProviderSessionIds + // but providerSessionId is still session-A (should not duplicate) + (deps.plugin.getConversationSync as jest.Mock).mockReturnValue({ + id: 'conv-1', + messages: [], + providerSessionId: 'session-A', + previousProviderSessionIds: ['session-A'], // Already contains A (from prior bug/race) + }); + + // Agent reports new session-B + mockAgentService.getSessionId.mockReturnValue('session-B'); + + await controller.save(); + + // Should deduplicate: [A, A] -> [A] + expect(deps.plugin.updateConversation).toHaveBeenCalledWith( + 'conv-1', + expect.objectContaining({ + providerSessionId: 'session-B', + previousProviderSessionIds: ['session-A'], // Deduplicated, not ['session-A', 'session-A'] + }) + ); + }); + }); +}); + +describe('ConversationController - Fork Session ID Isolation', () => { + let controller: ConversationController; + let deps: ConversationControllerDeps; + let mockAgentService: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockAgentService = { + getSessionId: jest.fn().mockReturnValue(null), + setSessionId: jest.fn(), + consumeSessionInvalidation: jest.fn().mockReturnValue(false), + buildSessionUpdates: null as any, + }; + mockAgentService.buildSessionUpdates = createMockBuildSessionUpdates(mockAgentService); + deps = createMockDeps({ + getAgentService: () => mockAgentService, + }); + controller = new ConversationController(deps); + }); + + it('should not persist fork source session ID as conversation own sessionId/providerSessionId', async () => { + deps.state.currentConversationId = 'fork-conv'; + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + + // Fork conversation: has forkSource but no own providerSessionId yet + (deps.plugin.getConversationSync as jest.Mock).mockReturnValue({ + id: 'fork-conv', + messages: [], + sessionId: null, + providerSessionId: undefined, + forkSource: { sessionId: 'source-session-abc', resumeAt: 'assistant-uuid-1' }, + }); + + // Agent service has the fork source ID set for resume purposes + mockAgentService.getSessionId.mockReturnValue('source-session-abc'); + + await controller.save(); + + expect(deps.plugin.updateConversation).toHaveBeenCalledWith( + 'fork-conv', + expect.objectContaining({ + sessionId: null, + providerSessionId: undefined, + }) + ); + }); + + it('should persist new session ID after SDK captures a different session for fork', async () => { + deps.state.currentConversationId = 'fork-conv'; + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + + (deps.plugin.getConversationSync as jest.Mock).mockReturnValue({ + id: 'fork-conv', + messages: [], + sessionId: null, + providerSessionId: undefined, + forkSource: { sessionId: 'source-session-abc', resumeAt: 'assistant-uuid-1' }, + }); + + // SDK captured a new session (different from fork source) + mockAgentService.getSessionId.mockReturnValue('new-session-xyz'); + + await controller.save(); + + expect(deps.plugin.updateConversation).toHaveBeenCalledWith( + 'fork-conv', + expect.objectContaining({ + sessionId: 'new-session-xyz', + providerSessionId: 'new-session-xyz', + forkSource: undefined, + }) + ); + }); + + it('should allow normal session ID persistence when fork metadata is already cleared', async () => { + deps.state.currentConversationId = 'fork-conv'; + deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }]; + + // Fork conversation after fork metadata was cleared (has its own providerSessionId) + (deps.plugin.getConversationSync as jest.Mock).mockReturnValue({ + id: 'fork-conv', + messages: [], + sessionId: 'new-session-xyz', + providerSessionId: 'new-session-xyz', + forkSource: undefined, + }); + + mockAgentService.getSessionId.mockReturnValue('new-session-xyz'); + + await controller.save(); + + expect(deps.plugin.updateConversation).toHaveBeenCalledWith( + 'fork-conv', + expect.objectContaining({ + sessionId: 'new-session-xyz', + providerSessionId: 'new-session-xyz', + }) + ); + }); +}); + +describe('ConversationController - switchTo fork path', () => { + let controller: ConversationController; + let deps: ConversationControllerDeps; + let mockAgentService: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockAgentService = { + getSessionId: jest.fn().mockReturnValue(null), + syncConversationState: jest.fn(), + consumeSessionInvalidation: jest.fn().mockReturnValue(false), + buildSessionUpdates: null as any, + }; + mockAgentService.buildSessionUpdates = createMockBuildSessionUpdates(mockAgentService); + deps = createMockDeps({ + getAgentService: () => mockAgentService, + }); + controller = new ConversationController(deps); + }); + + it('should sync conversation state for pending fork conversations', async () => { + deps.state.currentConversationId = 'old-conv'; + + const forkConversation = { + id: 'fork-conv', + messages: [{ id: '1', role: 'user', content: 'forked msg', timestamp: Date.now() }], + sessionId: null, + providerSessionId: undefined, + forkSource: { sessionId: 'source-session-abc', resumeAt: 'assistant-uuid-1' }, + }; + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue(forkConversation); + + await controller.switchTo('fork-conv'); + + expect(mockAgentService.syncConversationState).toHaveBeenCalledWith( + forkConversation, + expect.any(Array), + ); + }); + + it('should resolve to own sessionId when fork already has its own session', async () => { + deps.state.currentConversationId = 'old-conv'; + + const forkConversation = { + id: 'fork-conv', + messages: [{ id: '1', role: 'user', content: 'forked msg', timestamp: Date.now() }], + sessionId: 'own-session-xyz', + providerSessionId: 'own-session-xyz', + forkSource: { sessionId: 'source-session-abc', resumeAt: 'assistant-uuid-1' }, + }; + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue(forkConversation); + + await controller.switchTo('fork-conv'); + + expect(mockAgentService.syncConversationState).toHaveBeenCalledWith( + forkConversation, + expect.any(Array), + ); + }); +}); + +describe('ConversationController - restoreExternalContextPaths null selector', () => { + it('should return early when external context selector is null', async () => { + const deps = createMockDeps({ + getExternalContextSelector: () => null, + }); + const controller = new ConversationController(deps); + + deps.state.currentConversationId = 'old-conv'; + (deps.plugin.switchConversation as jest.Mock).mockResolvedValue({ + id: 'new-conv', + messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }], + sessionId: null, + externalContextPaths: ['/some/path'], + }); + + // Should not throw even though selector is null + await expect(controller.switchTo('new-conv')).resolves.not.toThrow(); + }); +}); + +describe('ConversationController - regenerateTitle callback branches', () => { + let controller: ConversationController; + let deps: ConversationControllerDeps; + let mockTitleService: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockTitleService = { + generateTitle: jest.fn().mockResolvedValue(undefined), + cancel: jest.fn(), + }; + deps = createMockDeps({ + getTitleGenerationService: () => mockTitleService, + }); + controller = new ConversationController(deps); + }); + + it('should mark as failed when generation fails and user has not renamed', async () => { + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: 'conv-1', + title: 'Original Title', + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi!' }, + ], + }); + + mockTitleService.generateTitle.mockImplementation( + async (_convId: string, _user: string, callback: any) => { + // On callback, getConversationById returns same title (user didn't rename) + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: 'conv-1', + title: 'Original Title', + messages: [], + }); + await callback('conv-1', { success: false, title: '' }); + } + ); + + await controller.regenerateTitle('conv-1'); + + expect(deps.plugin.renameConversation).not.toHaveBeenCalled(); + expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { + titleGenerationStatus: 'failed', + }); + }); + + it('should clear status when user manually renamed during generation', async () => { + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: 'conv-1', + title: 'Original Title', + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi!' }, + ], + }); + + // Simulate callback where user has renamed the conversation + mockTitleService.generateTitle.mockImplementation( + async (_convId: string, _user: string, callback: any) => { + // On callback, getConversationById returns a different title (user renamed) + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: 'conv-1', + title: 'User Renamed Title', + messages: [], + }); + await callback('conv-1', { success: true, title: 'AI Generated Title' }); + } + ); + + await controller.regenerateTitle('conv-1'); + + // Should NOT rename because user already renamed + expect(deps.plugin.renameConversation).not.toHaveBeenCalled(); + // Should clear the status since user's choice takes precedence + expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { + titleGenerationStatus: undefined, + }); + }); + + it('should not apply title when conversation no longer exists during callback', async () => { + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: 'conv-1', + title: 'Original Title', + messages: [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi!' }, + ], + }); + + // Simulate callback where conversation was deleted + mockTitleService.generateTitle.mockImplementation( + async (_convId: string, _user: string, callback: any) => { + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue(null); + await callback('conv-1', { success: true, title: 'New Title' }); + } + ); + + await controller.regenerateTitle('conv-1'); + + expect(deps.plugin.renameConversation).not.toHaveBeenCalled(); + }); +}); + +describe('ConversationController - Rewind', () => { + let controller: ConversationController; + let deps: ConversationControllerDeps; + let mockAgentService: any; + + beforeEach(() => { + jest.clearAllMocks(); + mockAgentService = { + getSessionId: jest.fn().mockReturnValue(null), + setSessionId: jest.fn(), + consumeSessionInvalidation: jest.fn().mockReturnValue(false), + rewind: jest.fn().mockResolvedValue({ canRewind: true, filesChanged: ['a.ts'] }), + getCapabilities: jest.fn().mockReturnValue({ supportsRewind: true }), + buildSessionUpdates: null as any, + }; + mockAgentService.buildSessionUpdates = createMockBuildSessionUpdates(mockAgentService); + deps = createMockDeps({ + getAgentService: () => mockAgentService, + }); + controller = new ConversationController(deps); + }); + + it('should find prev/response assistants with bounded scan (skipping non-uuid messages)', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [ + { id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'prev-a' }, + { id: 'm2', role: 'assistant', content: 'boundary', timestamp: 2 }, // No uuid + { id: 'm3', role: 'user', content: 'test', timestamp: 3, userMessageId: 'user-uuid' }, + { id: 'm4', role: 'assistant', content: 'boundary2', timestamp: 4 }, // No uuid + { id: 'm5', role: 'assistant', content: 'resp', timestamp: 5, assistantMessageId: 'resp-a' }, + ]; + + await controller.rewind('m3'); + + expect(mockAgentService.rewind).toHaveBeenCalledWith('user-uuid', 'prev-a', 'code-and-conversation'); + }); + + it('should show Notice when message ID not found', async () => { + deps.state.messages = [ + { id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' }, + { id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' }, + { id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' }, + ]; + + await controller.rewind('nonexistent'); + + expect(mockNotice).toHaveBeenCalled(); + expect(mockAgentService.rewind).not.toHaveBeenCalled(); + }); + + it('should show Notice when streaming', async () => { + deps.state.isStreaming = true; + deps.state.messages = [ + { id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' }, + { id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' }, + { id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' }, + ]; + + await controller.rewind('m2'); + + expect(mockNotice).toHaveBeenCalled(); + expect(mockAgentService.rewind).not.toHaveBeenCalled(); + }); + + it('should show Notice when user message has no userMessageId', async () => { + deps.state.messages = [ + { id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' }, + { id: 'm2', role: 'user', content: 'test', timestamp: 2 }, // No userMessageId + { id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' }, + ]; + + await controller.rewind('m2'); + + expect(mockNotice).toHaveBeenCalled(); + expect(mockAgentService.rewind).not.toHaveBeenCalled(); + }); + + it('should allow rewind when no previous assistant with uuid exists', async () => { + deps.state.messages = [ + { id: 'm1', role: 'user', content: 'test', timestamp: 1, userMessageId: 'u1' }, + { id: 'm2', role: 'assistant', content: '', timestamp: 2, assistantMessageId: 'a1' }, + ]; + + await controller.rewind('m1'); + + expect(mockAgentService.rewind).toHaveBeenCalledWith('u1', undefined, 'code-and-conversation'); + }); + + it('should show Notice when no response assistant with uuid exists', async () => { + deps.state.messages = [ + { id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' }, + { id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' }, + ]; + + await controller.rewind('m2'); + + expect(mockNotice).toHaveBeenCalled(); + expect(mockAgentService.rewind).not.toHaveBeenCalled(); + }); + + it('should show i18n Notice on SDK rewind exception', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [ + { id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' }, + { id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' }, + { id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' }, + ]; + mockAgentService.rewind.mockRejectedValue(new Error('SDK error')); + + await controller.rewind('m2'); + + expect(mockNotice).toHaveBeenCalled(); + const msg = mockNotice.mock.calls[0][0] as string; + expect(msg).toContain('SDK error'); + }); + + it('should show i18n Notice when canRewind is false', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [ + { id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' }, + { id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' }, + { id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' }, + ]; + mockAgentService.rewind.mockResolvedValue({ canRewind: false, error: 'No checkpoints' }); + + await controller.rewind('m2'); + + expect(mockNotice).toHaveBeenCalled(); + const msg = mockNotice.mock.calls[0][0] as string; + expect(msg).toContain('No checkpoints'); + }); + + it('should truncateAt, save with resumeAtMessageId, and renderMessages on success', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [ + { id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'prev-a' }, + { id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'user-uuid' }, + { id: 'm3', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'resp-a' }, + ]; + + const truncateSpy = jest.spyOn(deps.state, 'truncateAt'); + + await controller.rewind('m2'); + + expect(mockAgentService.rewind).toHaveBeenCalledWith('user-uuid', 'prev-a', 'code-and-conversation'); + expect(truncateSpy).toHaveBeenCalledWith('m2'); + expect(deps.renderer.renderMessages).toHaveBeenCalledWith( + expect.any(Array), + expect.any(Function) + ); + expect(deps.plugin.updateConversation).toHaveBeenCalledWith( + 'conv-1', + expect.objectContaining({ resumeAtMessageId: 'prev-a' }) + ); + + // Should populate input with rewound message content + const inputEl = deps.getInputEl(); + expect(inputEl.value).toBe('test'); + expect(inputEl.focus).toHaveBeenCalled(); + + // Should show success notice with file count + const noticeMsg = mockNotice.mock.calls[0][0] as string; + expect(noticeMsg).toContain('1'); + + truncateSpy.mockRestore(); + }); + + it('should rewind to before the first user message and clear provider session state', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [ + { id: 'm1', role: 'user', content: 'first prompt', timestamp: 1, userMessageId: 'user-uuid' }, + { id: 'm2', role: 'assistant', content: 'resp', timestamp: 2, assistantMessageId: 'resp-a' }, + ]; + (deps.plugin.getConversationSync as jest.Mock).mockReturnValue({ + id: 'conv-1', + providerId: 'claude', + sessionId: 'old-session', + providerState: { providerSessionId: 'old-session' }, + messages: deps.state.messages, + }); + + await controller.rewind('m1'); + + expect(mockAgentService.rewind).toHaveBeenCalledWith('user-uuid', undefined, 'code-and-conversation'); + expect(mockAgentService.buildSessionUpdates).not.toHaveBeenCalled(); + expect(deps.state.messages).toEqual([]); + expect(deps.plugin.updateConversation).toHaveBeenCalledWith( + 'conv-1', + expect.objectContaining({ + messages: [], + sessionId: null, + providerState: undefined, + resumeAtMessageId: undefined, + }) + ); + expect(deps.getInputEl().value).toBe('first prompt'); + }); + + it('should pass conversation-only mode and keep file changes', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [ + { id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'prev-a' }, + { id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'user-uuid' }, + { id: 'm3', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'resp-a' }, + ]; + + await controller.rewind('m2', 'conversation'); + + expect(confirm).toHaveBeenCalledWith( + deps.plugin.app, + 'Rewind conversation to this point? File changes will be kept.', + 'Rewind', + ); + expect(mockAgentService.rewind).toHaveBeenCalledWith('user-uuid', 'prev-a', 'conversation'); + expect(deps.plugin.updateConversation).toHaveBeenCalledWith( + 'conv-1', + expect.objectContaining({ resumeAtMessageId: 'prev-a' }) + ); + const noticeMsg = mockNotice.mock.calls[0][0] as string; + expect(noticeMsg).toBe('Rewound conversation; file changes kept'); + }); + + it('should abort when confirmation is declined', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [ + { id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' }, + { id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' }, + { id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' }, + ]; + (confirm as jest.Mock).mockResolvedValueOnce(false); + + await controller.rewind('m2'); + + expect(mockAgentService.rewind).not.toHaveBeenCalled(); + expect(mockNotice).not.toHaveBeenCalled(); + }); + + it('should re-check streaming state after confirmation dialog', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [ + { id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' }, + { id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' }, + { id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' }, + ]; + (confirm as jest.Mock).mockImplementationOnce(async () => { + deps.state.isStreaming = true; + return true; + }); + + await controller.rewind('m2'); + + expect(mockAgentService.rewind).not.toHaveBeenCalled(); + expect(mockNotice).toHaveBeenCalled(); + }); + + it('should show a warning notice when rewind succeeded but save failed', async () => { + deps.state.currentConversationId = 'conv-1'; + deps.state.messages = [ + { id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'prev-a' }, + { id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'user-uuid' }, + { id: 'm3', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'resp-a' }, + ]; + + (deps.plugin.updateConversation as jest.Mock).mockRejectedValueOnce(new Error('Save failed')); + + await controller.rewind('m2'); + + expect(mockAgentService.rewind).toHaveBeenCalledWith('user-uuid', 'prev-a', 'code-and-conversation'); + const msg = mockNotice.mock.calls[0][0] as string; + expect(msg).toContain('Save failed'); + }); + + describe('Inline prompt dismissal', () => { + it('dismisses pending inline prompts during createNew()', async () => { + const dismissFn = jest.fn(); + deps = createMockDeps({ dismissPendingInlinePrompts: dismissFn }); + controller = new ConversationController(deps); + + await controller.createNew(); + + expect(dismissFn).toHaveBeenCalled(); + }); + + it('dismisses pending inline prompts during switchTo()', async () => { + const dismissFn = jest.fn(); + deps = createMockDeps({ dismissPendingInlinePrompts: dismissFn }); + controller = new ConversationController(deps); + deps.state.currentConversationId = 'old-conv'; + + await controller.switchTo('switched-conv'); + + expect(dismissFn).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/features/chat/controllers/InputController.test.ts b/tests/unit/features/chat/controllers/InputController.test.ts new file mode 100644 index 0000000..8cfcda1 --- /dev/null +++ b/tests/unit/features/chat/controllers/InputController.test.ts @@ -0,0 +1,3369 @@ +import { createMockEl } from '@test/helpers/mockElement'; +import { Notice } from 'obsidian'; + +import { InputController, type InputControllerDeps } from '@/features/chat/controllers/InputController'; +import { ChatState } from '@/features/chat/state/ChatState'; +import { encodeClaudeTurn } from '@/providers/claude/prompt/ClaudeTurnEncoder'; +import { ResumeSessionDropdown } from '@/shared/components/ResumeSessionDropdown'; + +jest.mock('@/shared/components/ResumeSessionDropdown', () => ({ + ResumeSessionDropdown: jest.fn(), +})); + +beforeAll(() => { + globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { + cb(0); + return 0; + }; +}); + +const mockNotice = Notice as jest.Mock; + +function createMockInputEl() { + return { + value: '', + focus: jest.fn(), + } as unknown as HTMLTextAreaElement; +} + +function createMockWelcomeEl() { + return createMockEl(); +} + +function createMockFileContextManager() { + return { + startSession: jest.fn(), + getCurrentNotePath: jest.fn().mockReturnValue(null), + shouldSendCurrentNote: jest.fn().mockReturnValue(false), + markCurrentNoteSent: jest.fn(), + transformContextMentions: jest.fn().mockImplementation((text: string) => text), + }; +} + +function createMockImageContextManager() { + return { + hasImages: jest.fn().mockReturnValue(false), + getAttachedImages: jest.fn().mockReturnValue([]), + clearImages: jest.fn(), + setImages: jest.fn(), + }; +} + +async function* createMockStream(chunks: any[]) { + for (const chunk of chunks) { + yield chunk; + } +} + +const mockMcpForEncoder = { + extractMentions: jest.fn().mockReturnValue(new Set()), + transformMentions: jest.fn().mockImplementation((text: string) => text), +}; + +function createMockAgentService() { + return { + providerId: 'claude', + getCapabilities: jest.fn().mockReturnValue({ + providerId: 'claude', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: true, + supportsFork: true, + supportsProviderCommands: true, + supportsTurnSteer: false, + reasoningControl: 'effort', + }), + prepareTurn: jest.fn().mockImplementation((request: any) => + encodeClaudeTurn(request, mockMcpForEncoder), + ), + query: jest.fn(), + steer: jest.fn().mockResolvedValue(true), + cancel: jest.fn(), + resetSession: jest.fn(), + setResumeCheckpoint: jest.fn(), + setApprovedPlanContent: jest.fn(), + setCurrentPlanFilePath: jest.fn(), + getApprovedPlanContent: jest.fn().mockReturnValue(null), + clearApprovedPlanContent: jest.fn(), + ensureReady: jest.fn().mockResolvedValue(true), + getSessionId: jest.fn().mockReturnValue(null), + getAuxiliaryModel: jest.fn().mockReturnValue(null), + consumeTurnMetadata: jest.fn().mockReturnValue({}), + }; +} + +function createMockInstructionRefineService(overrides: Record = {}) { + return { + refineInstruction: jest.fn().mockResolvedValue({ success: true }), + resetConversation: jest.fn(), + continueConversation: jest.fn(), + cancel: jest.fn(), + setModelOverride: jest.fn(), + ...overrides, + }; +} + +function createMockInstructionModeManager() { + return { clear: jest.fn() }; +} + +function createMockDeps(overrides: Partial = {}): InputControllerDeps & { mockAgentService: ReturnType } { + const state = new ChatState(); + const inputEl = createMockInputEl(); + const queueIndicatorEl = createMockEl(); + queueIndicatorEl.style.display = 'none'; + jest.spyOn(queueIndicatorEl, 'setText'); + state.queueIndicatorEl = queueIndicatorEl as any; + + const imageContextManager = createMockImageContextManager(); + const mockAgentService = createMockAgentService(); + const pluginSettings = { + permissionMode: 'yolo', + enableAutoTitleGeneration: true, + }; + const saveSettings = jest.fn(); + + return { + plugin: { + saveSettings, + mutateSettings: jest.fn(async (mutation) => { + await mutation(pluginSettings); + await saveSettings(); + }), + settings: pluginSettings, + mcpManager: { + extractMentions: jest.fn().mockReturnValue(new Set()), + transformMentions: jest.fn().mockImplementation((text: string) => text), + }, + renameConversation: jest.fn(), + updateConversation: jest.fn(), + getConversationSync: jest.fn().mockReturnValue(null), + getConversationById: jest.fn().mockResolvedValue(null), + createConversation: jest.fn().mockResolvedValue({ id: 'conv-1' }), + deleteConversation: jest.fn().mockResolvedValue(undefined), + handleMissingProviderSession: jest.fn().mockResolvedValue('deleted'), + } as any, + state, + renderer: { + addMessage: jest.fn().mockReturnValue({ + querySelector: jest.fn().mockReturnValue(createMockEl()), + }), + refreshActionButtons: jest.fn(), + removeMessage: jest.fn(), + updateLiveUserMessage: jest.fn(), + } as any, + streamController: { + showThinkingIndicator: jest.fn(), + hideThinkingIndicator: jest.fn(), + handleStreamChunk: jest.fn(), + finalizeCurrentTextBlock: jest.fn(), + finalizeCurrentThinkingBlock: jest.fn(), + appendText: jest.fn(), + } as any, + selectionController: { + getContext: jest.fn().mockReturnValue(null), + } as any, + canvasSelectionController: { + getContext: jest.fn().mockReturnValue(null), + } as any, + conversationController: { + save: jest.fn(), + generateFallbackTitle: jest.fn().mockReturnValue('Test Title'), + updateHistoryDropdown: jest.fn(), + clearTerminalSubagentsFromMessages: jest.fn(), + } as any, + getInputEl: () => inputEl, + getInputContainerEl: () => createMockEl() as any, + getWelcomeEl: () => null, + getMessagesEl: () => createMockEl() as any, + getFileContextManager: () => ({ + startSession: jest.fn(), + getCurrentNotePath: jest.fn().mockReturnValue(null), + shouldSendCurrentNote: jest.fn().mockReturnValue(false), + markCurrentNoteSent: jest.fn(), + transformContextMentions: jest.fn().mockImplementation((text: string) => text), + }) as any, + getImageContextManager: () => imageContextManager as any, + getMcpServerSelector: () => null, + getExternalContextSelector: () => null, + getInstructionModeManager: () => null, + getInstructionRefineService: () => null, + getTitleGenerationService: () => null, + getStatusPanel: () => null, + generateId: () => `msg-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, + resetInputHeight: jest.fn(), + getAgentService: () => mockAgentService as any, + getSubagentManager: () => ({ resetSpawnedCount: jest.fn(), resetStreamingState: jest.fn() }) as any, + mockAgentService, + ...overrides, + }; +} + +/** + * Composite helper for tests that need a complete "sendable" deps setup. + * Creates welcomeEl + fileContextManager and sets conversationId by default, + * eliminating the repeated boilerplate in send-path tests. + */ +function createSendableDeps( + overrides: Partial = {}, + conversationId: string | null = 'conv-1', +): InputControllerDeps & { mockAgentService: ReturnType } { + const welcomeEl = createMockWelcomeEl(); + const fileContextManager = createMockFileContextManager(); + const result = createMockDeps({ + getWelcomeEl: () => welcomeEl, + getFileContextManager: () => fileContextManager as any, + ...overrides, + }); + if (conversationId !== null) { + result.state.currentConversationId = conversationId; + } + return result; +} + +describe('InputController - Missing provider session', () => { + it('rolls back the failed turn and restores unsent input after resume state is reset', async () => { + const deps = createSendableDeps(); + const inputEl = deps.getInputEl(); + inputEl.value = 'retry this prompt'; + (deps.plugin.handleMissingProviderSession as jest.Mock).mockResolvedValue('reset'); + deps.mockAgentService.query.mockReturnValue(createMockStream([{ + type: 'error', + content: 'No conversation found with session ID: missing-session', + code: 'provider_session_missing', + }])); + const controller = new InputController(deps); + + await controller.sendMessage(); + + expect(deps.plugin.handleMissingProviderSession).toHaveBeenCalledWith( + 'conv-1', + undefined, + ); + expect(inputEl.value).toBe('retry this prompt'); + expect(deps.state.messages).toEqual([]); + expect(deps.state.isStreaming).toBe(false); + expect(deps.state.hasPendingConversationSave).toBe(false); + expect(deps.streamController.hideThinkingIndicator).toHaveBeenCalled(); + expect(deps.renderer.removeMessage).toHaveBeenCalledTimes(2); + expect(deps.conversationController.save).not.toHaveBeenCalled(); + expect(deps.streamController.handleStreamChunk).not.toHaveBeenCalled(); + }); + + it('restores a programmatic queued turn without overwriting a newer draft', async () => { + const deps = createSendableDeps(); + const inputEl = deps.getInputEl(); + const retryImage = { + id: 'retry-image', + name: 'retry.png', + mediaType: 'image/png' as const, + data: 'cmV0cnk=', + size: 5, + source: 'paste' as const, + }; + inputEl.value = 'newer draft'; + (deps.plugin.handleMissingProviderSession as jest.Mock).mockResolvedValue('reset'); + deps.mockAgentService.query.mockReturnValue(createMockStream([{ + type: 'error', + content: 'No conversation found with session ID: missing-session', + code: 'provider_session_missing', + }])); + + await new InputController(deps).sendMessage({ + content: 'queued prompt', + images: [retryImage], + turnRequestOverride: { text: 'provider-ready prompt' }, + }); + + expect(inputEl.value).toBe('queued prompt\n\nnewer draft'); + expect(deps.getImageContextManager()?.setImages).toHaveBeenCalledWith([retryImage]); + expect(deps.state.messages).toEqual([]); + expect(deps.state.isStreaming).toBe(false); + }); + + it('does not claim a record was removed when no conversation is active', async () => { + const deps = createSendableDeps({}, null); + deps.state.currentConversationId = null; + (deps.plugin.createConversation as jest.Mock).mockResolvedValue({ id: '' }); + deps.getInputEl().value = 'retry this prompt'; + deps.mockAgentService.query.mockReturnValue(createMockStream([{ + type: 'error', + content: 'No conversation found with session ID: missing-session', + code: 'provider_session_missing', + }])); + + await new InputController(deps).sendMessage(); + + expect(deps.plugin.handleMissingProviderSession).not.toHaveBeenCalled(); + expect(mockNotice).toHaveBeenLastCalledWith( + 'The provider session no longer exists. Send again to start a new session.', + ); + }); + + it('reports that recoverable history was preserved after resetting resume state', async () => { + const deps = createSendableDeps(); + deps.getInputEl().value = 'retry this prompt'; + (deps.plugin.handleMissingProviderSession as jest.Mock).mockResolvedValue('reset'); + deps.mockAgentService.query.mockReturnValue(createMockStream([{ + type: 'error', + content: 'No conversation found with session ID: missing-session', + code: 'provider_session_missing', + providerSessionId: 'missing-session', + }])); + + await new InputController(deps).sendMessage(); + + expect(deps.plugin.handleMissingProviderSession).toHaveBeenCalledWith( + 'conv-1', + 'missing-session', + ); + expect(mockNotice).toHaveBeenLastCalledWith( + 'The provider session no longer exists. Claudian preserved the recoverable history; send again to rebuild the session.', + ); + }); + + it('preserves drafts and queued follow-ups when deleting the stale record resets the tab', async () => { + const deps = createSendableDeps(); + const inputEl = deps.getInputEl(); + const imageContextManager = deps.getImageContextManager()!; + deps.mockAgentService.query.mockImplementation(() => (async function* () { + inputEl.value = 'newer draft'; + deps.state.queuedMessage = { + content: 'queued follow-up', + images: undefined, + editorContext: null, + browserContext: null, + canvasContext: null, + }; + yield { + type: 'error', + content: 'No conversation found with session ID: missing-session', + code: 'provider_session_missing', + }; + })()); + (deps.plugin.handleMissingProviderSession as jest.Mock).mockImplementation(async () => { + inputEl.value = ''; + deps.state.queuedMessage = null; + imageContextManager.clearImages(); + return 'deleted'; + }); + + await new InputController(deps).sendMessage({ content: 'failed prompt' }); + + expect(inputEl.value).toBe('failed prompt\n\nqueued follow-up\n\nnewer draft'); + }); +}); + +describe('InputController - Message Queue', () => { + let controller: InputController; + let deps: InputControllerDeps; + let inputEl: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + deps = createMockDeps(); + inputEl = deps.getInputEl() as ReturnType; + controller = new InputController(deps); + }); + + describe('Queuing messages while streaming', () => { + it('should queue message when isStreaming is true', async () => { + deps.state.isStreaming = true; + inputEl.value = 'queued message'; + + await controller.sendMessage(); + + expect(deps.state.queuedMessage).toMatchObject({ + content: 'queued message', + images: undefined, + editorContext: null, + browserContext: null, + canvasContext: null, + }); + expect(deps.state.queuedMessage?.turnRequest).toMatchObject({ + text: 'queued message', + editorSelection: null, + browserSelection: null, + canvasSelection: null, + }); + expect(inputEl.value).toBe(''); + }); + + it('should queue message with images when streaming', async () => { + deps.state.isStreaming = true; + inputEl.value = 'queued with images'; + const mockImages = [{ id: 'img1', name: 'test.png' }]; + const imageContextManager = deps.getImageContextManager()!; + (imageContextManager.hasImages as jest.Mock).mockReturnValue(true); + (imageContextManager.getAttachedImages as jest.Mock).mockReturnValue(mockImages); + + await controller.sendMessage(); + + expect(deps.state.queuedMessage).toMatchObject({ + content: 'queued with images', + images: mockImages, + editorContext: null, + browserContext: null, + canvasContext: null, + }); + expect(deps.state.queuedMessage?.turnRequest).toMatchObject({ + text: 'queued with images', + images: mockImages, + }); + expect(imageContextManager.clearImages).toHaveBeenCalled(); + }); + + it('should append new message to existing queued message', async () => { + deps.state.isStreaming = true; + inputEl.value = 'first message'; + await controller.sendMessage(); + + inputEl.value = 'second message'; + await controller.sendMessage(); + + expect(deps.state.queuedMessage!.content).toBe('first message\n\nsecond message'); + }); + + it('should merge images when appending to queue', async () => { + deps.state.isStreaming = true; + const imageContextManager = deps.getImageContextManager()!; + + inputEl.value = 'first'; + (imageContextManager.hasImages as jest.Mock).mockReturnValue(true); + (imageContextManager.getAttachedImages as jest.Mock).mockReturnValue([{ id: 'img1' }]); + await controller.sendMessage(); + + inputEl.value = 'second'; + (imageContextManager.getAttachedImages as jest.Mock).mockReturnValue([{ id: 'img2' }]); + await controller.sendMessage(); + + expect(deps.state.queuedMessage!.images).toHaveLength(2); + expect(deps.state.queuedMessage!.images![0].id).toBe('img1'); + expect(deps.state.queuedMessage!.images![1].id).toBe('img2'); + }); + + it('should not queue empty message', async () => { + deps.state.isStreaming = true; + inputEl.value = ''; + const imageContextManager = deps.getImageContextManager()!; + (imageContextManager.hasImages as jest.Mock).mockReturnValue(false); + + await controller.sendMessage(); + + expect(deps.state.queuedMessage).toBeNull(); + }); + }); + + describe('Queued message processing', () => { + it('should send queued message in non-plan mode', async () => { + jest.useFakeTimers(); + try { + deps.plugin.settings.permissionMode = 'normal'; + deps.state.queuedMessage = { + content: 'queued plan', + images: undefined, + editorContext: null, + canvasContext: null, + }; + + const sendSpy = jest.spyOn(controller, 'sendMessage').mockResolvedValue(undefined); + + (controller as any).processQueuedMessage(); + jest.runAllTimers(); + await Promise.resolve(); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + content: 'queued plan', + turnRequestOverride: expect.objectContaining({ + text: 'queued plan', + editorSelection: null, + canvasSelection: null, + }), + })); + sendSpy.mockRestore(); + } finally { + jest.useRealTimers(); + } + }); + }); + + describe('Queue indicator UI', () => { + it('should show queue indicator when message is queued', () => { + deps.state.queuedMessage = { content: 'test message', images: undefined, editorContext: null, canvasContext: null }; + + controller.updateQueueIndicator(); + + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + expect(queueIndicatorEl.querySelector('.claudian-queue-indicator-text')?.textContent).toBe('⌙ Queued: test message'); + expect(queueIndicatorEl.style.display).toBe('flex'); + }); + + it('should hide queue indicator when no message is queued', () => { + deps.state.queuedMessage = null; + + controller.updateQueueIndicator(); + + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + expect(queueIndicatorEl.style.display).toBe('none'); + }); + + it('should withdraw queued message to the composer for editing', () => { + const mockImages = [{ id: 'img1', name: 'queued.png' }]; + const draftImages = [{ id: 'img2', name: 'draft.png' }]; + deps.state.queuedMessage = { + content: 'queued content', + images: mockImages as any, + editorContext: null, + canvasContext: null, + }; + inputEl.value = 'draft content'; + const imageContextManager = deps.getImageContextManager()!; + (imageContextManager.getAttachedImages as jest.Mock).mockReturnValue(draftImages); + + controller.updateQueueIndicator(); + + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + const editButton = queueIndicatorEl + .querySelectorAll('.claudian-queue-indicator-icon-action') + .find((button: any) => button.getAttribute('aria-label') === 'Edit queued message'); + editButton?.click(); + + expect(deps.state.queuedMessage).toBeNull(); + expect(inputEl.value).toBe('queued content\n\ndraft content'); + expect(imageContextManager.setImages).toHaveBeenCalledWith([...mockImages, ...draftImages]); + expect(deps.resetInputHeight).toHaveBeenCalled(); + expect(inputEl.focus).toHaveBeenCalled(); + expect(queueIndicatorEl.style.display).toBe('none'); + }); + + it('should discard queued message without changing the composer', () => { + deps.state.queuedMessage = { + content: 'queued content', + images: undefined, + editorContext: null, + canvasContext: null, + }; + inputEl.value = 'draft content'; + + controller.updateQueueIndicator(); + + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + const discardButton = queueIndicatorEl + .querySelectorAll('.claudian-queue-indicator-icon-action') + .find((button: any) => button.getAttribute('aria-label') === 'Discard queued message'); + discardButton?.click(); + + expect(deps.state.queuedMessage).toBeNull(); + expect(inputEl.value).toBe('draft content'); + expect(queueIndicatorEl.style.display).toBe('none'); + }); + + it('should truncate long message preview in indicator', () => { + const longMessage = 'a'.repeat(100); + deps.state.queuedMessage = { content: longMessage, images: undefined, editorContext: null, canvasContext: null }; + + controller.updateQueueIndicator(); + + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + const text = queueIndicatorEl.querySelector('.claudian-queue-indicator-text')?.textContent as string; + expect(text).toContain('...'); + }); + + it('should include [images] when queue message has images', () => { + const mockImages = [{ id: 'img1', name: 'test.png' }]; + deps.state.queuedMessage = { content: 'queued content', images: mockImages as any, editorContext: null, canvasContext: null }; + + controller.updateQueueIndicator(); + + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + const text = queueIndicatorEl.querySelector('.claudian-queue-indicator-text')?.textContent as string; + expect(text).toContain('queued content'); + expect(text).toContain('[images]'); + }); + + it('should show [images] when queue message has only images', () => { + const mockImages = [{ id: 'img1', name: 'test.png' }]; + deps.state.queuedMessage = { content: '', images: mockImages as any, editorContext: null, canvasContext: null }; + + controller.updateQueueIndicator(); + + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + expect(queueIndicatorEl.querySelector('.claudian-queue-indicator-text')?.textContent).toBe('⌙ Queued: [images]'); + }); + + it('should show Codex steer action when queued message can be steered', () => { + const mockAgentService = (deps as any).mockAgentService; + mockAgentService.providerId = 'codex'; + mockAgentService.getCapabilities = jest.fn().mockReturnValue({ + providerId: 'codex', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: false, + supportsFork: true, + supportsProviderCommands: false, + supportsTurnSteer: true, + reasoningControl: 'effort', + }); + deps.state.isStreaming = true; + deps.state.queuedMessage = { content: 'queued content', images: undefined, editorContext: null, canvasContext: null }; + + controller.updateQueueIndicator(); + + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + expect(queueIndicatorEl.querySelector('.claudian-queue-indicator-action')?.textContent).toBe('Steer Now'); + }); + + it('should steer the queued Codex message when the action is clicked', async () => { + const mockAgentService = (deps as any).mockAgentService; + mockAgentService.providerId = 'codex'; + mockAgentService.getCapabilities = jest.fn().mockReturnValue({ + providerId: 'codex', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: false, + supportsFork: true, + supportsProviderCommands: false, + supportsTurnSteer: true, + reasoningControl: 'effort', + }); + mockAgentService.prepareTurn = jest.fn().mockReturnValue({ + request: { text: 'queued follow-up' }, + persistedContent: 'queued follow-up', + prompt: 'queued follow-up', + isCompact: false, + mcpMentions: new Set(), + }); + mockAgentService.steer = jest.fn().mockResolvedValue(true); + + deps.state.isStreaming = true; + deps.state.messages = [ + { + id: 'user-1', + role: 'user', + content: 'original', + displayContent: 'original', + timestamp: Date.now(), + }, + { + id: 'assistant-1', + role: 'assistant', + content: '', + timestamp: Date.now(), + }, + ]; + deps.state.queuedMessage = { + content: 'queued follow-up', + images: undefined, + editorContext: null, + browserContext: null, + canvasContext: null, + }; + + controller.updateQueueIndicator(); + + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + queueIndicatorEl.querySelector('.claudian-queue-indicator-action')?.click(); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockAgentService.prepareTurn).toHaveBeenCalledWith(expect.objectContaining({ + text: 'queued follow-up', + })); + expect(mockAgentService.steer).toHaveBeenCalled(); + expect(deps.state.queuedMessage).toBeNull(); + expect(queueIndicatorEl.querySelector('.claudian-queue-indicator-text')?.textContent) + .toBe('⌙ Steering: queued follow-up'); + expect(queueIndicatorEl.querySelector('.claudian-queue-indicator-action')).toBeNull(); + expect(queueIndicatorEl.style.display).toBe('flex'); + expect(deps.state.messages).toHaveLength(2); + expect(deps.state.messages[0]).toMatchObject({ + id: 'user-1', + role: 'user', + content: 'original', + displayContent: 'original', + }); + expect((deps.renderer as any).addMessage).not.toHaveBeenCalled(); + expect((deps.renderer as any).updateLiveUserMessage).not.toHaveBeenCalled(); + }); + + it('should restore the queued message when steering fails', async () => { + const mockAgentService = (deps as any).mockAgentService; + mockAgentService.providerId = 'codex'; + mockAgentService.getCapabilities = jest.fn().mockReturnValue({ + providerId: 'codex', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: false, + supportsFork: true, + supportsProviderCommands: false, + supportsTurnSteer: true, + reasoningControl: 'effort', + }); + mockAgentService.prepareTurn = jest.fn().mockReturnValue({ + request: { text: 'queued follow-up' }, + persistedContent: 'queued follow-up', + prompt: 'queued follow-up', + isCompact: false, + mcpMentions: new Set(), + }); + mockAgentService.steer = jest.fn().mockRejectedValue(new Error('boom')); + + deps.state.isStreaming = true; + deps.state.queuedMessage = { + content: 'queued follow-up', + images: undefined, + editorContext: null, + browserContext: null, + canvasContext: null, + }; + + controller.updateQueueIndicator(); + + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + queueIndicatorEl.querySelector('.claudian-queue-indicator-action')?.click(); + await Promise.resolve(); + await Promise.resolve(); + + expect(deps.state.queuedMessage).toEqual({ + content: 'queued follow-up', + images: undefined, + editorContext: null, + browserContext: null, + canvasContext: null, + }); + expect(mockNotice).toHaveBeenCalledWith( + 'Failed to steer the queued Codex message. It is still available.', + ); + }); + + it('should not mark the current note as sent when steering is rejected', async () => { + const fileContextManager = createMockFileContextManager(); + (fileContextManager.getCurrentNotePath as jest.Mock).mockReturnValue('notes/session.md'); + (fileContextManager.shouldSendCurrentNote as jest.Mock).mockReturnValue(true); + deps = createSendableDeps({ + getFileContextManager: () => fileContextManager as any, + }); + + const mockAgentService = (deps as any).mockAgentService; + mockAgentService.providerId = 'codex'; + mockAgentService.getCapabilities = jest.fn().mockReturnValue({ + providerId: 'codex', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: false, + supportsFork: true, + supportsProviderCommands: false, + supportsTurnSteer: true, + reasoningControl: 'effort', + }); + mockAgentService.prepareTurn = jest.fn().mockReturnValue({ + request: { text: 'queued follow-up', currentNotePath: 'notes/session.md' }, + persistedContent: 'queued follow-up', + prompt: 'queued follow-up', + isCompact: false, + mcpMentions: new Set(), + }); + mockAgentService.steer = jest.fn().mockResolvedValue(false); + + deps.state.isStreaming = true; + deps.state.queuedMessage = { + content: 'queued follow-up', + images: undefined, + editorContext: null, + browserContext: null, + canvasContext: null, + }; + controller = new InputController(deps); + controller.updateQueueIndicator(); + + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + queueIndicatorEl.querySelector('.claudian-queue-indicator-action')?.click(); + await Promise.resolve(); + await Promise.resolve(); + + expect(fileContextManager.markCurrentNoteSent).not.toHaveBeenCalled(); + expect(deps.state.queuedMessage).toEqual({ + content: 'queued follow-up', + images: undefined, + editorContext: null, + browserContext: null, + canvasContext: null, + }); + }); + + it('should route subsequent live chunks to a new assistant bubble after steering', async () => { + deps = createSendableDeps(); + const mockAgentService = (deps as any).mockAgentService; + mockAgentService.providerId = 'codex'; + mockAgentService.getCapabilities = jest.fn().mockReturnValue({ + providerId: 'codex', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: false, + supportsFork: true, + supportsProviderCommands: false, + supportsTurnSteer: true, + reasoningControl: 'effort', + }); + mockAgentService.prepareTurn = jest.fn().mockImplementation((request: any) => ({ + request: { + ...request, + currentNotePath: 'notes/steer.md', + }, + persistedContent: 'persisted steer prompt', + prompt: request.text, + isCompact: false, + mcpMentions: new Set(), + })); + mockAgentService.steer = jest.fn().mockResolvedValue(true); + + let releaseSecondChunk: () => void = () => { + throw new Error('Second chunk gate was not initialized'); + }; + const secondChunkGate = new Promise((resolve) => { + releaseSecondChunk = () => resolve(); + }); + const firstChunkHandled = new Promise((resolve) => { + let handledCount = 0; + (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async () => { + handledCount += 1; + if (handledCount === 1) { + resolve(); + } + }); + }); + + mockAgentService.query = jest.fn().mockImplementation(() => { + return (async function* () { + yield { type: 'user_message_start', content: 'first prompt', itemId: 'user-1' }; + yield { type: 'assistant_message_start', itemId: 'assistant-1' }; + yield { type: 'text', content: 'partial' }; + await secondChunkGate; + yield { type: 'user_message_start', content: 'steer prompt', itemId: 'user-2' }; + yield { type: 'thinking', content: 'thinking after steer' }; + yield { type: 'assistant_message_start', itemId: 'assistant-2' }; + yield { type: 'text', content: 'after steer' }; + yield { type: 'done' }; + })(); + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'first prompt'; + controller = new InputController(deps); + + const sendPromise = controller.sendMessage(); + await firstChunkHandled; + + deps.state.queuedMessage = { + content: 'steer prompt', + images: undefined, + editorContext: null, + browserContext: null, + canvasContext: null, + }; + controller.updateQueueIndicator(); + + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + queueIndicatorEl.querySelector('.claudian-queue-indicator-action')?.click(); + await Promise.resolve(); + await Promise.resolve(); + + expect(deps.state.messages).toHaveLength(2); + + const firstAssistant = deps.state.messages[1]; + + releaseSecondChunk(); + await sendPromise; + + expect(deps.state.messages).toHaveLength(4); + const steerUser = deps.state.messages[2]; + const secondAssistant = deps.state.messages[3]; + expect(steerUser).toMatchObject({ + role: 'user', + content: 'persisted steer prompt', + displayContent: 'steer prompt', + currentNote: 'notes/steer.md', + }); + expect(secondAssistant).toMatchObject({ + role: 'assistant', + }); + + expect(deps.streamController.handleStreamChunk).toHaveBeenNthCalledWith( + 1, + { type: 'text', content: 'partial' }, + firstAssistant, + ); + expect(deps.streamController.handleStreamChunk).toHaveBeenNthCalledWith( + 2, + { type: 'thinking', content: 'thinking after steer' }, + secondAssistant, + ); + expect(deps.streamController.handleStreamChunk).toHaveBeenNthCalledWith( + 3, + { type: 'text', content: 'after steer' }, + secondAssistant, + ); + expect(deps.streamController.finalizeCurrentThinkingBlock).toHaveBeenCalledWith(firstAssistant); + expect(deps.streamController.finalizeCurrentTextBlock).toHaveBeenCalledWith(firstAssistant); + expect(queueIndicatorEl.style.display).toBe('none'); + }); + + it('should discard the empty assistant placeholder when steer lands before assistant output', async () => { + deps = createSendableDeps(); + const mockAgentService = (deps as any).mockAgentService; + mockAgentService.providerId = 'codex'; + mockAgentService.getCapabilities = jest.fn().mockReturnValue({ + providerId: 'codex', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: false, + supportsFork: true, + supportsProviderCommands: false, + supportsTurnSteer: true, + reasoningControl: 'effort', + }); + mockAgentService.prepareTurn = jest.fn().mockImplementation((request: any) => ({ + request: { + ...request, + currentNotePath: 'notes/steer.md', + }, + persistedContent: request.text === 'steer prompt' + ? 'persisted steer prompt' + : request.text, + prompt: request.text, + isCompact: false, + mcpMentions: new Set(), + })); + mockAgentService.steer = jest.fn().mockResolvedValue(true); + + let releaseSecondChunk: () => void = () => { + throw new Error('Second chunk gate was not initialized'); + }; + const secondChunkGate = new Promise((resolve) => { + releaseSecondChunk = () => resolve(); + }); + mockAgentService.query = jest.fn().mockImplementation(() => { + return (async function* () { + yield { type: 'user_message_start', content: 'first prompt', itemId: 'user-1' }; + await secondChunkGate; + yield { type: 'user_message_start', content: 'steer prompt', itemId: 'user-2' }; + yield { type: 'assistant_message_start', itemId: 'assistant-2' }; + yield { type: 'text', content: 'after steer' }; + yield { type: 'done' }; + })(); + }); + (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { + if (chunk.type === 'text') { + msg.content += chunk.content; + } + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'first prompt'; + controller = new InputController(deps); + + const sendPromise = controller.sendMessage(); + await Promise.resolve(); + await Promise.resolve(); + + expect(deps.state.messages).toHaveLength(2); + const discardedAssistant = deps.state.messages[1]; + + deps.state.queuedMessage = { + content: 'steer prompt', + images: undefined, + editorContext: null, + browserContext: null, + canvasContext: null, + }; + controller.updateQueueIndicator(); + + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + queueIndicatorEl.querySelector('.claudian-queue-indicator-action')?.click(); + await Promise.resolve(); + await Promise.resolve(); + + releaseSecondChunk(); + await sendPromise; + + expect((deps.renderer as any).removeMessage).toHaveBeenCalledWith(discardedAssistant.id); + expect(deps.state.messages).toHaveLength(3); + expect(deps.state.messages.map((message) => message.role)).toEqual(['user', 'user', 'assistant']); + expect(deps.state.messages[1]).toMatchObject({ + content: 'persisted steer prompt', + displayContent: 'steer prompt', + currentNote: 'notes/steer.md', + }); + expect(deps.state.messages[2]).toMatchObject({ + role: 'assistant', + content: 'after steer', + }); + expect(deps.streamController.handleStreamChunk).toHaveBeenCalledTimes(2); + expect(deps.streamController.handleStreamChunk).toHaveBeenNthCalledWith( + 1, + { type: 'text', content: 'after steer' }, + deps.state.messages[2], + ); + }); + }); + + describe('Clearing queued message', () => { + it('should clear queued message and update indicator', () => { + deps.state.queuedMessage = { content: 'test', images: undefined, editorContext: null, canvasContext: null }; + + controller.clearQueuedMessage(); + + expect(deps.state.queuedMessage).toBeNull(); + const queueIndicatorEl = deps.state.queueIndicatorEl as any; + expect(queueIndicatorEl.style.display).toBe('none'); + }); + }); + + describe('Cancel streaming', () => { + it('should clear queue on cancel', () => { + deps.state.queuedMessage = { content: 'test', images: undefined, editorContext: null, canvasContext: null }; + deps.state.isStreaming = true; + + controller.cancelStreaming(); + + expect(deps.state.queuedMessage).toBeNull(); + expect(deps.state.cancelRequested).toBe(true); + expect((deps as any).mockAgentService.cancel).toHaveBeenCalled(); + }); + + it('should restore a pending steer message to input on cancel', () => { + deps.state.isStreaming = true; + (controller as any).pendingSteerMessage = { + content: 'steered follow-up', + images: undefined, + editorContext: null, + browserContext: null, + canvasContext: null, + }; + (controller as any).steerInFlight = true; + + controller.cancelStreaming(); + + expect(inputEl.value).toBe('steered follow-up'); + expect(deps.state.queuedMessage).toBeNull(); + expect((deps.state.queueIndicatorEl as any).style.display).toBe('none'); + expect((deps as any).mockAgentService.cancel).toHaveBeenCalled(); + }); + + it('should not cancel if not streaming', () => { + deps.state.isStreaming = false; + + controller.cancelStreaming(); + + expect((deps as any).mockAgentService.cancel).not.toHaveBeenCalled(); + }); + }); + + describe('Sending messages', () => { + it('should send message, hide welcome, and save conversation', async () => { + const welcomeEl = createMockWelcomeEl(); + const fileContextManager = createMockFileContextManager(); + const imageContextManager = deps.getImageContextManager()!; + + deps.getWelcomeEl = () => welcomeEl; + deps.getFileContextManager = () => fileContextManager as any; + deps.state.currentConversationId = 'conv-1'; + (deps as any).mockAgentService.query = jest.fn().mockImplementation(() => createMockStream([{ type: 'done' }])); + + inputEl.value = 'See ![[image.png]]'; + + await controller.sendMessage(); + + expect(welcomeEl.style.display).toBe('none'); + expect(fileContextManager.startSession).toHaveBeenCalled(); + expect(deps.renderer.addMessage).toHaveBeenCalledTimes(2); + expect(deps.state.messages).toHaveLength(2); + // Without XML context tags, content equals displayContent (no wrapper) + expect(deps.state.messages[0].content).toBe('See ![[image.png]]'); + expect(deps.state.messages[0].displayContent).toBe('See ![[image.png]]'); + expect(deps.state.messages[0].images).toBeUndefined(); + expect(imageContextManager.clearImages).toHaveBeenCalled(); + expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'Test Title'); + // No user_message_sent in stream → save without clearing resumeAtMessageId + expect(deps.conversationController.save).toHaveBeenCalledWith(true, undefined); + expect((deps as any).mockAgentService.query).toHaveBeenCalled(); + expect(deps.state.isStreaming).toBe(false); + }); + + it('should persist replay-safe user content instead of transport-only prompt', async () => { + deps = createSendableDeps(); + (deps as any).mockAgentService.prepareTurn = jest.fn().mockReturnValue({ + request: { text: '@server-a hello' }, + persistedContent: '@server-a hello', + prompt: '@server-a MCP hello', + isCompact: false, + mcpMentions: new Set(['server-a']), + }); + (deps as any).mockAgentService.query = jest.fn().mockImplementation(() => createMockStream([{ type: 'done' }])); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = '@server-a hello'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(deps.state.messages[0].content).toBe('@server-a hello'); + expect(deps.state.messages[0].content).not.toBe('@server-a MCP hello'); + }); + + it('should prepend current note only once per session', async () => { + const prompts: string[] = []; + let currentNoteSent = false; + const fileContextManager = { + startSession: jest.fn(), + getCurrentNotePath: jest.fn().mockReturnValue('notes/session.md'), + shouldSendCurrentNote: jest.fn().mockImplementation(() => !currentNoteSent), + markCurrentNoteSent: jest.fn().mockImplementation(() => { currentNoteSent = true; }), + transformContextMentions: jest.fn().mockImplementation((text: string) => text), + }; + + deps.getFileContextManager = () => fileContextManager as any; + (deps as any).mockAgentService.query = jest.fn().mockImplementation((turn: any) => { + prompts.push(turn.prompt); + return createMockStream([{ type: 'done' }]); + }); + + inputEl.value = 'First message'; + await controller.sendMessage(); + + inputEl.value = 'Second message'; + await controller.sendMessage(); + + expect(prompts[0]).toContain(''); + expect(prompts[1]).not.toContain(''); + }); + + it('should not persist currentNote metadata for /compact turns', async () => { + const fileContextManager = { + startSession: jest.fn(), + getCurrentNotePath: jest.fn().mockReturnValue('notes/session.md'), + shouldSendCurrentNote: jest.fn().mockReturnValue(true), + markCurrentNoteSent: jest.fn(), + transformContextMentions: jest.fn().mockImplementation((text: string) => text), + }; + + deps = createSendableDeps({ + getFileContextManager: () => fileContextManager as any, + }); + (deps as any).mockAgentService.query = jest.fn().mockImplementation(() => createMockStream([{ type: 'done' }])); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = '/compact'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(deps.state.messages[0].content).toBe('/compact'); + expect(deps.state.messages[0].currentNote).toBeUndefined(); + }); + + it('should include MCP options in query when mentions are present', async () => { + const mcpMentions = new Set(['server-a']); + const enabledServers = new Set(['server-b']); + + (deps as any).mockAgentService.prepareTurn = jest.fn().mockImplementation((request: any) => ({ + request, + persistedContent: request.text, + prompt: request.text, + isCompact: false, + mcpMentions, + })); + deps.getMcpServerSelector = () => ({ + getEnabledServers: () => enabledServers, + }) as any; + (deps as any).mockAgentService.query = jest.fn().mockImplementation(() => createMockStream([{ type: 'done' }])); + + inputEl.value = 'hello'; + + await controller.sendMessage(); + + const prepareTurnCall = ((deps as any).mockAgentService.prepareTurn as jest.Mock).mock.calls[0]; + expect(prepareTurnCall[0].enabledMcpServers).toBe(enabledServers); + const queryCall = ((deps as any).mockAgentService.query as jest.Mock).mock.calls[0]; + expect(queryCall[0].mcpMentions).toBe(mcpMentions); + }); + + it('should pass the selected model in runtime query options', async () => { + deps = createSendableDeps({ + getAuxiliaryModel: () => 'opus', + }); + (deps as any).mockAgentService.query = jest.fn().mockImplementation(() => createMockStream([{ type: 'done' }])); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'hello'; + controller = new InputController(deps); + + await controller.sendMessage(); + + const queryCall = ((deps as any).mockAgentService.query as jest.Mock).mock.calls[0]; + expect(queryCall[2]).toEqual({ model: 'opus' }); + }); + + it('should persist the selected model when title generation creates the first-send conversation', async () => { + deps = createSendableDeps({ + getAuxiliaryModel: () => 'opus', + }, null); + (deps as any).mockAgentService.query = jest.fn().mockImplementation(() => createMockStream([{ type: 'done' }])); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'hello'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(deps.plugin.createConversation).toHaveBeenCalledWith({ + providerId: 'claude', + sessionId: undefined, + selectedModel: 'opus', + }); + }); + + it('should append browser selection context when available', async () => { + const mockAgentService = createMockAgentService(); + const localDeps = createSendableDeps({ + browserSelectionController: { + getContext: jest.fn().mockReturnValue({ + source: 'surfing-view', + selectedText: 'selected from browser', + title: 'Surfing', + }), + } as any, + getAgentService: () => mockAgentService as any, + }); + const localController = new InputController(localDeps); + + mockAgentService.query.mockImplementation((turn: any) => { + expect(turn.prompt).toContain(''); + expect(turn.prompt).toContain('selected from browser'); + return createMockStream([{ type: 'done' }]); + }); + + const localInput = localDeps.getInputEl() as ReturnType; + localInput.value = 'Summarize this'; + + await localController.sendMessage(); + + expect(mockAgentService.query).toHaveBeenCalled(); + }); + }); + + describe('Conversation operation guards', () => { + it('should not send message when isCreatingConversation is true', async () => { + deps.state.isCreatingConversation = true; + inputEl.value = 'test message'; + + await controller.sendMessage(); + + expect((deps as any).mockAgentService.query).not.toHaveBeenCalled(); + // Input should be preserved for retry + expect(inputEl.value).toBe('test message'); + }); + + it('should not send message when isSwitchingConversation is true', async () => { + deps.state.isSwitchingConversation = true; + inputEl.value = 'test message'; + + await controller.sendMessage(); + + expect((deps as any).mockAgentService.query).not.toHaveBeenCalled(); + // Input should be preserved for retry + expect(inputEl.value).toBe('test message'); + }); + + it('should preserve images when blocked by conversation operation', async () => { + deps.state.isCreatingConversation = true; + inputEl.value = 'test message'; + const mockImages = [{ id: 'img1', name: 'test.png' }]; + const imageContextManager = deps.getImageContextManager()!; + (imageContextManager.hasImages as jest.Mock).mockReturnValue(true); + (imageContextManager.getAttachedImages as jest.Mock).mockReturnValue(mockImages); + + await controller.sendMessage(); + + expect((deps as any).mockAgentService.query).not.toHaveBeenCalled(); + // Images should NOT be cleared + expect(imageContextManager.clearImages).not.toHaveBeenCalled(); + }); + }); + + describe('Title generation', () => { + it('should set pending status and fallback title after first user message', async () => { + const mockTitleService = { + generateTitle: jest.fn().mockResolvedValue(undefined), + cancel: jest.fn(), + }; + + // conversationId=null to test the conversation creation path + deps = createSendableDeps({ + getTitleGenerationService: () => mockTitleService as any, + }, null); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([ + { type: 'text', content: 'Hello, how can I help?' }, + { type: 'done' }, + ]) + ); + + (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { + if (chunk.type === 'text') { + msg.content = chunk.content; + } + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'Hello world'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(deps.plugin.createConversation).toHaveBeenCalled(); + expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { titleGenerationStatus: 'pending' }); + expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'Test Title'); + }); + + it('should find messages by role, not by index', async () => { + deps = createSendableDeps(); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([{ type: 'done' }]) + ); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'Test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + const userMsg = deps.state.messages.find(m => m.role === 'user'); + const assistantMsg = deps.state.messages.find(m => m.role === 'assistant'); + expect(userMsg).toBeDefined(); + expect(assistantMsg).toBeDefined(); + }); + + it('should call title generation service when available', async () => { + const mockTitleService = { + generateTitle: jest.fn().mockResolvedValue(undefined), + cancel: jest.fn(), + }; + + deps = createSendableDeps({ + getTitleGenerationService: () => mockTitleService as any, + }); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([ + { type: 'text', content: 'Response text' }, + { type: 'done' }, + ]) + ); + + (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { + if (chunk.type === 'text') { + msg.content = chunk.content; + } + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'Hello world'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockTitleService.generateTitle).toHaveBeenCalled(); + const callArgs = mockTitleService.generateTitle.mock.calls[0]; + expect(callArgs[0]).toBe('conv-1'); + expect(callArgs[1]).toContain('Hello world'); + }); + + it('should lazily create the conversation with the active runtime provider', async () => { + const sendableDeps = createSendableDeps({}, null); + sendableDeps.mockAgentService.providerId = 'codex'; + deps = sendableDeps; + (deps.plugin.createConversation as jest.Mock).mockResolvedValue({ id: 'conv-codex', providerId: 'codex' }); + + (sendableDeps.mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([ + { type: 'text', content: 'Response text' }, + { type: 'done' }, + ]) + ); + + (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { + if (chunk.type === 'text') { + msg.content = chunk.content; + } + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'Hello world'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(deps.plugin.createConversation).toHaveBeenCalledWith({ + providerId: 'codex', + sessionId: undefined, + }); + }); + + it('should prefer the blank-tab provider over a stale runtime when lazily creating a conversation', async () => { + const sendableDeps = createSendableDeps({ + getTabProviderId: () => 'claude', + }, null); + sendableDeps.mockAgentService.providerId = 'codex'; + deps = sendableDeps; + (deps.plugin.createConversation as jest.Mock).mockResolvedValue({ id: 'conv-claude', providerId: 'claude' }); + + (sendableDeps.mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([ + { type: 'text', content: 'Response text' }, + { type: 'done' }, + ]) + ); + + (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { + if (chunk.type === 'text') { + msg.content = chunk.content; + } + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'Hello world'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(deps.plugin.createConversation).toHaveBeenCalledWith({ + providerId: 'claude', + sessionId: undefined, + }); + }); + + it('should not overwrite user-renamed title in callback', async () => { + const mockTitleService = { + generateTitle: jest.fn().mockResolvedValue(undefined), + cancel: jest.fn(), + }; + + deps = createSendableDeps({ + getTitleGenerationService: () => mockTitleService as any, + }); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([ + { type: 'text', content: 'Response' }, + { type: 'done' }, + ]) + ); + + (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { + if (chunk.type === 'text') { + msg.content = chunk.content; + } + }); + + // Simulate user having renamed the conversation + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: 'conv-1', + title: 'User Custom Title', + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'Test'; + controller = new InputController(deps); + + await controller.sendMessage(); + + const callback = mockTitleService.generateTitle.mock.calls[0][2]; + await callback('conv-1', { success: true, title: 'AI Generated Title' }); + + // Should clear status since user manually renamed (not apply AI title) + expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { titleGenerationStatus: undefined }); + }); + + it('should not set pending status when titleService is null', async () => { + deps = createSendableDeps({ + getTitleGenerationService: () => null, + }); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([ + { type: 'text', content: 'Response' }, + { type: 'done' }, + ]) + ); + + (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { + if (chunk.type === 'text') { + msg.content = chunk.content; + } + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'Test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + const updateCalls = (deps.plugin.updateConversation as jest.Mock).mock.calls; + const pendingCall = updateCalls.find((call: [string, { titleGenerationStatus?: string }]) => + call[1]?.titleGenerationStatus === 'pending' + ); + expect(pendingCall).toBeUndefined(); + }); + + it('should NOT call title generation service when enableAutoTitleGeneration is false', async () => { + const mockTitleService = { + generateTitle: jest.fn().mockResolvedValue(undefined), + cancel: jest.fn(), + }; + + deps = createSendableDeps({ + getTitleGenerationService: () => mockTitleService as any, + }); + deps.plugin.settings.enableAutoTitleGeneration = false; + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([ + { type: 'text', content: 'Response text' }, + { type: 'done' }, + ]) + ); + + (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { + if (chunk.type === 'text') { + msg.content = chunk.content; + } + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'Hello world'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockTitleService.generateTitle).not.toHaveBeenCalled(); + + const updateCalls = (deps.plugin.updateConversation as jest.Mock).mock.calls; + const pendingCall = updateCalls.find((call: [string, { titleGenerationStatus?: string }]) => + call[1]?.titleGenerationStatus === 'pending' + ); + expect(pendingCall).toBeUndefined(); + + expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'Test Title'); + }); + }); + + describe('Auto-hide status panels on response end', () => { + it('should clear currentTodos when all todos are completed', async () => { + deps = createSendableDeps(); + deps.state.currentTodos = [ + { content: 'Task 1', status: 'completed', activeForm: 'Task 1' }, + { content: 'Task 2', status: 'completed', activeForm: 'Task 2' }, + ]; + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([{ type: 'done' }]) + ); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'Test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(deps.state.currentTodos).toBeNull(); + }); + + it('should NOT clear currentTodos when some todos are pending', async () => { + deps = createSendableDeps(); + deps.state.currentTodos = [ + { content: 'Task 1', status: 'completed', activeForm: 'Task 1' }, + { content: 'Task 2', status: 'pending', activeForm: 'Task 2' }, + ]; + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([{ type: 'done' }]) + ); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'Test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(deps.state.currentTodos).not.toBeNull(); + expect(deps.state.currentTodos).toHaveLength(2); + }); + + it('should handle null statusPanel gracefully', async () => { + deps = createSendableDeps({ + getStatusPanel: () => null, + }); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([{ type: 'done' }]) + ); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'Test message'; + controller = new InputController(deps); + + await expect(controller.sendMessage()).resolves.not.toThrow(); + }); + }); + + describe('Approval inline tracking', () => { + it('should dismiss pending inline and clear reference', () => { + controller = new InputController(deps); + const mockInline = { destroy: jest.fn() }; + (controller as any).pendingApprovalInline = mockInline; + + controller.dismissPendingApproval(); + + expect(mockInline.destroy).toHaveBeenCalled(); + expect((controller as any).pendingApprovalInline).toBeNull(); + }); + + it('should dismiss pending ask inline and clear reference', () => { + controller = new InputController(deps); + const mockAskInline = { destroy: jest.fn() }; + (controller as any).pendingAskInline = mockAskInline; + + controller.dismissPendingApproval(); + + expect(mockAskInline.destroy).toHaveBeenCalled(); + expect((controller as any).pendingAskInline).toBeNull(); + }); + + it('should dismiss both approval and ask inlines', () => { + controller = new InputController(deps); + const mockApproval = { destroy: jest.fn() }; + const mockAsk = { destroy: jest.fn() }; + (controller as any).pendingApprovalInline = mockApproval; + (controller as any).pendingAskInline = mockAsk; + + controller.dismissPendingApproval(); + + expect(mockApproval.destroy).toHaveBeenCalled(); + expect(mockAsk.destroy).toHaveBeenCalled(); + expect((controller as any).pendingApprovalInline).toBeNull(); + expect((controller as any).pendingAskInline).toBeNull(); + }); + + it('should be a no-op when no inline is pending', () => { + controller = new InputController(deps); + expect((controller as any).pendingApprovalInline).toBeNull(); + expect(() => controller.dismissPendingApproval()).not.toThrow(); + }); + }); + + describe('Built-in commands - /add-dir', () => { + beforeEach(() => { + mockNotice.mockClear(); + }); + + it('should work on codex tabs', async () => { + const mockExternalContextSelector = { + getExternalContexts: jest.fn().mockReturnValue([]), + addExternalContext: jest.fn().mockReturnValue({ success: true, normalizedPath: '/some/path' }), + }; + deps.getExternalContextSelector = () => mockExternalContextSelector; + deps.getAgentService = () => ({ + ...(deps as any).mockAgentService, + providerId: 'codex', + getCapabilities: jest.fn().mockReturnValue({ + providerId: 'codex', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: false, + supportsRewind: false, + supportsFork: false, + supportsProviderCommands: false, + reasoningControl: 'effort', + }), + } as any); + inputEl.value = '/add-dir /some/path'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('/some/path'); + expect(mockNotice).toHaveBeenCalledWith('Added external context: /some/path'); + }); + + it('should show error notice when external context selector is not available', async () => { + deps.getExternalContextSelector = () => null; + inputEl.value = '/add-dir /some/path'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockNotice).toHaveBeenCalledWith('External context selector not available.'); + expect(inputEl.value).toBe(''); + }); + + it('should show success notice when path is added successfully', async () => { + const mockExternalContextSelector = { + getExternalContexts: jest.fn().mockReturnValue([]), + addExternalContext: jest.fn().mockReturnValue({ success: true, normalizedPath: '/some/path' }), + }; + deps.getExternalContextSelector = () => mockExternalContextSelector; + inputEl.value = '/add-dir /some/path'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('/some/path'); + expect(mockNotice).toHaveBeenCalledWith('Added external context: /some/path'); + expect(inputEl.value).toBe(''); + }); + + it('should show error notice when /add-dir is called without path', async () => { + const mockExternalContextSelector = { + getExternalContexts: jest.fn().mockReturnValue([]), + addExternalContext: jest.fn().mockReturnValue({ + success: false, + error: 'No path provided. Usage: /add-dir /absolute/path', + }), + }; + deps.getExternalContextSelector = () => mockExternalContextSelector; + inputEl.value = '/add-dir'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith(''); + expect(mockNotice).toHaveBeenCalledWith('No path provided. Usage: /add-dir /absolute/path'); + expect(inputEl.value).toBe(''); + }); + + it('should show error notice when path addition fails', async () => { + const mockExternalContextSelector = { + getExternalContexts: jest.fn().mockReturnValue([]), + addExternalContext: jest.fn().mockReturnValue({ + success: false, + error: 'Path must be absolute. Usage: /add-dir /absolute/path', + }), + }; + deps.getExternalContextSelector = () => mockExternalContextSelector; + inputEl.value = '/add-dir relative/path'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('relative/path'); + expect(mockNotice).toHaveBeenCalledWith('Path must be absolute. Usage: /add-dir /absolute/path'); + expect(inputEl.value).toBe(''); + }); + + it('should handle /add-dir with home path expansion', async () => { + const expandedPath = '/Users/test/projects'; + const mockExternalContextSelector = { + getExternalContexts: jest.fn().mockReturnValue([]), + addExternalContext: jest.fn().mockReturnValue({ success: true, normalizedPath: expandedPath }), + }; + deps.getExternalContextSelector = () => mockExternalContextSelector; + inputEl.value = '/add-dir ~/projects'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('~/projects'); + expect(mockNotice).toHaveBeenCalledWith(`Added external context: ${expandedPath}`); + }); + + it('should handle /add-dir with quoted path', async () => { + const normalizedPath = '/path/with spaces'; + const mockExternalContextSelector = { + getExternalContexts: jest.fn().mockReturnValue([]), + addExternalContext: jest.fn().mockReturnValue({ success: true, normalizedPath }), + }; + deps.getExternalContextSelector = () => mockExternalContextSelector; + inputEl.value = '/add-dir "/path/with spaces"'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('"/path/with spaces"'); + expect(mockNotice).toHaveBeenCalledWith(`Added external context: ${normalizedPath}`); + }); + }); + + describe('Built-in commands - /clear', () => { + it('should call conversationController.createNew on /clear', async () => { + (deps.conversationController as any).createNew = jest.fn().mockResolvedValue(undefined); + inputEl.value = '/clear'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect((deps.conversationController as any).createNew).toHaveBeenCalled(); + expect(inputEl.value).toBe(''); + }); + }); + + describe('Built-in commands - /resume', () => { + const mockConversations = [ + { id: 'conv-1', title: 'Chat 1', createdAt: 1000, updatedAt: 1000, messageCount: 1, preview: '' }, + ]; + + let mockDropdownInstance: { + isVisible: jest.Mock; + handleKeydown: jest.Mock; + destroy: jest.Mock; + }; + + beforeEach(() => { + mockNotice.mockClear(); + mockDropdownInstance = { + isVisible: jest.fn().mockReturnValue(true), + handleKeydown: jest.fn().mockReturnValue(false), + destroy: jest.fn(), + }; + (ResumeSessionDropdown as jest.Mock).mockImplementation(() => mockDropdownInstance); + }); + + it('should reject /resume when the provider lacks native history support', async () => { + deps.getAgentService = () => ({ + ...(deps as any).mockAgentService, + providerId: 'codex', + getCapabilities: jest.fn().mockReturnValue({ + providerId: 'codex', + supportsPersistentRuntime: true, + supportsNativeHistory: false, + supportsPlanMode: false, + supportsRewind: false, + supportsFork: false, + supportsProviderCommands: false, + reasoningControl: 'effort', + }), + } as any); + inputEl.value = '/resume'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockNotice).toHaveBeenCalledWith('/resume is not supported by this provider.'); + expect(ResumeSessionDropdown).not.toHaveBeenCalled(); + }); + + it('should show notice when no conversations exist', async () => { + (deps.plugin as any).getConversationList = jest.fn().mockReturnValue([]); + inputEl.value = '/resume'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockNotice).toHaveBeenCalledWith('No conversations to resume'); + expect(ResumeSessionDropdown).not.toHaveBeenCalled(); + expect(inputEl.value).toBe(''); + }); + + it('should create dropdown when conversations exist', async () => { + (deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations); + inputEl.value = '/resume'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(ResumeSessionDropdown).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + mockConversations, + deps.state.currentConversationId, + expect.objectContaining({ onSelect: expect.any(Function), onDismiss: expect.any(Function) }), + ); + expect(controller.isResumeDropdownVisible()).toBe(true); + }); + + it('should call switchTo on select callback', async () => { + (deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations); + (deps.conversationController as any).switchTo = jest.fn().mockResolvedValue(undefined); + inputEl.value = '/resume'; + controller = new InputController(deps); + + await controller.sendMessage(); + + const callbacks = (ResumeSessionDropdown as jest.Mock).mock.calls[0][4]; + callbacks.onSelect('conv-1'); + + expect((deps.conversationController as any).switchTo).toHaveBeenCalledWith('conv-1'); + expect(mockDropdownInstance.destroy).toHaveBeenCalled(); + }); + + it('should call openConversation on select callback when provided', async () => { + (deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations); + (deps.conversationController as any).switchTo = jest.fn().mockResolvedValue(undefined); + deps.openConversation = jest.fn().mockResolvedValue(undefined); + inputEl.value = '/resume'; + controller = new InputController(deps); + + await controller.sendMessage(); + + const callbacks = (ResumeSessionDropdown as jest.Mock).mock.calls[0][4]; + callbacks.onSelect('conv-1'); + + expect(deps.openConversation).toHaveBeenCalledWith('conv-1'); + expect((deps.conversationController as any).switchTo).not.toHaveBeenCalled(); + expect(mockDropdownInstance.destroy).toHaveBeenCalled(); + }); + + it('should destroy dropdown on dismiss callback', async () => { + (deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations); + inputEl.value = '/resume'; + controller = new InputController(deps); + + await controller.sendMessage(); + + const callbacks = (ResumeSessionDropdown as jest.Mock).mock.calls[0][4]; + callbacks.onDismiss(); + + expect(mockDropdownInstance.destroy).toHaveBeenCalled(); + expect(controller.isResumeDropdownVisible()).toBe(false); + }); + + it('should show notice with error message when openConversation rejects', async () => { + (deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations); + deps.openConversation = jest.fn().mockRejectedValue(new Error('session not found')); + inputEl.value = '/resume'; + controller = new InputController(deps); + + await controller.sendMessage(); + + const callbacks = (ResumeSessionDropdown as jest.Mock).mock.calls[0][4]; + callbacks.onSelect('conv-1'); + + await Promise.resolve(); + + expect(mockNotice).toHaveBeenCalledWith('Failed to open conversation: session not found'); + }); + + it('should destroy existing dropdown before creating new one', async () => { + (deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations); + inputEl.value = '/resume'; + controller = new InputController(deps); + + await controller.sendMessage(); + const firstInstance = mockDropdownInstance; + + // Create new mock instance for second call + const secondInstance = { isVisible: jest.fn().mockReturnValue(true), handleKeydown: jest.fn(), destroy: jest.fn() }; + (ResumeSessionDropdown as jest.Mock).mockImplementation(() => secondInstance); + + inputEl.value = '/resume'; + await controller.sendMessage(); + + expect(firstInstance.destroy).toHaveBeenCalled(); + expect(ResumeSessionDropdown).toHaveBeenCalledTimes(2); + }); + }); + + describe('Built-in commands - /fork', () => { + beforeEach(() => { + mockNotice.mockClear(); + }); + + it('should call onForkAll callback when /fork is executed', async () => { + const mockOnForkAll = jest.fn().mockResolvedValue(undefined); + deps.onForkAll = mockOnForkAll; + inputEl.value = '/fork'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockOnForkAll).toHaveBeenCalled(); + expect(inputEl.value).toBe(''); + }); + + it('should show notice when onForkAll is not available', async () => { + deps.onForkAll = undefined; + inputEl.value = '/fork'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockNotice).toHaveBeenCalledWith('Fork not available.'); + expect(inputEl.value).toBe(''); + }); + }); + + describe('Cancel streaming - restore behavior', () => { + it('should set cancelRequested and call agent cancel', () => { + deps.state.isStreaming = true; + controller = new InputController(deps); + + controller.cancelStreaming(); + + expect(deps.state.cancelRequested).toBe(true); + expect((deps as any).mockAgentService.cancel).toHaveBeenCalled(); + }); + + it('should restore queued message to input when cancelling', () => { + deps.state.isStreaming = true; + deps.state.queuedMessage = { content: 'restored text', images: undefined, editorContext: null, canvasContext: null }; + controller = new InputController(deps); + + controller.cancelStreaming(); + + expect(deps.state.queuedMessage).toBeNull(); + expect(inputEl.value).toBe('restored text'); + }); + + it('should restore queued images to image context manager when cancelling', () => { + deps.state.isStreaming = true; + const mockImages = [{ id: 'img1', name: 'test.png' }]; + deps.state.queuedMessage = { content: 'msg', images: mockImages as any, editorContext: null, canvasContext: null }; + + controller = new InputController(deps); + controller.cancelStreaming(); + + const imageContextManager = deps.getImageContextManager()!; + expect(imageContextManager.setImages).toHaveBeenCalledWith(mockImages); + }); + + it('should hide thinking indicator when cancelling', () => { + deps.state.isStreaming = true; + controller = new InputController(deps); + + controller.cancelStreaming(); + + expect(deps.streamController.hideThinkingIndicator).toHaveBeenCalled(); + }); + + it('should be a no-op when not streaming', () => { + deps.state.isStreaming = false; + controller = new InputController(deps); + + controller.cancelStreaming(); + + expect(deps.state.cancelRequested).toBe(false); + expect((deps as any).mockAgentService.cancel).not.toHaveBeenCalled(); + }); + }); + + describe('ensureServiceInitialized failure', () => { + beforeEach(() => { + mockNotice.mockClear(); + }); + + it('should show Notice and reset streaming when ensureServiceInitialized returns false', async () => { + deps = createSendableDeps({ + ensureServiceInitialized: jest.fn().mockResolvedValue(false), + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockNotice).toHaveBeenCalledWith('Failed to initialize agent service. Please try again.'); + expect(deps.streamController.hideThinkingIndicator).toHaveBeenCalled(); + expect(deps.state.isStreaming).toBe(false); + expect(deps.state.hasPendingConversationSave).toBe(false); + expect(deps.state.messages).toEqual([]); + expect(inputEl.value).toBe('test message'); + expect((deps as any).mockAgentService.query).not.toHaveBeenCalled(); + }); + }); + + describe('Agent service null', () => { + beforeEach(() => { + mockNotice.mockClear(); + }); + + it('should show Notice when getAgentService returns null', async () => { + deps = createSendableDeps({ + getAgentService: () => null, + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockNotice).toHaveBeenCalledWith('Agent service not available. Please reload the plugin.'); + expect(deps.state.hasPendingConversationSave).toBe(false); + expect(deps.state.isStreaming).toBe(false); + expect(deps.state.messages).toEqual([]); + expect(inputEl.value).toBe('test message'); + expect((deps as any).mockAgentService.query).not.toHaveBeenCalled(); + }); + }); + + describe('Streaming error handling', () => { + it('should catch errors and display via appendText', async () => { + deps = createSendableDeps(); + + ((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => { + throw new Error('Network timeout'); + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(deps.streamController.appendText).toHaveBeenCalledWith('\n\n**Error:** Network timeout'); + expect(deps.state.isStreaming).toBe(false); + }); + + it('should handle non-Error thrown values', async () => { + deps = createSendableDeps(); + + ((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => { + throw 'string error'; + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(deps.streamController.appendText).toHaveBeenCalledWith('\n\n**Error:** Unknown error'); + }); + }); + + describe('Stream interruption', () => { + it('should append interrupted text when cancelRequested is true', async () => { + deps = createSendableDeps(); + + ((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => { + return (async function* () { + // Simulate cancel requested during streaming + deps.state.cancelRequested = true; + yield { type: 'text', content: 'partial' }; + })(); + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(deps.streamController.appendText).toHaveBeenCalledWith( + expect.stringContaining('Interrupted') + ); + expect(deps.state.isStreaming).toBe(false); + expect(deps.state.cancelRequested).toBe(false); + }); + + it('should append interrupted text when cancelRequested is set after last stream chunk', async () => { + deps = createSendableDeps(); + + ((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => { + return (async function* () { + yield { type: 'text', content: 'partial' }; + })(); + }); + (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async () => { + deps.state.cancelRequested = true; + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(deps.streamController.appendText).toHaveBeenCalledWith( + expect.stringContaining('Interrupted') + ); + expect(deps.state.isStreaming).toBe(false); + expect(deps.state.cancelRequested).toBe(false); + }); + }); + + describe('Duration footer', () => { + it('should render response duration footer when durationSeconds > 0', async () => { + deps = createSendableDeps(); + + // First call sets responseStartTime; must be non-zero (0 is falsy and skips duration) + let callCount = 0; + jest.spyOn(performance, 'now').mockImplementation(() => { + callCount++; + // Returns 1000 for responseStartTime, 6000 for elapsed (5 seconds) + return callCount <= 1 ? 1000 : 6000; + }); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([{ type: 'done' }]) + ); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + const assistantMsg = deps.state.messages.find((m: any) => m.role === 'assistant'); + expect(assistantMsg).toBeDefined(); + expect(assistantMsg!.durationSeconds).toBe(5); + expect(assistantMsg!.durationFlavorWord).toBeDefined(); + + jest.spyOn(performance, 'now').mockRestore(); + }); + + it('should sync to the true bottom after response completion UI updates', async () => { + const messagesEl = createMockEl(); + messagesEl.scrollTop = 120; + messagesEl.scrollHeight = 640; + messagesEl.clientHeight = 400; + + deps = createSendableDeps({ + getMessagesEl: () => messagesEl as any, + }); + + let callCount = 0; + jest.spyOn(performance, 'now').mockImplementation(() => { + callCount++; + return callCount <= 1 ? 1000 : 6000; + }); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([{ type: 'done' }]) + ); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(messagesEl.scrollTop).toBe(messagesEl.scrollHeight); + jest.spyOn(performance, 'now').mockRestore(); + }); + }); + + describe('External context in query', () => { + it('should pass externalContextPaths in queryOptions', async () => { + const externalPaths = ['/external/path1', '/external/path2']; + + deps = createSendableDeps({ + getExternalContextSelector: () => ({ + getExternalContexts: () => externalPaths, + addExternalContext: jest.fn(), + }), + }); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([{ type: 'done' }]) + ); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + const prepareTurnCall = ((deps as any).mockAgentService.prepareTurn as jest.Mock).mock.calls[0]; + expect(prepareTurnCall[0].externalContextPaths).toEqual(externalPaths); + }); + }); + + describe('Editor context', () => { + it('should append editorContext to prompt when available', async () => { + const editorContext = { + notePath: 'test/note.md', + mode: 'selection' as const, + selectedText: 'selected text content', + }; + + deps = createSendableDeps(); + (deps.selectionController.getContext as jest.Mock).mockReturnValue(editorContext); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([{ type: 'done' }]) + ); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'hello'; + controller = new InputController(deps); + + await controller.sendMessage(); + + const queryCall = ((deps as any).mockAgentService.query as jest.Mock).mock.calls[0]; + const promptSent = queryCall[0].prompt; + expect(promptSent).toContain('selected text content'); + expect(promptSent).toContain('test/note.md'); + }); + + it('should preserve preview selection text without fabricating line attributes', async () => { + const editorContext = { + notePath: 'test/note.md', + mode: 'selection' as const, + selectedText: ' selected text\nsecond line ', + lineCount: 2, + }; + + deps = createSendableDeps(); + (deps.selectionController.getContext as jest.Mock).mockReturnValue(editorContext); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([{ type: 'done' }]) + ); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'hello'; + controller = new InputController(deps); + + await controller.sendMessage(); + + const queryCall = ((deps as any).mockAgentService.query as jest.Mock).mock.calls[0]; + const promptSent = queryCall[0].prompt; + expect(promptSent).toContain('\n selected text\nsecond line \n'); + expect(promptSent).not.toContain('lines='); + }); + }); + + describe('Built-in commands - unknown', () => { + beforeEach(() => { + mockNotice.mockClear(); + }); + + it('should show Notice for unknown built-in command', async () => { + // Directly call the private method since there's no public API to trigger unknown commands + controller = new InputController(deps); + + await (controller as any).executeBuiltInCommand({ action: 'nonexistent-command', name: 'nonexistent-command' }, ''); + + expect(mockNotice).toHaveBeenCalledWith('Unknown command: nonexistent-command'); + }); + }); + + describe('Title generation callback branches', () => { + it('should rename conversation when title generation callback succeeds', async () => { + const mockTitleService = { + generateTitle: jest.fn().mockImplementation( + async (convId: string, _user: string, callback: any) => { + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: convId, + title: 'Test Title', + }); + await callback(convId, { success: true, title: 'AI Generated Title' }); + } + ), + cancel: jest.fn(), + }; + + deps = createSendableDeps({ + getTitleGenerationService: () => mockTitleService as any, + }); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([{ type: 'text', content: 'Response' }, { type: 'done' }]) + ); + + (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { + if (chunk.type === 'text') msg.content = chunk.content; + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'Hello world'; + controller = new InputController(deps); + + await controller.sendMessage(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'AI Generated Title'); + expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { + titleGenerationStatus: 'success', + }); + }); + + it('should mark as failed when title generation callback fails', async () => { + const mockTitleService = { + generateTitle: jest.fn().mockImplementation( + async (convId: string, _user: string, callback: any) => { + (deps.plugin.getConversationById as jest.Mock).mockResolvedValue({ + id: convId, + title: 'Test Title', + }); + await callback(convId, { success: false, title: '' }); + } + ), + cancel: jest.fn(), + }; + + deps = createSendableDeps({ + getTitleGenerationService: () => mockTitleService as any, + }); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([{ type: 'text', content: 'Response' }, { type: 'done' }]) + ); + + (deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => { + if (chunk.type === 'text') msg.content = chunk.content; + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'Hello world'; + controller = new InputController(deps); + + await controller.sendMessage(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { + titleGenerationStatus: 'failed', + }); + }); + }); + + describe('handleApprovalRequest', () => { + it('should create inline approval and store as pending', async () => { + const parentEl = createMockEl(); + const inputContainerEl = createMockEl(); + (inputContainerEl as any).parentElement = parentEl; + deps.getInputContainerEl = () => inputContainerEl as any; + + controller = new InputController(deps); + + const approvalPromise = controller.handleApprovalRequest( + 'bash', + { command: 'ls -la' }, + 'Run shell command' + ); + + expect((controller as any).pendingApprovalInline).not.toBeNull(); + + controller.dismissPendingApproval(); + expect((controller as any).pendingApprovalInline).toBeNull(); + + const result = await approvalPromise; + expect(result).toBe('cancel'); + }); + + it('should throw when input container has no parent', async () => { + const inputContainerEl = createMockEl(); + // no parentElement set + deps.getInputContainerEl = () => inputContainerEl as any; + + controller = new InputController(deps); + await expect(controller.handleApprovalRequest('bash', {}, 'test')) + .rejects.toThrow('Input container is detached from DOM'); + }); + + it.each([ + ['Deny', 'deny'], + ['Allow once', 'allow'], + ['Always allow', 'allow-always'], + ] as const)('should return "%s" → "%s"', async (optionLabel, expected) => { + const parentEl = createMockEl(); + const inputContainerEl = createMockEl(); + (inputContainerEl as any).parentElement = parentEl; + deps.getInputContainerEl = () => inputContainerEl as any; + + controller = new InputController(deps); + + const approvalPromise = controller.handleApprovalRequest( + 'bash', + { command: 'ls -la' }, + 'Run shell command', + ); + + const items = parentEl.querySelectorAll('claudian-ask-item'); + const target = items.find((item: any) => { + const label = item.querySelector('claudian-ask-item-label'); + return label?.textContent === optionLabel; + }); + expect(target).toBeDefined(); + target!.click(); + + const result = await approvalPromise; + expect(result).toBe(expected); + }); + + it('should render header metadata when approvalOptions provided', async () => { + const parentEl = createMockEl(); + const inputContainerEl = createMockEl(); + (inputContainerEl as any).parentElement = parentEl; + deps.getInputContainerEl = () => inputContainerEl as any; + + controller = new InputController(deps); + + const approvalPromise = controller.handleApprovalRequest( + 'bash', + { command: 'rm -rf /' }, + 'Run dangerous command', + { + decisionReason: 'Command is destructive', + blockedPath: '/usr/bin/rm', + agentID: 'agent-42', + }, + ); + + const reasonEl = parentEl.querySelector('claudian-ask-approval-reason'); + expect(reasonEl?.textContent).toBe('Command is destructive'); + + const pathEl = parentEl.querySelector('claudian-ask-approval-blocked-path'); + expect(pathEl?.textContent).toBe('/usr/bin/rm'); + + const agentEl = parentEl.querySelector('claudian-ask-approval-agent'); + expect(agentEl?.textContent).toBe('Agent: agent-42'); + + controller.dismissPendingApproval(); + await approvalPromise; + }); + + it('should render provider-supplied approval options and network-specific context', async () => { + const parentEl = createMockEl(); + const inputContainerEl = createMockEl(); + (inputContainerEl as any).parentElement = parentEl; + deps.getInputContainerEl = () => inputContainerEl as any; + + controller = new InputController(deps); + + const approvalPromise = controller.handleApprovalRequest( + 'Bash', + { command: 'curl https://api.openai.com' }, + 'Allow https access to api.openai.com', + { + networkApprovalContext: { host: 'api.openai.com', protocol: 'https' }, + decisionOptions: [ + { label: 'Allow once', decision: 'allow' }, + { + label: 'Allow similar commands', + description: 'Approve and store an exec policy amendment.', + decision: { + type: 'allow-with-exec-policy-amendment', + execPolicyAmendment: ['curl', 'https://api.openai.com/*'], + }, + }, + { label: 'Deny', decision: 'deny' }, + ], + } as any, + ); + + const descEl = parentEl.querySelector('claudian-ask-approval-desc'); + expect(descEl?.textContent).toContain('api.openai.com'); + + const items = parentEl.querySelectorAll('claudian-ask-item'); + const labels = items + .map((item: any) => item.querySelector('claudian-ask-item-label')?.textContent) + .filter(Boolean); + expect(labels).toEqual(expect.arrayContaining([ + 'Allow once', + 'Allow similar commands', + 'Deny', + ])); + + controller.dismissPendingApproval(); + await approvalPromise; + }); + + it.each([ + ['Allow once', 'approval-allow-once', 'allow'], + ['Always allow', 'approval-allow-always', 'allow-always'], + ['Reject', 'approval-reject', { type: 'select-option', value: 'approval-reject' }], + ] as const)( + 'preserves provider option values for "%s"', + async (optionLabel, optionValue, expectedDecision) => { + const parentEl = createMockEl(); + const inputContainerEl = createMockEl(); + (inputContainerEl as any).parentElement = parentEl; + deps.getInputContainerEl = () => inputContainerEl as any; + + controller = new InputController(deps); + + const approvalPromise = controller.handleApprovalRequest( + 'External Directory', + { filepath: '/tmp/outside' }, + 'OpenCode wants to access a path outside the working directory.', + { + decisionOptions: [ + { label: 'Allow once', value: 'approval-allow-once', decision: 'allow' }, + { label: 'Always allow', value: 'approval-allow-always', decision: 'allow-always' }, + { label: 'Reject', value: 'approval-reject' }, + ], + }, + ); + + const items = parentEl.querySelectorAll('claudian-ask-item'); + const target = items.find((item: any) => { + const label = item.querySelector('claudian-ask-item-label'); + return label?.textContent === optionLabel; + }); + expect(target).toBeDefined(); + target!.click(); + + await expect(approvalPromise).resolves.toEqual(expectedDecision); + }, + ); + + it('should return provider-specific amendment decisions from supplied approval options', async () => { + const parentEl = createMockEl(); + const inputContainerEl = createMockEl(); + (inputContainerEl as any).parentElement = parentEl; + deps.getInputContainerEl = () => inputContainerEl as any; + + controller = new InputController(deps); + + const approvalPromise = controller.handleApprovalRequest( + 'Bash', + { command: 'npm test' }, + 'Run test command', + { + decisionOptions: [ + { + label: 'Allow similar commands', + decision: { + type: 'allow-with-exec-policy-amendment', + execPolicyAmendment: ['npm', 'test'], + }, + }, + { label: 'Deny', decision: 'deny' }, + ], + } as any, + ); + + const items = parentEl.querySelectorAll('claudian-ask-item'); + const target = items.find((item: any) => { + const label = item.querySelector('claudian-ask-item-label'); + return label?.textContent === 'Allow similar commands'; + }); + expect(target).toBeDefined(); + target!.click(); + + await expect(approvalPromise).resolves.toEqual({ + type: 'allow-with-exec-policy-amendment', + execPolicyAmendment: ['npm', 'test'], + }); + }); + + it('should restore input visibility after overlapping inline prompts are dismissed', async () => { + const parentEl = createMockEl(); + const inputContainerEl = createMockEl(); + (inputContainerEl as any).parentElement = parentEl; + deps.getInputContainerEl = () => inputContainerEl as any; + + controller = new InputController(deps); + + const approvalPromise = controller.handleApprovalRequest( + 'bash', + { command: 'ls -la' }, + 'Run shell command', + ); + const askPromise = controller.handleAskUserQuestion({ + questions: [ + { + question: 'Select one option', + options: ['Option A', 'Option B'], + }, + ], + }); + + expect(inputContainerEl.style.display).toBe('none'); + + controller.dismissPendingApproval(); + + await expect(approvalPromise).resolves.toBe('cancel'); + await expect(askPromise).resolves.toBeNull(); + expect(inputContainerEl.style.display).toBe(''); + }); + + it('should keep input hidden until overlapping exit-plan prompt is dismissed', async () => { + const parentEl = createMockEl(); + const inputContainerEl = createMockEl(); + (inputContainerEl as any).parentElement = parentEl; + deps.getInputContainerEl = () => inputContainerEl as any; + + controller = new InputController(deps); + + const approvalPromise = controller.handleApprovalRequest( + 'bash', + { command: 'ls -la' }, + 'Run shell command', + ); + const exitPlanPromise = controller.handleExitPlanMode({}); + + expect(inputContainerEl.style.display).toBe('none'); + + const items = parentEl.querySelectorAll('claudian-ask-item'); + const allowOnceItem = items.find((item: any) => { + const label = item.querySelector('claudian-ask-item-label'); + return label?.textContent === 'Allow once'; + }); + expect(allowOnceItem).toBeDefined(); + + allowOnceItem!.click(); + await expect(approvalPromise).resolves.toBe('allow'); + expect(inputContainerEl.style.display).toBe('none'); + + controller.dismissPendingApproval(); + await expect(exitPlanPromise).resolves.toBeNull(); + expect(inputContainerEl.style.display).toBe(''); + }); + }); + + describe('handleInstructionSubmit', () => { + it('should create InstructionModal and call refineInstruction', async () => { + const mockInstructionRefineService = createMockInstructionRefineService({ + refineInstruction: jest.fn().mockResolvedValue({ + success: true, + refinedInstruction: 'refined instruction', + }), + }); + const mockInstructionModeManager = createMockInstructionModeManager(); + + deps = createMockDeps({ + getInstructionRefineService: () => mockInstructionRefineService as any, + getInstructionModeManager: () => mockInstructionModeManager as any, + }); + deps.plugin.settings.systemPrompt = ''; + + controller = new InputController(deps); + + await controller.handleInstructionSubmit('add logging'); + + expect(mockInstructionRefineService.resetConversation).toHaveBeenCalled(); + expect(mockInstructionRefineService.refineInstruction).toHaveBeenCalledWith( + 'add logging', + '' + ); + }); + + it('should pass the active chat model into instruction refine service', async () => { + const mockInstructionRefineService = createMockInstructionRefineService({ + refineInstruction: jest.fn().mockResolvedValue({ + success: true, + refinedInstruction: 'refined instruction', + }), + }); + + deps = createMockDeps({ + getAuxiliaryModel: () => 'opencode:openai/gpt-5.4', + getInstructionRefineService: () => mockInstructionRefineService as any, + }); + deps.plugin.settings.systemPrompt = ''; + + controller = new InputController(deps); + + await controller.handleInstructionSubmit('add logging'); + + expect(mockInstructionRefineService.setModelOverride).toHaveBeenCalledWith( + 'opencode:openai/gpt-5.4', + ); + }); + + it('should return early when instructionRefineService is null', async () => { + deps = createMockDeps({ + getInstructionRefineService: () => null, + }); + controller = new InputController(deps); + + await expect(controller.handleInstructionSubmit('test')).resolves.not.toThrow(); + }); + }); + + describe('processQueuedMessage sends the queued snapshot', () => { + it('should send images from the queued message without rebuilding composer state', () => { + jest.useFakeTimers(); + try { + const mockImages = [{ id: 'img1', name: 'test.png' }]; + deps.state.queuedMessage = { + content: 'queued content', + images: mockImages as any, + editorContext: null, + canvasContext: null, + }; + const sendSpy = jest.spyOn(controller, 'sendMessage').mockResolvedValue(undefined); + + (controller as any).processQueuedMessage(); + jest.runAllTimers(); + + expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({ + content: 'queued content', + images: mockImages, + turnRequestOverride: expect.objectContaining({ + text: 'queued content', + images: mockImages, + }), + })); + sendSpy.mockRestore(); + } finally { + jest.useRealTimers(); + } + }); + }); + + describe('Sending messages - edge cases', () => { + it('should not send empty message without images', async () => { + inputEl.value = ''; + const imageContextManager = deps.getImageContextManager()!; + (imageContextManager.hasImages as jest.Mock).mockReturnValue(false); + + await controller.sendMessage(); + + expect((deps as any).mockAgentService.query).not.toHaveBeenCalled(); + }); + + it('should send message with only images (empty text)', async () => { + const imageContextManager = createMockImageContextManager(); + (imageContextManager.hasImages as jest.Mock).mockReturnValue(true); + (imageContextManager.getAttachedImages as jest.Mock).mockReturnValue([{ id: 'img1', name: 'test.png' }]); + + deps = createSendableDeps({ + getImageContextManager: () => imageContextManager as any, + }); + + ((deps as any).mockAgentService.query as jest.Mock).mockReturnValue( + createMockStream([{ type: 'done' }]) + ); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = ''; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect((deps as any).mockAgentService.query).toHaveBeenCalled(); + expect(deps.state.messages).toHaveLength(2); + expect(deps.state.messages[0].images).toHaveLength(1); + }); + }); + + describe('Stream invalidation', () => { + it('should break from stream loop and skip cleanup when stream generation changes', async () => { + deps = createSendableDeps(); + + ((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => { + return (async function* () { + yield { type: 'text', content: 'partial' }; + // Simulate stream invalidation (e.g. tab closed during stream) + deps.state.bumpStreamGeneration(); + yield { type: 'text', content: 'should not be processed' }; + })(); + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'test message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + // The stream was invalidated, so isStreaming should still be true + // (cleanup was skipped) and no interrupt text should appear + expect(deps.streamController.appendText).not.toHaveBeenCalledWith( + expect.stringContaining('Interrupted') + ); + }); + }); + + describe('handleInstructionSubmit - advanced paths', () => { + it('should show clarification when result has clarification', async () => { + const mockInstructionRefineService = createMockInstructionRefineService({ + refineInstruction: jest.fn().mockResolvedValue({ + success: true, + clarification: 'Please clarify what you mean', + }), + }); + const mockInstructionModeManager = createMockInstructionModeManager(); + + deps = createMockDeps({ + getInstructionRefineService: () => mockInstructionRefineService as any, + getInstructionModeManager: () => mockInstructionModeManager as any, + }); + controller = new InputController(deps); + + await controller.handleInstructionSubmit('ambiguous instruction'); + + expect(mockInstructionRefineService.refineInstruction).toHaveBeenCalledWith( + 'ambiguous instruction', + undefined + ); + }); + + it('should show error when result has no clarification or instruction', async () => { + const mockInstructionRefineService = createMockInstructionRefineService(); + const mockInstructionModeManager = createMockInstructionModeManager(); + + deps = createMockDeps({ + getInstructionRefineService: () => mockInstructionRefineService as any, + getInstructionModeManager: () => mockInstructionModeManager as any, + }); + controller = new InputController(deps); + mockNotice.mockClear(); + + await controller.handleInstructionSubmit('empty result'); + + expect(mockNotice).toHaveBeenCalledWith('No instruction received'); + expect(mockInstructionModeManager.clear).toHaveBeenCalled(); + }); + + it('should handle cancelled result from refineInstruction', async () => { + const mockInstructionRefineService = createMockInstructionRefineService({ + refineInstruction: jest.fn().mockResolvedValue({ + success: false, + error: 'Cancelled', + }), + }); + const mockInstructionModeManager = createMockInstructionModeManager(); + + deps = createMockDeps({ + getInstructionRefineService: () => mockInstructionRefineService as any, + getInstructionModeManager: () => mockInstructionModeManager as any, + }); + controller = new InputController(deps); + + await controller.handleInstructionSubmit('cancelled instruction'); + + expect(mockInstructionModeManager.clear).toHaveBeenCalled(); + expect(mockNotice).not.toHaveBeenCalledWith(expect.stringContaining('Cancelled')); + }); + + it('should handle non-cancelled error from refineInstruction', async () => { + const mockInstructionRefineService = createMockInstructionRefineService({ + refineInstruction: jest.fn().mockResolvedValue({ + success: false, + error: 'API Error', + }), + }); + const mockInstructionModeManager = createMockInstructionModeManager(); + + deps = createMockDeps({ + getInstructionRefineService: () => mockInstructionRefineService as any, + getInstructionModeManager: () => mockInstructionModeManager as any, + }); + controller = new InputController(deps); + mockNotice.mockClear(); + + await controller.handleInstructionSubmit('error instruction'); + + expect(mockNotice).toHaveBeenCalledWith('API Error'); + expect(mockInstructionModeManager.clear).toHaveBeenCalled(); + }); + + it('should handle exception thrown during refineInstruction', async () => { + const mockInstructionRefineService = createMockInstructionRefineService({ + refineInstruction: jest.fn().mockRejectedValue(new Error('Unexpected error')), + }); + const mockInstructionModeManager = createMockInstructionModeManager(); + + deps = createMockDeps({ + getInstructionRefineService: () => mockInstructionRefineService as any, + getInstructionModeManager: () => mockInstructionModeManager as any, + }); + controller = new InputController(deps); + mockNotice.mockClear(); + + await controller.handleInstructionSubmit('error instruction'); + + expect(mockNotice).toHaveBeenCalledWith('Error: Unexpected error'); + expect(mockInstructionModeManager.clear).toHaveBeenCalled(); + }); + }); + + describe('resumeAtMessageId lifecycle', () => { + beforeEach(() => { + mockNotice.mockClear(); + }); + + it('should call setResumeCheckpoint when resumeAtMessageId points to last assistant (still-needed)', async () => { + deps = createSendableDeps(); + const { mockAgentService } = deps as any; + mockAgentService.setResumeCheckpoint = jest.fn(); + mockAgentService.query = jest.fn().mockReturnValue(createMockStream([{ type: 'done' }])); + + // Pre-populate messages: user → assistant (with assistantMessageId matching resumeAtMessageId) + deps.state.messages = [ + { id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'u1' }, + { id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, assistantMessageId: 'a1' }, + ]; + + // Set conversation with resumeAtMessageId + (deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({ + id: 'conv-1', + resumeAtMessageId: 'a1', + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'follow up'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockAgentService.setResumeCheckpoint).toHaveBeenCalledWith('a1'); + // Should NOT clear metadata eagerly (clearing is done by save(true)) + expect(deps.plugin.updateConversation).not.toHaveBeenCalledWith('conv-1', { resumeAtMessageId: undefined }); + }); + + it('should NOT call setResumeCheckpoint when follow-up already exists (stale)', async () => { + deps = createSendableDeps(); + const { mockAgentService } = deps as any; + mockAgentService.setResumeCheckpoint = jest.fn(); + mockAgentService.query = jest.fn().mockReturnValue(createMockStream([{ type: 'done' }])); + + // Messages: user → assistant(a1) → user(follow-up) → assistant + // resumeAtMessageId=a1 is stale because there's a follow-up after a1 + deps.state.messages = [ + { id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'u1' }, + { id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, assistantMessageId: 'a1' }, + { id: 'msg-u2', role: 'user', content: 'follow up', timestamp: 3, userMessageId: 'u2' }, + { id: 'msg-a2', role: 'assistant', content: 'response', timestamp: 4, assistantMessageId: 'a2' }, + ]; + + (deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({ + id: 'conv-1', + resumeAtMessageId: 'a1', + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'another message'; + controller = new InputController(deps); + + await controller.sendMessage(); + + expect(mockAgentService.setResumeCheckpoint).not.toHaveBeenCalled(); + // Should clear stale metadata + expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { resumeAtMessageId: undefined }); + }); + + it('should clear resumeAtMessageId on save when turn metadata reports the message was sent', async () => { + deps = createSendableDeps(); + const { mockAgentService } = deps as any; + mockAgentService.setResumeCheckpoint = jest.fn(); + mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ wasSent: true }); + mockAgentService.query = jest.fn().mockReturnValue( + createMockStream([ + { type: 'text', content: 'hi' }, + { type: 'done' }, + ]) + ); + + deps.state.messages = [ + { id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'u1' }, + { id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, assistantMessageId: 'a1' }, + ]; + + (deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({ + id: 'conv-1', + resumeAtMessageId: 'a1', + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'follow up'; + controller = new InputController(deps); + + await controller.sendMessage(); + + // save(true) should include { resumeAtMessageId: undefined } because the turn metadata reports a sent message + expect(deps.conversationController.save).toHaveBeenCalledWith(true, { resumeAtMessageId: undefined }); + }); + + it('should NOT clear resumeAtMessageId on save when query fails before enqueue', async () => { + deps = createSendableDeps(); + const { mockAgentService } = deps as any; + mockAgentService.setResumeCheckpoint = jest.fn(); + // Stream throws before yielding user_message_sent + mockAgentService.query = jest.fn().mockImplementation(() => { + throw new Error('Connection failed'); + }); + + deps.state.messages = [ + { id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'u1' }, + { id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, assistantMessageId: 'a1' }, + ]; + + (deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({ + id: 'conv-1', + resumeAtMessageId: 'a1', + }); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'follow up'; + controller = new InputController(deps); + + await controller.sendMessage(); + + // save(true) should NOT clear resumeAtMessageId because user_message_sent was never received + expect(deps.conversationController.save).toHaveBeenCalledWith(true, undefined); + }); + + it('should not block send when stale metadata clear fails', async () => { + deps = createSendableDeps(); + const { mockAgentService } = deps as any; + mockAgentService.setResumeCheckpoint = jest.fn(); + mockAgentService.query = jest.fn().mockReturnValue(createMockStream([{ type: 'done' }])); + + deps.state.messages = [ + { id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'u1' }, + { id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, assistantMessageId: 'a1' }, + { id: 'msg-u2', role: 'user', content: 'next', timestamp: 3, userMessageId: 'u2' }, + { id: 'msg-a2', role: 'assistant', content: 'resp', timestamp: 4, assistantMessageId: 'a2' }, + ]; + + (deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({ + id: 'conv-1', + resumeAtMessageId: 'a1', + }); + // Make updateConversation throw + (deps.plugin.updateConversation as jest.Mock).mockRejectedValueOnce(new Error('disk error')); + + inputEl = deps.getInputEl() as ReturnType; + inputEl.value = 'test'; + controller = new InputController(deps); + + // Should not throw + await expect(controller.sendMessage()).resolves.not.toThrow(); + expect(mockAgentService.query).toHaveBeenCalled(); + }); + }); + + describe('Codex plan_completed flow', () => { + it('opens the Codex approval UI after a successful plan turn', async () => { + const deps = createSendableDeps({ + restorePrePlanPermissionModeIfNeeded: jest.fn(), + }); + const mockAgentService = (deps as any).mockAgentService; + mockAgentService.providerId = 'codex'; + mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ planCompleted: true, wasSent: true }); + mockAgentService.query = jest.fn().mockImplementation(() => + createMockStream([ + { type: 'text', content: 'Here is my plan...' }, + { type: 'done' }, + ]), + ); + const inputEl = deps.getInputEl(); + inputEl.value = 'Plan the migration'; + const controller = new InputController(deps); + const showPlanApproval = jest.spyOn(controller as any, 'showPlanApproval').mockResolvedValue({ + decision: null, + invalidated: false, + }); + + await controller.sendMessage(); + + expect(showPlanApproval).toHaveBeenCalled(); + }); + + it('implement restores mode and auto-sends follow-up', async () => { + const restoreFn = jest.fn(); + const deps = createSendableDeps({ + restorePrePlanPermissionModeIfNeeded: restoreFn, + }); + const mockAgentService = (deps as any).mockAgentService; + mockAgentService.providerId = 'codex'; + mockAgentService.consumeTurnMetadata = jest.fn() + .mockReturnValueOnce({ planCompleted: true, wasSent: true }) + .mockReturnValueOnce({ wasSent: true }); + + let callCount = 0; + mockAgentService.query = jest.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return createMockStream([ + { type: 'text', content: 'Plan content' }, + { type: 'done' }, + ]); + } + return createMockStream([{ type: 'done' }]); + }); + + const controller = new InputController(deps); + + // Mock the showPlanApproval to return 'implement' + (controller as any).showPlanApproval = jest.fn().mockResolvedValue({ + decision: { type: 'implement' }, + invalidated: false, + }); + + const inputEl = deps.getInputEl(); + inputEl.value = 'Plan this feature'; + await controller.sendMessage(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(restoreFn).toHaveBeenCalled(); + // Auto-send should have been triggered + expect(mockAgentService.query).toHaveBeenCalledTimes(2); + }); + + it('revise keeps plan mode active and populates input', async () => { + const restoreFn = jest.fn(); + const deps = createSendableDeps({ + restorePrePlanPermissionModeIfNeeded: restoreFn, + }); + const mockAgentService = (deps as any).mockAgentService; + mockAgentService.providerId = 'codex'; + mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ planCompleted: true, wasSent: true }); + mockAgentService.query = jest.fn().mockImplementation(() => + createMockStream([ + { type: 'text', content: 'Plan content' }, + { type: 'done' }, + ]), + ); + + const controller = new InputController(deps); + (controller as any).showPlanApproval = jest.fn().mockResolvedValue({ + decision: { + type: 'revise', + text: 'Add more tests', + }, + invalidated: false, + }); + + const inputEl = deps.getInputEl(); + inputEl.value = 'Plan this'; + await controller.sendMessage(); + + expect(restoreFn).not.toHaveBeenCalled(); + expect(inputEl.value).toBe('Add more tests'); + }); + + it('revise does not let queued input overwrite the revision text', async () => { + const restoreFn = jest.fn(); + const deps = createSendableDeps({ + restorePrePlanPermissionModeIfNeeded: restoreFn, + }); + deps.state.queuedMessage = { + content: 'queued follow-up', + images: undefined, + editorContext: null, + canvasContext: null, + }; + + const mockAgentService = (deps as any).mockAgentService; + mockAgentService.providerId = 'codex'; + mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ planCompleted: true, wasSent: true }); + mockAgentService.query = jest.fn().mockImplementation(() => + createMockStream([ + { type: 'text', content: 'Plan content' }, + { type: 'done' }, + ]), + ); + + const controller = new InputController(deps); + (controller as any).showPlanApproval = jest.fn().mockResolvedValue({ + decision: { type: 'revise', text: 'Add more tests' }, + invalidated: false, + }); + + const inputEl = deps.getInputEl(); + inputEl.value = 'Plan this'; + await controller.sendMessage(); + + expect(restoreFn).not.toHaveBeenCalled(); + expect(inputEl.value).toBe('Add more tests'); + expect(deps.state.queuedMessage).toEqual({ + content: 'queued follow-up', + images: undefined, + editorContext: null, + canvasContext: null, + }); + expect(mockAgentService.query).toHaveBeenCalledTimes(1); + }); + + it('cancel restores mode and does not auto-send', async () => { + const restoreFn = jest.fn(); + const deps = createSendableDeps({ + restorePrePlanPermissionModeIfNeeded: restoreFn, + }); + const mockAgentService = (deps as any).mockAgentService; + mockAgentService.providerId = 'codex'; + mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ planCompleted: true, wasSent: true }); + mockAgentService.query = jest.fn().mockImplementation(() => + createMockStream([ + { type: 'text', content: 'Plan content' }, + { type: 'done' }, + ]), + ); + + const controller = new InputController(deps); + (controller as any).showPlanApproval = jest.fn().mockResolvedValue({ + decision: { type: 'cancel' }, + invalidated: false, + }); + + const inputEl = deps.getInputEl(); + inputEl.value = 'Plan this'; + await controller.sendMessage(); + + expect(restoreFn).toHaveBeenCalled(); + expect(mockAgentService.query).toHaveBeenCalledTimes(1); + }); + + it('external dismissal while the approval UI is open bails out without save or restore', async () => { + const restoreFn = jest.fn(); + const parentEl = createMockEl(); + const inputContainerEl = createMockEl(); + inputContainerEl.parentElement = parentEl; + + const deps = createSendableDeps({ + getInputContainerEl: () => inputContainerEl as any, + restorePrePlanPermissionModeIfNeeded: restoreFn, + }); + const mockAgentService = (deps as any).mockAgentService; + mockAgentService.providerId = 'codex'; + mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ planCompleted: true, wasSent: true }); + mockAgentService.query = jest.fn().mockImplementation(() => + createMockStream([ + { type: 'text', content: 'Plan content' }, + { type: 'done' }, + ]), + ); + + const controller = new InputController(deps); + const inputEl = deps.getInputEl(); + inputEl.value = 'Plan this'; + + const sendPromise = controller.sendMessage(); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect((controller as any).pendingPlanApproval).not.toBeNull(); + + controller.dismissPendingApproval(); + await sendPromise; + + expect(restoreFn).not.toHaveBeenCalled(); + expect(deps.conversationController.save).not.toHaveBeenCalled(); + expect(mockAgentService.query).toHaveBeenCalledTimes(1); + }); + + it('null decision (dismiss) restores mode and does not auto-send', async () => { + const restoreFn = jest.fn(); + const deps = createSendableDeps({ + restorePrePlanPermissionModeIfNeeded: restoreFn, + }); + const mockAgentService = (deps as any).mockAgentService; + mockAgentService.providerId = 'codex'; + mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ planCompleted: true, wasSent: true }); + mockAgentService.query = jest.fn().mockImplementation(() => + createMockStream([ + { type: 'text', content: 'Plan content' }, + { type: 'done' }, + ]), + ); + + const controller = new InputController(deps); + (controller as any).showPlanApproval = jest.fn().mockResolvedValue({ + decision: null, + invalidated: false, + }); + + const inputEl = deps.getInputEl(); + inputEl.value = 'Plan this'; + await controller.sendMessage(); + + expect(restoreFn).toHaveBeenCalled(); + expect(mockAgentService.query).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/unit/features/chat/controllers/NavigationController.test.ts b/tests/unit/features/chat/controllers/NavigationController.test.ts new file mode 100644 index 0000000..ad3bb9e --- /dev/null +++ b/tests/unit/features/chat/controllers/NavigationController.test.ts @@ -0,0 +1,640 @@ +import { NavigationController, type NavigationControllerDeps } from '@/features/chat/controllers/NavigationController'; + +type Listener = (event: any) => void; + +/** Mock KeyboardEvent for Node environment. */ +class MockKeyboardEvent { + public type: string; + public key: string; + public cancelable: boolean; + public bubbles: boolean; + public ctrlKey: boolean; + public metaKey: boolean; + public altKey: boolean; + public shiftKey: boolean; + private defaultPrevented = false; + private propagationStopped = false; + + constructor(type: string, options: { + key: string; + cancelable?: boolean; + bubbles?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + } = { key: '' }) { + this.type = type; + this.key = options.key; + this.cancelable = options.cancelable ?? false; + this.bubbles = options.bubbles ?? false; + this.ctrlKey = options.ctrlKey ?? false; + this.metaKey = options.metaKey ?? false; + this.altKey = options.altKey ?? false; + this.shiftKey = options.shiftKey ?? false; + } + + preventDefault(): void { + if (this.cancelable) { + this.defaultPrevented = true; + } + } + + stopPropagation(): void { + this.propagationStopped = true; + } + + get defaultPreventedValue(): boolean { + return this.defaultPrevented; + } +} + +// Replace global KeyboardEvent if not defined +if (typeof KeyboardEvent === 'undefined') { + (global as any).KeyboardEvent = MockKeyboardEvent; +} + +/** Mock HTML element for testing. */ +class MockElement { + public tagName: string; + public scrollTop = 0; + public style: Record = {}; + public ownerDocument: Document; + private attributes: Map = new Map(); + private classes: Set = new Set(); + private listeners: Map = new Map(); + + constructor(tagName = 'DIV') { + this.tagName = tagName; + this.ownerDocument = (global as any).document; + } + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } + + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + + addClass(cls: string): void { + this.classes.add(cls); + } + + removeClass(cls: string): void { + this.classes.delete(cls); + } + + hasClass(cls: string): boolean { + return this.classes.has(cls); + } + + addEventListener(type: string, listener: Listener, options?: AddEventListenerOptions | boolean): void { + if (!this.listeners.has(type)) { + this.listeners.set(type, []); + } + const opts = typeof options === 'boolean' ? { capture: options } : options; + this.listeners.get(type)!.push({ listener, options: opts }); + } + + removeEventListener(type: string, listener: Listener, options?: AddEventListenerOptions | boolean): void { + const eventListeners = this.listeners.get(type); + if (eventListeners) { + const opts = typeof options === 'boolean' ? { capture: options } : options; + const idx = eventListeners.findIndex((l) => l.listener === listener && l.options?.capture === opts?.capture); + if (idx !== -1) { + eventListeners.splice(idx, 1); + } + } + } + + dispatchEvent(event: KeyboardEvent): boolean { + const eventListeners = this.listeners.get(event.type) ?? []; + // Sort by capture phase (capture first, then bubble) + const sortedListeners = [...eventListeners].sort((a, b) => { + const aCapture = a.options?.capture ?? false; + const bCapture = b.options?.capture ?? false; + return aCapture === bCapture ? 0 : aCapture ? -1 : 1; + }); + for (const { listener } of sortedListeners) { + listener(event); + } + return true; + } + + focus(): void { + // Mock focus + } + + blur(): void { + // Mock blur + } +} + +describe('NavigationController', () => { + let controller: NavigationController; + let messagesEl: MockElement; + let inputEl: MockElement; + let deps: NavigationControllerDeps; + let settings: { scrollUpKey: string; scrollDownKey: string; focusInputKey: string }; + let isStreaming: boolean; + let shouldSkipEscapeHandling: jest.Mock | undefined; + let mockRaf: jest.Mock; + let mockCancelRaf: jest.Mock; + let originalRaf: typeof requestAnimationFrame; + let originalCancelRaf: typeof cancelAnimationFrame; + let originalDocument: typeof document; + + beforeEach(() => { + jest.useFakeTimers(); + + // Save originals + originalRaf = global.requestAnimationFrame; + originalCancelRaf = global.cancelAnimationFrame; + originalDocument = (global as any).document; + + // Mock requestAnimationFrame + let rafId = 0; + mockRaf = jest.fn((cb: FrameRequestCallback) => { + rafId++; + setTimeout(() => cb(performance.now()), 16); + return rafId; + }); + mockCancelRaf = jest.fn(); + global.requestAnimationFrame = mockRaf; + global.cancelAnimationFrame = mockCancelRaf; + + // Mock document for event listeners + const documentListeners: Map = new Map(); + (global as any).document = { + defaultView: { + requestAnimationFrame: mockRaf, + cancelAnimationFrame: mockCancelRaf, + }, + addEventListener: (type: string, listener: Listener) => { + if (!documentListeners.has(type)) { + documentListeners.set(type, []); + } + documentListeners.get(type)!.push(listener); + }, + removeEventListener: (type: string, listener: Listener) => { + const listeners = documentListeners.get(type); + if (listeners) { + const idx = listeners.indexOf(listener); + if (idx !== -1) { + listeners.splice(idx, 1); + } + } + }, + dispatchEvent: (event: KeyboardEvent) => { + const listeners = documentListeners.get(event.type) ?? []; + for (const listener of listeners) { + listener(event); + } + return true; + }, + }; + + // Create mock elements + messagesEl = new MockElement('DIV'); + inputEl = new MockElement('TEXTAREA'); + settings = { scrollUpKey: 'w', scrollDownKey: 's', focusInputKey: 'i' }; + isStreaming = false; + shouldSkipEscapeHandling = undefined; + + deps = { + getMessagesEl: () => messagesEl as unknown as HTMLElement, + getInputEl: () => inputEl as unknown as HTMLTextAreaElement, + getSettings: () => settings, + isStreaming: () => isStreaming, + }; + + controller = new NavigationController(deps); + }); + + afterEach(() => { + if (controller) { + controller.dispose(); + } + jest.useRealTimers(); + + // Restore originals + global.requestAnimationFrame = originalRaf; + global.cancelAnimationFrame = originalCancelRaf; + (global as any).document = originalDocument; + }); + + describe('initialization', () => { + it('makes messagesEl focusable with tabindex', () => { + controller.initialize(); + expect(messagesEl.getAttribute('tabindex')).toBe('0'); + }); + + it('adds focusable CSS class to messagesEl', () => { + controller.initialize(); + expect(messagesEl.hasClass('claudian-messages-focusable')).toBe(true); + }); + + it('attaches keydown listener to messagesEl', () => { + const addEventListenerSpy = jest.spyOn(messagesEl, 'addEventListener'); + controller.initialize(); + expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); + }); + + it('attaches keyup listener to document', () => { + const addEventListenerSpy = jest.spyOn((global as any).document, 'addEventListener'); + controller.initialize(); + expect(addEventListenerSpy).toHaveBeenCalledWith('keyup', expect.any(Function)); + }); + + it('attaches keydown listener to inputEl with capture phase', () => { + const addEventListenerSpy = jest.spyOn(inputEl, 'addEventListener'); + controller.initialize(); + expect(addEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), { capture: true }); + }); + }); + + describe('disposal', () => { + it('removes keydown listener from messagesEl', () => { + controller.initialize(); + const removeEventListenerSpy = jest.spyOn(messagesEl, 'removeEventListener'); + controller.dispose(); + expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); + }); + + it('removes keyup listener from document', () => { + controller.initialize(); + const removeEventListenerSpy = jest.spyOn((global as any).document, 'removeEventListener'); + controller.dispose(); + expect(removeEventListenerSpy).toHaveBeenCalledWith('keyup', expect.any(Function)); + }); + + it('removes keydown listener from inputEl', () => { + controller.initialize(); + const removeEventListenerSpy = jest.spyOn(inputEl, 'removeEventListener'); + controller.dispose(); + expect(removeEventListenerSpy).toHaveBeenCalledWith('keydown', expect.any(Function), { capture: true }); + }); + + it('cancels ongoing animation frame', () => { + controller.initialize(); + + // Trigger scrolling + const keydownEvent = new KeyboardEvent('keydown', { key: 'w' }); + messagesEl.dispatchEvent(keydownEvent); + + controller.dispose(); + expect(mockCancelRaf).toHaveBeenCalled(); + }); + }); + + describe('scroll key handling', () => { + beforeEach(() => { + controller.initialize(); + messagesEl.scrollTop = 100; + }); + + it('scrolls up when scroll up key is pressed', () => { + const keydownEvent = new KeyboardEvent('keydown', { key: 'w' }); + messagesEl.dispatchEvent(keydownEvent); + + // Advance timers to trigger RAF callback + jest.advanceTimersByTime(16); + + expect(messagesEl.scrollTop).toBeLessThan(100); + }); + + it('scrolls down when scroll down key is pressed', () => { + const keydownEvent = new KeyboardEvent('keydown', { key: 's' }); + messagesEl.dispatchEvent(keydownEvent); + + jest.advanceTimersByTime(16); + + expect(messagesEl.scrollTop).toBeGreaterThan(100); + }); + + it('stops scrolling when key is released', () => { + // Start scrolling + const keydownEvent = new KeyboardEvent('keydown', { key: 'w' }); + messagesEl.dispatchEvent(keydownEvent); + + // Release key - this should trigger cancelAnimationFrame + const keyupEvent = new KeyboardEvent('keyup', { key: 'w' }); + (global as any).document.dispatchEvent(keyupEvent); + + // Scrolling should have stopped (cancelAnimationFrame was called) + expect(mockCancelRaf).toHaveBeenCalled(); + }); + + it('uses the messages owner window for scroll animation frames', () => { + const ownerRequestAnimationFrame = jest.fn, Parameters>() + .mockReturnValue(42); + const ownerCancelAnimationFrame = jest.fn(); + Object.defineProperty(messagesEl.ownerDocument, 'defaultView', { + configurable: true, + value: { + ...messagesEl.ownerDocument.defaultView, + requestAnimationFrame: ownerRequestAnimationFrame, + cancelAnimationFrame: ownerCancelAnimationFrame, + }, + }); + + messagesEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'w' })); + + expect(ownerRequestAnimationFrame).toHaveBeenCalledWith(expect.any(Function)); + expect(mockRaf).not.toHaveBeenCalled(); + + (global as any).document.dispatchEvent(new KeyboardEvent('keyup', { key: 'w' })); + + expect(ownerCancelAnimationFrame).toHaveBeenCalledWith(42); + expect(mockCancelRaf).not.toHaveBeenCalled(); + }); + + it('uses configured scroll keys (case insensitive)', () => { + settings.scrollUpKey = 'k'; + settings.scrollDownKey = 'j'; + + // 'w' should not scroll now + const keydownW = new KeyboardEvent('keydown', { key: 'w' }); + messagesEl.dispatchEvent(keydownW); + jest.advanceTimersByTime(16); + expect(messagesEl.scrollTop).toBe(100); + + // 'k' should scroll up + const keydownK = new KeyboardEvent('keydown', { key: 'K' }); // Test uppercase + messagesEl.dispatchEvent(keydownK); + jest.advanceTimersByTime(16); + expect(messagesEl.scrollTop).toBeLessThan(100); + }); + + it('prevents default on scroll key press', () => { + const keydownEvent = new KeyboardEvent('keydown', { key: 'w', cancelable: true }); + const preventDefaultSpy = jest.spyOn(keydownEvent, 'preventDefault'); + + messagesEl.dispatchEvent(keydownEvent); + + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('does not start duplicate scroll in same direction', () => { + // First keydown + const keydown1 = new KeyboardEvent('keydown', { key: 'w' }); + messagesEl.dispatchEvent(keydown1); + + const rafCallCount = mockRaf.mock.calls.length; + + // Second keydown in same direction + const keydown2 = new KeyboardEvent('keydown', { key: 'w' }); + messagesEl.dispatchEvent(keydown2); + + // RAF should not be called again (already scrolling) + expect(mockRaf.mock.calls.length).toBe(rafCallCount); + }); + + it('changes direction when opposite key pressed', () => { + messagesEl.scrollTop = 100; + + // Start scrolling up + const keydownUp = new KeyboardEvent('keydown', { key: 'w' }); + messagesEl.dispatchEvent(keydownUp); + jest.advanceTimersByTime(16); + + const scrollAfterUp = messagesEl.scrollTop; + expect(scrollAfterUp).toBeLessThan(100); + + // Change to scrolling down + const keydownDown = new KeyboardEvent('keydown', { key: 's' }); + messagesEl.dispatchEvent(keydownDown); + jest.advanceTimersByTime(16); + + expect(messagesEl.scrollTop).toBeGreaterThan(scrollAfterUp); + }); + + it('ignores scroll key when modifier keys are held (Ctrl)', () => { + const keydownEvent = new KeyboardEvent('keydown', { key: 'w', ctrlKey: true }); + messagesEl.dispatchEvent(keydownEvent); + jest.advanceTimersByTime(16); + + // scrollTop should not change - modifier key blocks scrolling + expect(messagesEl.scrollTop).toBe(100); + }); + + it('ignores scroll key when modifier keys are held (Meta/Cmd)', () => { + const keydownEvent = new KeyboardEvent('keydown', { key: 'w', metaKey: true }); + messagesEl.dispatchEvent(keydownEvent); + jest.advanceTimersByTime(16); + + expect(messagesEl.scrollTop).toBe(100); + }); + + it('ignores scroll key when modifier keys are held (Alt)', () => { + const keydownEvent = new KeyboardEvent('keydown', { key: 's', altKey: true }); + messagesEl.dispatchEvent(keydownEvent); + jest.advanceTimersByTime(16); + + expect(messagesEl.scrollTop).toBe(100); + }); + + it('ignores scroll key when modifier keys are held (Shift)', () => { + const keydownEvent = new KeyboardEvent('keydown', { key: 'w', shiftKey: true }); + messagesEl.dispatchEvent(keydownEvent); + jest.advanceTimersByTime(16); + + expect(messagesEl.scrollTop).toBe(100); + }); + }); + + describe('focus input key (i)', () => { + beforeEach(() => { + controller.initialize(); + }); + + it('focuses input when i is pressed on messages', () => { + const focusSpy = jest.spyOn(inputEl, 'focus'); + + const keydownEvent = new KeyboardEvent('keydown', { key: 'i' }); + messagesEl.dispatchEvent(keydownEvent); + + expect(focusSpy).toHaveBeenCalled(); + }); + + it('prevents default on i key press', () => { + const keydownEvent = new KeyboardEvent('keydown', { key: 'i', cancelable: true }); + const preventDefaultSpy = jest.spyOn(keydownEvent, 'preventDefault'); + + messagesEl.dispatchEvent(keydownEvent); + + expect(preventDefaultSpy).toHaveBeenCalled(); + }); + + it('works with uppercase I', () => { + const focusSpy = jest.spyOn(inputEl, 'focus'); + + const keydownEvent = new KeyboardEvent('keydown', { key: 'I' }); + messagesEl.dispatchEvent(keydownEvent); + + expect(focusSpy).toHaveBeenCalled(); + }); + + it('uses configured focus input key', () => { + settings.focusInputKey = 'a'; + const focusSpy = jest.spyOn(inputEl, 'focus'); + + // 'i' should not focus now + const keydownI = new KeyboardEvent('keydown', { key: 'i' }); + messagesEl.dispatchEvent(keydownI); + expect(focusSpy).not.toHaveBeenCalled(); + + // 'a' should focus + const keydownA = new KeyboardEvent('keydown', { key: 'a' }); + messagesEl.dispatchEvent(keydownA); + expect(focusSpy).toHaveBeenCalled(); + }); + }); + + describe('escape key in input', () => { + beforeEach(() => { + controller.initialize(); + }); + + it('blurs input and focuses messages when Escape pressed (not streaming)', () => { + isStreaming = false; + const blurSpy = jest.spyOn(inputEl, 'blur'); + const focusSpy = jest.spyOn(messagesEl, 'focus'); + + const keydownEvent = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true, bubbles: true }); + inputEl.dispatchEvent(keydownEvent); + + expect(blurSpy).toHaveBeenCalled(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('prevents default and stops propagation when Escape handled', () => { + isStreaming = false; + + const keydownEvent = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true, bubbles: true }); + const preventDefaultSpy = jest.spyOn(keydownEvent, 'preventDefault'); + const stopPropagationSpy = jest.spyOn(keydownEvent, 'stopPropagation'); + + inputEl.dispatchEvent(keydownEvent); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(stopPropagationSpy).toHaveBeenCalled(); + }); + + it('does not handle Escape when streaming (lets other handlers work)', () => { + isStreaming = true; + const blurSpy = jest.spyOn(inputEl, 'blur'); + + const keydownEvent = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true, bubbles: true }); + const preventDefaultSpy = jest.spyOn(keydownEvent, 'preventDefault'); + + inputEl.dispatchEvent(keydownEvent); + + expect(blurSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('does not handle Escape when shouldSkipEscapeHandling returns true', () => { + // Dispose current controller and create one with shouldSkipEscapeHandling + controller.dispose(); + + shouldSkipEscapeHandling = jest.fn().mockReturnValue(true); + controller = new NavigationController({ + ...deps, + shouldSkipEscapeHandling, + }); + controller.initialize(); + + isStreaming = false; + const blurSpy = jest.spyOn(inputEl, 'blur'); + + const keydownEvent = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true, bubbles: true }); + const preventDefaultSpy = jest.spyOn(keydownEvent, 'preventDefault'); + + inputEl.dispatchEvent(keydownEvent); + + expect(shouldSkipEscapeHandling).toHaveBeenCalled(); + expect(blurSpy).not.toHaveBeenCalled(); + expect(preventDefaultSpy).not.toHaveBeenCalled(); + }); + + it('handles Escape when shouldSkipEscapeHandling returns false', () => { + // Dispose current controller and create one with shouldSkipEscapeHandling + controller.dispose(); + + shouldSkipEscapeHandling = jest.fn().mockReturnValue(false); + controller = new NavigationController({ + ...deps, + shouldSkipEscapeHandling, + }); + controller.initialize(); + + isStreaming = false; + const blurSpy = jest.spyOn(inputEl, 'blur'); + + const keydownEvent = new KeyboardEvent('keydown', { key: 'Escape', cancelable: true, bubbles: true }); + + inputEl.dispatchEvent(keydownEvent); + + expect(shouldSkipEscapeHandling).toHaveBeenCalled(); + expect(blurSpy).toHaveBeenCalled(); + }); + + it('ignores non-Escape keys', () => { + const blurSpy = jest.spyOn(inputEl, 'blur'); + + const keydownEvent = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }); + inputEl.dispatchEvent(keydownEvent); + + expect(blurSpy).not.toHaveBeenCalled(); + }); + }); + + describe('public API', () => { + beforeEach(() => { + controller.initialize(); + }); + + it('focusMessages focuses the messages element', () => { + const focusSpy = jest.spyOn(messagesEl, 'focus'); + controller.focusMessages(); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('focusInput focuses the input element', () => { + const focusSpy = jest.spyOn(inputEl, 'focus'); + controller.focusInput(); + expect(focusSpy).toHaveBeenCalled(); + }); + }); + + describe('edge cases', () => { + beforeEach(() => { + controller.initialize(); + }); + + it('handles rapid direction changes', () => { + messagesEl.scrollTop = 100; + + // Rapidly alternate directions + for (let i = 0; i < 5; i++) { + messagesEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'w' })); + messagesEl.dispatchEvent(new KeyboardEvent('keydown', { key: 's' })); + } + + // Should not throw and should be scrolling down (last direction) + jest.advanceTimersByTime(16); + expect(messagesEl.scrollTop).toBeGreaterThan(100); + }); + + it('handles empty settings keys gracefully', () => { + settings.scrollUpKey = ''; + settings.scrollDownKey = ''; + + // Should not throw + const keydownEvent = new KeyboardEvent('keydown', { key: 'w' }); + expect(() => messagesEl.dispatchEvent(keydownEvent)).not.toThrow(); + }); + }); +}); diff --git a/tests/unit/features/chat/controllers/SelectionController.test.ts b/tests/unit/features/chat/controllers/SelectionController.test.ts new file mode 100644 index 0000000..fbe5ecb --- /dev/null +++ b/tests/unit/features/chat/controllers/SelectionController.test.ts @@ -0,0 +1,676 @@ +import { createMockEl } from '@test/helpers/mockElement'; + +import { SelectionController } from '@/features/chat/controllers/SelectionController'; +import { hideSelectionHighlight, showSelectionHighlight } from '@/shared/components/SelectionHighlight'; + +jest.mock('@/shared/components/SelectionHighlight', () => ({ + showSelectionHighlight: jest.fn(), + hideSelectionHighlight: jest.fn(), +})); + +function createMockDOMRange(overrides: Partial<{ + startContainer: { isConnected: boolean }; + startOffset: number; + endContainer: { isConnected: boolean }; + endOffset: number; +}> = {}) { + const container = { isConnected: true }; + const range: any = { + startContainer: container, + startOffset: 0, + endContainer: container, + endOffset: 0, + ...overrides, + }; + range.cloneRange = jest.fn(() => range); + return range; +} + +function createMockDOMSelection(text: string, anchorNode: any, focusNode?: any, range?: any) { + const resolvedRange = range ?? createMockDOMRange(); + return { + toString: () => text, + anchorNode, + focusNode: focusNode ?? anchorNode, + rangeCount: 1, + getRangeAt: jest.fn(() => resolvedRange), + removeAllRanges: jest.fn(), + _range: resolvedRange, + }; +} + +function createMockEventTarget() { + const listeners = new Map void>>(); + const containedNodes = new Set(); + const el: any = { + ownerDocument: createMockEl().ownerDocument, + addEventListener: jest.fn((event: string, listener: (...args: unknown[]) => void) => { + const handlers = listeners.get(event) ?? new Set<(...args: unknown[]) => void>(); + handlers.add(listener); + listeners.set(event, handlers); + }), + removeEventListener: jest.fn((event: string, listener: (...args: unknown[]) => void) => { + listeners.get(event)?.delete(listener); + }), + trigger: (event: string, eventData: unknown = {}) => { + listeners.get(event)?.forEach(handler => handler(eventData)); + }, + contains: jest.fn((node: unknown) => node === el || containedNodes.has(node)), + addContainedNode: (node: unknown) => { + containedNodes.add(node); + }, + }; + return el; +} + +function createMockContextTray() { + return { + setItems: jest.fn(), + clearItems: jest.fn(), + }; +} + +describe('SelectionController', () => { + let controller: SelectionController; + let app: any; + let contextTray: ReturnType; + let inputEl: any; + let focusScopeEl: any; + let editor: any; + let editorView: any; + let originalDocument: any; + let originalCSS: any; + + beforeEach(() => { + // Mock Highlight constructor for CSS Custom Highlight API tests + (global as any).Highlight = jest.fn((...ranges: any[]) => ({ ranges })); + originalCSS = (global as any).CSS; + jest.useFakeTimers(); + (showSelectionHighlight as jest.Mock).mockClear(); + (hideSelectionHighlight as jest.Mock).mockClear(); + + contextTray = createMockContextTray(); + inputEl = createMockEventTarget(); + focusScopeEl = createMockEventTarget(); + focusScopeEl.addContainedNode(inputEl); + + editorView = { + id: 'editor-view', + dom: createMockEventTarget(), + state: { selection: { main: { head: 4 } } }, + dispatch: jest.fn(), + }; + editor = { + getSelection: jest.fn().mockReturnValue('selected text'), + getCursor: jest.fn((which: 'from' | 'to') => { + if (which === 'from') return { line: 0, ch: 0 }; + return { line: 0, ch: 4 }; + }), + posToOffset: jest.fn((pos: { line: number; ch: number }) => pos.line * 100 + pos.ch), + cm: editorView, + }; + + const view = { editor, getMode: () => 'source', file: { path: 'notes/test.md' } }; + app = { + workspace: { + getActiveViewOfType: jest.fn().mockReturnValue(view), + }, + }; + + controller = new SelectionController(app, contextTray as any, inputEl, undefined, focusScopeEl); + + originalDocument = (global as any).document; + (global as any).document = { activeElement: null }; + }); + + afterEach(() => { + controller.stop(); + jest.useRealTimers(); + (global as any).document = originalDocument; + (global as any).CSS = originalCSS; + delete (global as any).Highlight; + }); + + it('captures selection and updates indicator', () => { + controller.start(); + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(true); + expect(controller.getContext()).toEqual({ + notePath: 'notes/test.md', + mode: 'selection', + selectedText: 'selected text', + lineCount: 1, + startLine: 1, + }); + expect(contextTray.setItems).toHaveBeenLastCalledWith('editor-selection', [ + expect.objectContaining({ label: '1 line selected' }), + ]); + expect(contextTray.setItems.mock.calls[0][1][0]).not.toHaveProperty('title'); + + controller.showHighlight(); + expect(showSelectionHighlight).toHaveBeenCalledWith(editorView, 0, 4); + }); + + it('clears selection immediately when deselected without input handoff intent', () => { + controller.start(); + jest.advanceTimersByTime(250); + + editor.getSelection.mockReturnValue(''); + (global as any).document.activeElement = null; + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(false); + expect(contextTray.clearItems).toHaveBeenCalledWith('editor-selection'); + expect(hideSelectionHighlight).toHaveBeenCalledWith(editorView); + }); + + it('clears a sticky selection from the tray remove action', () => { + controller.start(); + jest.advanceTimersByTime(250); + + const items = contextTray.setItems.mock.calls[0][1]; + items[0].onRemove(); + + expect(controller.hasSelection()).toBe(false); + expect(contextTray.clearItems).toHaveBeenCalledWith('editor-selection'); + }); + + it('preserves selection when focus moves into the chat sidebar', () => { + controller.start(); + jest.advanceTimersByTime(250); + expect(controller.hasSelection()).toBe(true); + + app.workspace.getActiveViewOfType.mockReturnValue(null); + const sidebarButton = {}; + focusScopeEl.addContainedNode(sidebarButton); + (global as any).document.activeElement = sidebarButton; + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(true); + expect(contextTray.clearItems).not.toHaveBeenCalledWith('editor-selection'); + }); + + it('preserves selection when a relocated composer outside tab content has focus', () => { + const contentScopeEl = createMockEventTarget(); + const composerScopeEl = createMockEventTarget(); + composerScopeEl.addContainedNode(inputEl); + controller = new SelectionController( + app, + contextTray as any, + inputEl, + undefined, + [contentScopeEl, composerScopeEl], + ); + + controller.start(); + jest.advanceTimersByTime(250); + expect(controller.hasSelection()).toBe(true); + + app.workspace.getActiveViewOfType.mockReturnValue(null); + (global as any).document.activeElement = inputEl; + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(true); + expect(contextTray.clearItems).not.toHaveBeenCalledWith('editor-selection'); + }); + + it('preserves selection when shared footer controls have focus', () => { + const contentScopeEl = createMockEventTarget(); + const composerScopeEl = createMockEventTarget(); + const footerScopeEl = createMockEventTarget(); + const historyButton = {}; + footerScopeEl.addContainedNode(historyButton); + controller = new SelectionController( + app, + contextTray as any, + inputEl, + undefined, + [contentScopeEl, composerScopeEl, footerScopeEl], + ); + + controller.start(); + jest.advanceTimersByTime(250); + expect(controller.hasSelection()).toBe(true); + + app.workspace.getActiveViewOfType.mockReturnValue(null); + (global as any).document.activeElement = historyButton; + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(true); + expect(contextTray.clearItems).not.toHaveBeenCalledWith('editor-selection'); + }); + + it('shows selection highlight when focus enters shared footer controls', () => { + const footerScopeEl = createMockEventTarget(); + controller = new SelectionController( + app, + contextTray as any, + inputEl, + undefined, + [focusScopeEl, footerScopeEl], + ); + controller.start(); + jest.advanceTimersByTime(250); + (showSelectionHighlight as jest.Mock).mockClear(); + + footerScopeEl.trigger('focusin', { relatedTarget: null }); + + expect(showSelectionHighlight).toHaveBeenCalledWith(editorView, 0, 4); + }); + + it('does not re-show selection highlight when focus moves inside chat focus scopes', () => { + const footerScopeEl = createMockEventTarget(); + const footerButton = {}; + footerScopeEl.addContainedNode(footerButton); + controller = new SelectionController( + app, + contextTray as any, + inputEl, + undefined, + [focusScopeEl, footerScopeEl], + ); + controller.start(); + jest.advanceTimersByTime(250); + (showSelectionHighlight as jest.Mock).mockClear(); + + focusScopeEl.trigger('focusin', { relatedTarget: footerButton }); + + expect(showSelectionHighlight).not.toHaveBeenCalled(); + }); + + it('shows fake highlight when focus moves to another sidebar control in edit mode', () => { + controller.start(); + jest.advanceTimersByTime(250); + + const sidebarButton = {}; + focusScopeEl.addContainedNode(sidebarButton); + editorView.state.selection.main = { from: 0, to: 4, head: 4 }; + (global as any).document.activeElement = sidebarButton; + + controller.showHighlight(); + + expect(showSelectionHighlight).toHaveBeenCalledWith(editorView, 0, 4); + expect(hideSelectionHighlight).not.toHaveBeenCalled(); + }); + + it('clears selection when focus leaves markdown and the chat sidebar is not focused', () => { + controller.start(); + jest.advanceTimersByTime(250); + expect(controller.hasSelection()).toBe(true); + + app.workspace.getActiveViewOfType.mockReturnValue(null); + (global as any).document.activeElement = null; + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(false); + expect(contextTray.clearItems).toHaveBeenCalledWith('editor-selection'); + expect(hideSelectionHighlight).toHaveBeenCalledWith(editorView); + }); + + it('preserves selection when input focus arrives after a slow editor blur handoff', () => { + controller.start(); + jest.advanceTimersByTime(250); + + inputEl.trigger('pointerdown'); + editor.getSelection.mockReturnValue(''); + (global as any).document.activeElement = null; + + // Simulate delayed focus handoff under UI load. + jest.advanceTimersByTime(1250); + expect(controller.hasSelection()).toBe(true); + + (global as any).document.activeElement = inputEl; + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(true); + expect(hideSelectionHighlight).not.toHaveBeenCalled(); + }); + + it('clears selection after handoff grace expires when input never receives focus', () => { + controller.start(); + jest.advanceTimersByTime(250); + + inputEl.trigger('pointerdown'); + editor.getSelection.mockReturnValue(''); + (global as any).document.activeElement = null; + + jest.advanceTimersByTime(1250); + expect(controller.hasSelection()).toBe(true); + + jest.advanceTimersByTime(750); + expect(controller.hasSelection()).toBe(false); + expect(hideSelectionHighlight).toHaveBeenCalledWith(editorView); + }); + + describe('Reading mode (preview)', () => { + let readingView: any; + let containerEl: any; + + beforeEach(() => { + containerEl = { + contains: jest.fn().mockReturnValue(true), + }; + readingView = { + editor, + getMode: () => 'preview', + file: { path: 'notes/reading.md' }, + containerEl, + }; + app.workspace.getActiveViewOfType.mockReturnValue(readingView); + }); + + it('captures selection via document.getSelection() in reading mode', () => { + const anchorNode = {}; + const mockSel = createMockDOMSelection('reading selection', anchorNode); + (global as any).document = { + activeElement: null, + getSelection: jest.fn().mockReturnValue(mockSel), + }; + + controller.start(); + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(true); + expect(controller.getContext()).toEqual({ + notePath: 'notes/reading.md', + mode: 'selection', + selectedText: 'reading selection', + lineCount: 1, + }); + expect(contextTray.setItems).toHaveBeenLastCalledWith('editor-selection', [ + expect.objectContaining({ label: '1 line selected' }), + ]); + }); + + it('preserves raw reading mode text and omits line metadata', () => { + const anchorNode = {}; + (global as any).document = { + activeElement: null, + getSelection: jest.fn().mockReturnValue( + createMockDOMSelection(' reading selection\nsecond line ', anchorNode), + ), + }; + + controller.start(); + jest.advanceTimersByTime(250); + + expect(controller.getContext()).toEqual({ + notePath: 'notes/reading.md', + mode: 'selection', + selectedText: ' reading selection\nsecond line ', + lineCount: 2, + }); + expect(contextTray.setItems).toHaveBeenLastCalledWith('editor-selection', [ + expect.objectContaining({ label: '2 lines selected' }), + ]); + }); + + it('prefers native DOM selection in reading mode, falls back to CSS Highlight API when lost', () => { + const anchorNode = {}; + const mockSel = createMockDOMSelection('reading selection', anchorNode); + (global as any).document = { + activeElement: null, + getSelection: jest.fn().mockReturnValue(mockSel), + }; + const mockHighlights = { set: jest.fn(), delete: jest.fn() }; + (global as any).CSS = { highlights: mockHighlights }; + + controller.start(); + jest.advanceTimersByTime(250); + + controller.showHighlight(); + expect(mockHighlights.set).not.toHaveBeenCalled(); + + const differentRange: any = { + startContainer: {}, startOffset: 0, + endContainer: {}, endOffset: 0, + }; + (global as any).document.getSelection = jest.fn().mockReturnValue({ + rangeCount: 1, + getRangeAt: () => differentRange, + }); + controller.showHighlight(); + + expect(showSelectionHighlight).not.toHaveBeenCalled(); + expect(mockHighlights.set).toHaveBeenCalledWith( + 'claudian-selection', + expect.any(Object), + ); + }); + + it('shows CSS highlight when focus moves to another sidebar control in reading mode', () => { + const anchorNode = {}; + const mockSel = createMockDOMSelection('reading selection', anchorNode); + const sidebarButton = {}; + focusScopeEl.addContainedNode(sidebarButton); + (global as any).document = { + activeElement: null, + getSelection: jest.fn().mockReturnValue(mockSel), + }; + const mockHighlights = { set: jest.fn(), delete: jest.fn() }; + (global as any).CSS = { highlights: mockHighlights }; + + controller.start(); + jest.advanceTimersByTime(250); + + (global as any).document.activeElement = sidebarButton; + controller.showHighlight(); + + expect(mockHighlights.set).toHaveBeenCalledWith( + 'claudian-selection', + expect.any(Object), + ); + }); + + it('clears selection when deselected in reading mode', () => { + const anchorNode = {}; + (global as any).document = { + activeElement: null, + getSelection: jest.fn().mockReturnValue( + createMockDOMSelection('reading selection', anchorNode), + ), + }; + + controller.start(); + jest.advanceTimersByTime(250); + expect(controller.hasSelection()).toBe(true); + + (global as any).document.getSelection.mockReturnValue( + createMockDOMSelection('', null), + ); + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(false); + expect(contextTray.clearItems).toHaveBeenCalledWith('editor-selection'); + }); + + it('preserves reading mode selection when input is focused', () => { + const anchorNode = {}; + (global as any).document = { + activeElement: null, + getSelection: jest.fn().mockReturnValue( + createMockDOMSelection('reading selection', anchorNode), + ), + }; + + controller.start(); + jest.advanceTimersByTime(250); + expect(controller.hasSelection()).toBe(true); + + (global as any).document.getSelection.mockReturnValue( + createMockDOMSelection('', null), + ); + (global as any).document.activeElement = inputEl; + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(true); + }); + + it('preserves reading mode selection when sidebar gets focus', () => { + const anchorNode = {}; + (global as any).document = { + activeElement: null, + getSelection: jest.fn().mockReturnValue( + createMockDOMSelection('reading selection', anchorNode), + ), + }; + + controller.start(); + jest.advanceTimersByTime(250); + expect(controller.hasSelection()).toBe(true); + + app.workspace.getActiveViewOfType.mockReturnValue(null); + const sidebarButton = {}; + focusScopeEl.addContainedNode(sidebarButton); + (global as any).document.activeElement = sidebarButton; + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(true); + expect(contextTray.clearItems).not.toHaveBeenCalledWith('editor-selection'); + }); + + it('clears CSS highlight when reading mode selection is deselected', () => { + const anchorNode = {}; + (global as any).document = { + activeElement: null, + getSelection: jest.fn().mockReturnValue( + createMockDOMSelection('reading selection', anchorNode), + ), + }; + const mockHighlights = { set: jest.fn(), delete: jest.fn() }; + (global as any).CSS = { highlights: mockHighlights }; + + controller.start(); + jest.advanceTimersByTime(250); + + (global as any).document.getSelection.mockReturnValue( + createMockDOMSelection('', null), + ); + jest.advanceTimersByTime(250); + + expect(mockHighlights.delete).toHaveBeenCalledWith('claudian-selection'); + }); + + it('skips CSS highlight for disconnected DOM ranges', () => { + const anchorNode = {}; + const mockSel = createMockDOMSelection('reading selection', anchorNode); + (global as any).document = { + activeElement: null, + getSelection: jest.fn().mockReturnValue(mockSel), + }; + + controller.start(); + jest.advanceTimersByTime(250); + + mockSel._range.startContainer.isConnected = false; + const mockHighlights = { set: jest.fn(), delete: jest.fn() }; + (global as any).CSS = { highlights: mockHighlights }; + + controller.showHighlight(); + expect(mockHighlights.set).not.toHaveBeenCalled(); + }); + + it('refreshes preview ranges when the same text is reselected elsewhere', () => { + const firstAnchorNode = {}; + const secondAnchorNode = {}; + const firstRange = createMockDOMRange({ + startContainer: { isConnected: true }, + endContainer: { isConnected: true }, + startOffset: 1, + endOffset: 4, + }); + const secondRange = createMockDOMRange({ + startContainer: { isConnected: true }, + endContainer: { isConnected: true }, + startOffset: 7, + endOffset: 10, + }); + let currentSelection = createMockDOMSelection('repeat', firstAnchorNode, undefined, firstRange); + (global as any).document = { + activeElement: null, + getSelection: jest.fn(() => currentSelection), + }; + const mockHighlights = { set: jest.fn(), delete: jest.fn() }; + (global as any).CSS = { highlights: mockHighlights }; + + controller.start(); + jest.advanceTimersByTime(250); + + currentSelection = createMockDOMSelection('repeat', secondAnchorNode, undefined, secondRange); + jest.advanceTimersByTime(250); + + (global as any).document.activeElement = inputEl; + controller.showHighlight(); + + expect(mockHighlights.set).toHaveBeenCalledWith( + 'claudian-selection', + { ranges: [secondRange] }, + ); + }); + + it('ignores selection outside the view container', () => { + containerEl.contains.mockReturnValue(false); + const anchorNode = {}; + (global as any).document = { + activeElement: null, + getSelection: jest.fn().mockReturnValue( + createMockDOMSelection('outside selection', anchorNode), + ), + }; + + controller.start(); + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(false); + }); + + it('uses focusNode when anchorNode is outside the view container', () => { + const anchorNode = {}; + const focusNode = {}; + containerEl.contains.mockImplementation((node: unknown) => node === focusNode); + (global as any).document = { + activeElement: null, + getSelection: jest.fn().mockReturnValue( + createMockDOMSelection('reading selection', anchorNode, focusNode), + ), + }; + + controller.start(); + jest.advanceTimersByTime(250); + + expect(controller.hasSelection()).toBe(true); + }); + + it('replaces source selection metadata when switching the same text into preview mode', () => { + const sourceView = { editor, getMode: () => 'source', file: { path: 'notes/test.md' } }; + app.workspace.getActiveViewOfType.mockReturnValue(sourceView); + + controller.start(); + jest.advanceTimersByTime(250); + + const previewAnchorNode = {}; + readingView.file.path = 'notes/test.md'; + app.workspace.getActiveViewOfType.mockReturnValue(readingView); + (global as any).document = { + activeElement: null, + getSelection: jest.fn().mockReturnValue( + createMockDOMSelection('selected text', previewAnchorNode), + ), + }; + (showSelectionHighlight as jest.Mock).mockClear(); + + jest.advanceTimersByTime(250); + controller.showHighlight(); + + expect(controller.getContext()).toEqual({ + notePath: 'notes/test.md', + mode: 'selection', + selectedText: 'selected text', + lineCount: 1, + }); + expect(showSelectionHighlight).not.toHaveBeenCalled(); + }); + }); + +}); diff --git a/tests/unit/features/chat/controllers/StreamController.test.ts b/tests/unit/features/chat/controllers/StreamController.test.ts new file mode 100644 index 0000000..9017f1a --- /dev/null +++ b/tests/unit/features/chat/controllers/StreamController.test.ts @@ -0,0 +1,2534 @@ +import '@/providers'; + +import { TEST_CODEX_MODEL } from '@test/helpers/codexModels'; +import { createMockEl } from '@test/helpers/mockElement'; + +import { ProviderSettingsCoordinator } from '@/core/providers/ProviderSettingsCoordinator'; +import { + TOOL_AGENT_OUTPUT, + TOOL_APPLY_PATCH, + TOOL_SPAWN_AGENT, + TOOL_TASK, + TOOL_TODO_WRITE, + TOOL_WAIT_AGENT, +} from '@/core/tools/toolNames'; +import type { ChatMessage } from '@/core/types'; +import { StreamController, type StreamControllerDeps } from '@/features/chat/controllers/StreamController'; +import { ChatState } from '@/features/chat/state/ChatState'; + +jest.mock('@/core/tools/todo', () => ({ + parseTodoInput: jest.fn(), +})); + +jest.mock('@/core/tools/toolInput', () => ({ + extractResolvedAnswers: jest.fn().mockReturnValue(undefined), + extractResolvedAnswersFromResultText: jest.fn().mockReturnValue(undefined), +})); + +jest.mock('@/features/chat/rendering/SubagentRenderer', () => ({ + createSubagentBlock: jest.fn().mockReturnValue({ + info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] }, + labelEl: { setText: jest.fn() }, + }), + finalizeSubagentBlock: jest.fn(), +})); + +jest.mock('@/features/chat/rendering/ThinkingBlockRenderer', () => ({ + appendThinkingContent: jest.fn(), + createThinkingBlock: jest.fn().mockImplementation(() => ({ + container: {}, + contentEl: {}, + content: '', + startTime: Date.now(), + })), + finalizeThinkingBlock: jest.fn().mockReturnValue(0), +})); + +jest.mock('@/features/chat/rendering/ToolCallRenderer', () => ({ + getToolName: jest.fn().mockReturnValue('Read'), + getToolSummary: jest.fn().mockReturnValue('file.md'), + isBlockedToolResult: jest.fn().mockReturnValue(false), + renderToolCall: jest.fn(), + updateToolCallResult: jest.fn(), +})); + +jest.mock('@/features/chat/rendering/WriteEditRenderer', () => ({ + createWriteEditBlock: jest.fn().mockReturnValue({}), + finalizeWriteEditBlock: jest.fn(), + updateWriteEditWithDiff: jest.fn(), +})); + +jest.mock('@/utils/path', () => ({ + getVaultPath: jest.fn().mockReturnValue('/test/vault'), +})); + +const originalWindow = (globalThis as { window?: Window }).window; + +function installTestWindow(): void { + const testWindow = { + requestAnimationFrame: (callback: FrameRequestCallback): number => + globalThis.setTimeout(() => callback(performance.now()), 16) as unknown as number, + cancelAnimationFrame: (handle: number): void => { + globalThis.clearTimeout(handle as unknown as ReturnType); + }, + setTimeout: (callback: () => void, timeout: number): number => + globalThis.setTimeout(callback, timeout) as unknown as number, + clearTimeout: (handle: number): void => { + globalThis.clearTimeout(handle as unknown as ReturnType); + }, + setInterval: (callback: () => void, timeout: number): number => + globalThis.setInterval(callback, timeout) as unknown as number, + clearInterval: (handle: number): void => { + globalThis.clearInterval(handle as unknown as ReturnType); + }, + } as Window; + + Object.defineProperty(globalThis, 'window', { + value: testWindow, + configurable: true, + }); +} + +function restoreTestWindow(): void { + if (originalWindow === undefined) { + delete (globalThis as { window?: Window }).window; + return; + } + + Object.defineProperty(globalThis, 'window', { + value: originalWindow, + configurable: true, + }); +} + +function createMockDeps(): StreamControllerDeps { + const state = new ChatState(); + const messagesEl = createMockEl(); + const agentService = { + getSessionId: jest.fn().mockReturnValue('session-1'), + loadSubagentToolCalls: jest.fn().mockResolvedValue([]), + loadSubagentFinalResult: jest.fn().mockResolvedValue(null), + getCapabilities: jest.fn().mockReturnValue({ + providerId: 'claude', + supportsPlanMode: true, + planPathPrefix: '/.claude/plans/', + }), + }; + const fileContextManager = { + markFileBeingEdited: jest.fn(), + trackEditedFile: jest.fn(), + getAttachedFiles: jest.fn().mockReturnValue(new Set()), + hasFilesChanged: jest.fn().mockReturnValue(false), + }; + + return { + plugin: { + settings: { + permissionMode: 'yolo', + }, + app: { + vault: { + adapter: { + basePath: '/test/vault', + }, + }, + }, + } as any, + state, + renderer: { + renderContent: jest.fn(), + addTextCopyButton: jest.fn(), + } as any, + subagentManager: { + isAsyncTask: jest.fn().mockReturnValue(false), + isPendingAsyncTask: jest.fn().mockReturnValue(false), + isLinkedAgentOutputTool: jest.fn().mockReturnValue(false), + handleAgentOutputToolResult: jest.fn().mockReturnValue(undefined), + handleAgentOutputToolUse: jest.fn(), + handleAsyncSubagentResult: jest.fn().mockReturnValue(undefined), + handleTaskToolUse: jest.fn().mockReturnValue({ action: 'buffered' }), + handleTaskToolResult: jest.fn(), + refreshAsyncSubagent: jest.fn(), + hasPendingTask: jest.fn().mockReturnValue(false), + renderPendingTask: jest.fn().mockReturnValue(null), + renderPendingTaskFromTaskResult: jest.fn().mockReturnValue(null), + getSyncSubagent: jest.fn().mockReturnValue(undefined), + addSyncToolCall: jest.fn(), + updateSyncToolResult: jest.fn(), + finalizeSyncSubagent: jest.fn().mockReturnValue(null), + resetStreamingState: jest.fn(), + resetSpawnedCount: jest.fn(), + subagentsSpawnedThisStream: 0, + } as any, + getMessagesEl: () => messagesEl, + getFileContextManager: () => fileContextManager as any, + updateQueueIndicator: jest.fn(), + getAgentService: () => agentService as any, + }; +} + +function createTestMessage(): ChatMessage { + return { + id: 'assistant-1', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [], + contentBlocks: [], + }; +} + +function createMockUsage(overrides: Record = {}) { + return { + model: 'model-a', + inputTokens: 10, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + contextWindow: 100, + contextTokens: 10, + percentage: 10, + ...overrides, + }; +} + +describe('StreamController - Text Content', () => { + let controller: StreamController; + let deps: StreamControllerDeps; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + installTestWindow(); + deps = createMockDeps(); + controller = new StreamController(deps); + deps.state.currentContentEl = createMockEl(); + }); + + afterEach(() => { + // Clean up any timers set by ChatState + deps.state.resetStreamingState(); + restoreTestWindow(); + jest.useRealTimers(); + }); + + describe('Text streaming', () => { + it('should append text content to message', async () => { + const msg = createTestMessage(); + + deps.state.currentTextEl = createMockEl(); + + await controller.handleStreamChunk({ type: 'text', content: 'Hello ' }, msg); + await controller.handleStreamChunk({ type: 'text', content: 'World' }, msg); + + expect(msg.content).toBe('Hello World'); + }); + + it('should accumulate text across multiple chunks', async () => { + const msg = createTestMessage(); + deps.state.currentTextEl = createMockEl(); + + const chunks = ['This ', 'is ', 'a ', 'test.']; + for (const chunk of chunks) { + await controller.handleStreamChunk({ type: 'text', content: chunk }, msg); + } + + expect(msg.content).toBe('This is a test.'); + }); + + it('should coalesce text renders until the next animation frame', async () => { + deps.state.currentTextEl = createMockEl(); + + await controller.appendText('Hello '); + await controller.appendText('World'); + + expect(deps.renderer.renderContent).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(16); + await Promise.resolve(); + + expect(deps.renderer.renderContent).toHaveBeenCalledTimes(1); + expect(deps.renderer.renderContent).toHaveBeenCalledWith( + deps.state.currentTextEl, + 'Hello World' + ); + }); + + it('should defer math rendering during live text renders', async () => { + deps.state.currentTextEl = createMockEl(); + + await controller.appendText('Euler: $e^{i\\pi} + 1 = 0$'); + + jest.advanceTimersByTime(16); + await Promise.resolve(); + + expect(deps.renderer.renderContent).toHaveBeenCalledWith( + deps.state.currentTextEl, + 'Euler: $e^{i\\pi} + 1 = 0$', + { deferMath: true } + ); + }); + + it('should honor disabled deferred math rendering setting during live text renders', async () => { + (deps.plugin.settings as any).deferMathRenderingDuringStreaming = false; + deps.state.currentTextEl = createMockEl(); + + await controller.appendText('Euler: $e^{i\\pi} + 1 = 0$'); + + jest.advanceTimersByTime(16); + await Promise.resolve(); + + expect(deps.renderer.renderContent).toHaveBeenCalledWith( + deps.state.currentTextEl, + 'Euler: $e^{i\\pi} + 1 = 0$' + ); + }); + + it('should flush a pending text render before finalizing text', async () => { + const msg = createTestMessage(); + + await controller.appendText('Hello'); + await controller.finalizeCurrentTextBlock(msg); + + expect(deps.renderer.renderContent).toHaveBeenCalledWith( + expect.anything(), + 'Hello' + ); + expect(deps.renderer.addTextCopyButton).toHaveBeenCalledWith( + expect.anything(), + 'Hello' + ); + expect(msg.contentBlocks).toContainEqual({ + type: 'text', + content: 'Hello', + }); + }); + + it('should render original math once when finalizing a deferred text block', async () => { + const msg = createTestMessage(); + + await controller.appendText('Final $x^2$'); + await controller.finalizeCurrentTextBlock(msg); + + expect(deps.renderer.renderContent).toHaveBeenNthCalledWith( + 1, + expect.anything(), + 'Final $x^2$', + { deferMath: true } + ); + expect(deps.renderer.renderContent).toHaveBeenNthCalledWith( + 2, + expect.anything(), + 'Final $x^2$' + ); + expect(deps.renderer.addTextCopyButton).toHaveBeenCalledWith( + expect.anything(), + 'Final $x^2$' + ); + }); + }); + + describe('Text block finalization', () => { + it('should add copy button when finalizing text block with content', async () => { + const msg = createTestMessage(); + deps.state.currentTextEl = createMockEl(); + deps.state.currentTextContent = 'Hello World'; + + await controller.finalizeCurrentTextBlock(msg); + + expect(deps.renderer.addTextCopyButton).toHaveBeenCalledWith( + expect.anything(), + 'Hello World' + ); + expect(msg.contentBlocks).toContainEqual({ + type: 'text', + content: 'Hello World', + }); + }); + + it('should not add copy button when no text element exists', async () => { + const msg = createTestMessage(); + deps.state.currentTextEl = null; + deps.state.currentTextContent = 'Hello World'; + + await controller.finalizeCurrentTextBlock(msg); + + expect(deps.renderer.addTextCopyButton).not.toHaveBeenCalled(); + // Content block should still be added + expect(msg.contentBlocks).toContainEqual({ + type: 'text', + content: 'Hello World', + }); + }); + + it('should not add copy button when no text content exists', async () => { + const msg = createTestMessage(); + deps.state.currentTextEl = createMockEl(); + deps.state.currentTextContent = ''; + + await controller.finalizeCurrentTextBlock(msg); + + expect(deps.renderer.addTextCopyButton).not.toHaveBeenCalled(); + expect(msg.contentBlocks).toEqual([]); + }); + + it('should reset text state after finalization', async () => { + const msg = createTestMessage(); + deps.state.currentTextEl = createMockEl(); + deps.state.currentTextContent = 'Test content'; + + await controller.finalizeCurrentTextBlock(msg); + + expect(deps.state.currentTextEl).toBeNull(); + expect(deps.state.currentTextContent).toBe(''); + }); + }); + + describe('Error and notice handling', () => { + it('should append error message on error chunk', async () => { + const msg = createTestMessage(); + deps.state.currentTextEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'error', content: 'Something went wrong' }, + msg + ); + + expect(deps.state.currentTextContent).toContain('Error'); + }); + + it('should append warning notice on notice chunk', async () => { + const msg = createTestMessage(); + deps.state.currentTextEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'notice', content: 'Tool was blocked', level: 'warning' }, + msg + ); + + expect(deps.state.currentTextContent).toContain('Blocked'); + }); + }); + + describe('context_compacted handling', () => { + it('should record a context_compacted block on the message', async () => { + const msg = createTestMessage(); + + await controller.handleStreamChunk({ type: 'context_compacted' }, msg); + + expect(msg.contentBlocks).toContainEqual({ type: 'context_compacted' }); + }); + }); + + describe('Done chunk handling', () => { + it('should handle done chunk without error', async () => { + const msg = createTestMessage(); + deps.state.currentTextEl = createMockEl(); + + // Should not throw + await expect( + controller.handleStreamChunk({ type: 'done' }, msg) + ).resolves.not.toThrow(); + }); + }); + + describe('Usage handling', () => { + it('should update usage for current session', async () => { + const msg = createTestMessage(); + const usage = createMockUsage(); + + await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-1' }, msg); + + expect(deps.state.usage).toEqual(usage); + }); + + it('stamps the active provider model onto usage when the provider omits it', async () => { + const msg = createTestMessage(); + const usage = createMockUsage({ model: undefined }); + const providerSettingsSpy = jest.spyOn(ProviderSettingsCoordinator, 'getProviderSettingsSnapshot'); + providerSettingsSpy.mockReturnValue({ model: TEST_CODEX_MODEL } as any); + (deps.getAgentService!() as any).providerId = 'codex'; + + await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-1' }, msg); + + expect(deps.state.usage).toEqual({ ...usage, model: TEST_CODEX_MODEL }); + + providerSettingsSpy.mockRestore(); + }); + + it('should ignore usage from other sessions', async () => { + const msg = createTestMessage(); + const usage = createMockUsage(); + + await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-2' }, msg); + + expect(deps.state.usage).toBeNull(); + }); + }); + + describe('Tool handling', () => { + it('should record tool_use and add to content blocks', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: 'notes/test.md' } }, + msg + ); + + expect(msg.toolCalls).toHaveLength(1); + expect(msg.toolCalls![0].id).toBe('tool-1'); + expect(msg.toolCalls![0].status).toBe('running'); + expect(msg.contentBlocks).toHaveLength(1); + expect(msg.contentBlocks![0]).toEqual({ type: 'tool_use', toolId: 'tool-1' }); + }); + + it('should update tool_result status', async () => { + const msg = createTestMessage(); + msg.toolCalls = [ + { + id: 'tool-1', + name: 'Read', + input: { file_path: 'notes/test.md' }, + status: 'running', + } as any, + ]; + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'tool-1', content: 'ok' }, + msg + ); + + expect(msg.toolCalls![0].status).toBe('completed'); + expect(msg.toolCalls![0].result).toBe('ok'); + }); + + it('should add subagent entry to contentBlocks for Task tool', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + // Configure mock to return created_sync when run_in_background is known + (deps.subagentManager.handleTaskToolUse as jest.Mock).mockReturnValueOnce({ + action: 'created_sync', + subagentState: { + info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] }, + }, + }); + + await controller.handleStreamChunk( + { + type: 'tool_use', + id: 'task-1', + name: TOOL_TASK, + input: { prompt: 'Do something', subagent_type: 'general-purpose', run_in_background: false }, + }, + msg + ); + + expect(msg.contentBlocks).toHaveLength(1); + expect(msg.contentBlocks![0]).toEqual({ type: 'subagent', subagentId: 'task-1' }); + expect(msg.toolCalls).toContainEqual( + expect.objectContaining({ + id: 'task-1', + name: TOOL_TASK, + subagent: expect.objectContaining({ id: 'task-1' }), + }) + ); + }); + + it('should render TodoWrite inline and update panel', async () => { + const { parseTodoInput } = jest.requireMock('@/core/tools/todo'); + const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const mockTodos = [{ content: 'Task 1', status: 'pending', activeForm: 'Working on task 1' }]; + parseTodoInput.mockReturnValue(mockTodos); + + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { + type: 'tool_use', + id: 'todo-1', + name: TOOL_TODO_WRITE, + input: { todos: mockTodos }, + }, + msg + ); + + // Tool is buffered, should be in pendingTools + expect(msg.contentBlocks).toHaveLength(1); + expect(msg.contentBlocks![0]).toEqual({ type: 'tool_use', toolId: 'todo-1' }); + expect(deps.state.pendingTools.size).toBe(1); + + // Should update currentTodos for panel immediately (side effect) + expect(deps.state.currentTodos).toEqual(mockTodos); + + // Flush pending tools by sending a different chunk type (text or done) + await controller.handleStreamChunk({ type: 'done' }, msg); + + // Now renderToolCall should have been called + expect(renderToolCall).toHaveBeenCalled(); + expect(deps.state.pendingTools.size).toBe(0); + }); + + it('should flush pending tools before rendering text content', async () => { + const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } }, + msg + ); + expect(deps.state.pendingTools.size).toBe(1); + expect(renderToolCall).not.toHaveBeenCalled(); + + deps.state.currentTextEl = createMockEl(); + await controller.handleStreamChunk({ type: 'text', content: 'Hello' }, msg); + + expect(deps.state.pendingTools.size).toBe(0); + expect(renderToolCall).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'read-1', name: 'Read' }), + expect.any(Map), + { initiallyExpanded: false }, + ); + }); + + it('should pass expanded default to apply_patch tool blocks when enabled', async () => { + const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + (deps.plugin.settings as any).expandFileEditsByDefault = true; + + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { + type: 'tool_use', + id: 'patch-1', + name: TOOL_APPLY_PATCH, + input: { changes: [{ path: 'src/main.ts', kind: 'update' }] }, + }, + msg + ); + await controller.handleStreamChunk({ type: 'done' }, msg); + + expect(renderToolCall).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'patch-1', name: TOOL_APPLY_PATCH }), + expect.any(Map), + { initiallyExpanded: true }, + ); + }); + + it('should flush pending tools before rendering thinking content', async () => { + const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'grep-1', name: 'Grep', input: { pattern: 'test' } }, + msg + ); + expect(deps.state.pendingTools.size).toBe(1); + expect(renderToolCall).not.toHaveBeenCalled(); + + await controller.handleStreamChunk({ type: 'thinking', content: 'Let me think...' }, msg); + + expect(deps.state.pendingTools.size).toBe(0); + expect(renderToolCall).toHaveBeenCalled(); + }); + + it('should render pending tool when tool_result arrives before flush', async () => { + const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } }, + msg + ); + expect(deps.state.pendingTools.size).toBe(1); + expect(renderToolCall).not.toHaveBeenCalled(); + + // Result arrives while tool still pending - should render tool first + await controller.handleStreamChunk( + { type: 'tool_result', id: 'read-1', content: 'file contents here' }, + msg + ); + + expect(deps.state.pendingTools.size).toBe(0); + expect(renderToolCall).toHaveBeenCalled(); + expect(msg.toolCalls![0].status).toBe('completed'); + expect(msg.toolCalls![0].result).toBe('file contents here'); + }); + + it('should render a pending tool on tool_output and append incremental output', async () => { + const { renderToolCall, updateToolCallResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'bash-1', name: 'Bash', input: { command: 'npm test' } }, + msg + ); + + expect(deps.state.pendingTools.size).toBe(1); + + await controller.handleStreamChunk( + { type: 'tool_output', id: 'bash-1', content: 'line 1\n' }, + msg + ); + + expect(deps.state.pendingTools.size).toBe(0); + expect(renderToolCall).toHaveBeenCalled(); + expect(updateToolCallResult).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(16); + await Promise.resolve(); + + expect(updateToolCallResult).toHaveBeenCalledWith( + 'bash-1', + expect.objectContaining({ + id: 'bash-1', + status: 'running', + result: 'line 1\n', + }), + expect.any(Map) + ); + + await controller.handleStreamChunk( + { type: 'tool_output', id: 'bash-1', content: 'line 2\n' }, + msg + ); + + expect(msg.toolCalls![0].status).toBe('running'); + expect(msg.toolCalls![0].result).toBe('line 1\nline 2\n'); + expect(updateToolCallResult).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(16); + await Promise.resolve(); + + expect(updateToolCallResult).toHaveBeenLastCalledWith( + 'bash-1', + expect.objectContaining({ + id: 'bash-1', + status: 'running', + result: 'line 1\nline 2\n', + }), + expect.any(Map) + ); + }); + + it('should coalesce tool_output renders until the next animation frame', async () => { + const { updateToolCallResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'bash-1', name: 'Bash', input: { command: 'npm test' } }, + msg + ); + await controller.handleStreamChunk( + { type: 'tool_output', id: 'bash-1', content: 'line 1\n' }, + msg + ); + await controller.handleStreamChunk( + { type: 'tool_output', id: 'bash-1', content: 'line 2\n' }, + msg + ); + + expect(updateToolCallResult).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(16); + await Promise.resolve(); + + expect(updateToolCallResult).toHaveBeenCalledTimes(1); + expect(updateToolCallResult).toHaveBeenCalledWith( + 'bash-1', + expect.objectContaining({ + result: 'line 1\nline 2\n', + status: 'running', + }), + expect.any(Map) + ); + }); + + it('should buffer Write tool and use createWriteEditBlock on flush', async () => { + const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const { createWriteEditBlock } = jest.requireMock('@/features/chat/rendering/WriteEditRenderer'); + createWriteEditBlock.mockReturnValue({ wrapperEl: createMockEl() }); + + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: 'test.md', content: 'hello' } }, + msg + ); + + expect(deps.state.pendingTools.size).toBe(1); + expect(createWriteEditBlock).not.toHaveBeenCalled(); + expect(renderToolCall).not.toHaveBeenCalled(); + + await controller.handleStreamChunk({ type: 'done' }, msg); + + expect(deps.state.pendingTools.size).toBe(0); + expect(createWriteEditBlock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'write-1', name: 'Write' }), + { initiallyExpanded: false }, + ); + // renderToolCall should NOT be called for Write/Edit tools + expect(renderToolCall).not.toHaveBeenCalled(); + }); + + it('should pass expanded default to Write tool blocks when enabled', async () => { + const { createWriteEditBlock } = jest.requireMock('@/features/chat/rendering/WriteEditRenderer'); + createWriteEditBlock.mockReturnValue({ wrapperEl: createMockEl() }); + + (deps.plugin.settings as any).expandFileEditsByDefault = true; + + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: 'test.md', content: 'hello' } }, + msg + ); + await controller.handleStreamChunk({ type: 'done' }, msg); + + expect(createWriteEditBlock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'write-1', name: 'Write' }), + { initiallyExpanded: true }, + ); + }); + + it('should buffer Edit tool and use createWriteEditBlock on flush', async () => { + const { createWriteEditBlock } = jest.requireMock('@/features/chat/rendering/WriteEditRenderer'); + createWriteEditBlock.mockReturnValue({ wrapperEl: createMockEl() }); + + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'edit-1', name: 'Edit', input: { file_path: 'test.md', old_string: 'a', new_string: 'b' } }, + msg + ); + + expect(deps.state.pendingTools.size).toBe(1); + expect(createWriteEditBlock).not.toHaveBeenCalled(); + + deps.state.currentTextEl = createMockEl(); + await controller.handleStreamChunk({ type: 'text', content: 'Done editing' }, msg); + + expect(deps.state.pendingTools.size).toBe(0); + expect(createWriteEditBlock).toHaveBeenCalled(); + }); + + it('should flush pending tools before rendering blocked message', async () => { + const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'bash-1', name: 'Bash', input: { command: 'ls' } }, + msg + ); + expect(deps.state.pendingTools.size).toBe(1); + + await controller.handleStreamChunk({ type: 'notice', content: 'Command blocked', level: 'warning' }, msg); + + expect(deps.state.pendingTools.size).toBe(0); + expect(renderToolCall).toHaveBeenCalled(); + }); + + it('should flush pending tools before rendering error message', async () => { + const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'missing.md' } }, + msg + ); + expect(deps.state.pendingTools.size).toBe(1); + + await controller.handleStreamChunk({ type: 'error', content: 'Something went wrong' }, msg); + + expect(deps.state.pendingTools.size).toBe(0); + expect(renderToolCall).toHaveBeenCalled(); + }); + + it('should flush pending tools before Task tool renders', async () => { + const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + (deps.subagentManager.handleTaskToolUse as jest.Mock).mockReturnValueOnce({ + action: 'created_sync', + subagentState: { + info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] }, + }, + }); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } }, + msg + ); + expect(deps.state.pendingTools.size).toBe(1); + expect(renderToolCall).not.toHaveBeenCalled(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', subagent_type: 'general-purpose', run_in_background: false } }, + msg + ); + + expect(deps.state.pendingTools.size).toBe(0); + expect(renderToolCall).toHaveBeenCalled(); + expect(deps.subagentManager.handleTaskToolUse).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ run_in_background: false }), + expect.anything() + ); + }); + + it('should re-parse TodoWrite on input updates when streaming completes', async () => { + const { parseTodoInput } = jest.requireMock('@/core/tools/todo'); + + const mockTodos = [ + { content: 'Task 1', status: 'pending', activeForm: 'Working on task 1' }, + ]; + + // First chunk: partial input, parsing fails + parseTodoInput.mockReturnValueOnce(null); + + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { + type: 'tool_use', + id: 'todo-1', + name: TOOL_TODO_WRITE, + input: { todos: '[' }, // Incomplete JSON + }, + msg + ); + + // No todos yet + expect(deps.state.currentTodos).toBeNull(); + + // Second chunk: complete input, parsing succeeds + parseTodoInput.mockReturnValueOnce(mockTodos); + + await controller.handleStreamChunk( + { + type: 'tool_use', + id: 'todo-1', + name: TOOL_TODO_WRITE, + input: { todos: mockTodos }, + }, + msg + ); + + // Now todos should be updated + expect(deps.state.currentTodos).toEqual(mockTodos); + }); + + it('should clear pendingTools on resetStreamingState', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.md' } }, + msg + ); + await controller.handleStreamChunk( + { type: 'tool_use', id: 'read-2', name: 'Read', input: { file_path: 'b.md' } }, + msg + ); + expect(deps.state.pendingTools.size).toBe(2); + + controller.resetStreamingState(); + + expect(deps.state.pendingTools.size).toBe(0); + }); + + it('should clear responseStartTime on resetStreamingState', () => { + deps.state.responseStartTime = 12345; + expect(deps.state.responseStartTime).toBe(12345); + + controller.resetStreamingState(); + + expect(deps.state.responseStartTime).toBeNull(); + }); + }); + + describe('Timer lifecycle', () => { + it('should create timer interval when showing thinking indicator', () => { + deps.state.responseStartTime = performance.now(); + + controller.showThinkingIndicator(); + jest.advanceTimersByTime(500); // Past the debounce delay + + expect(deps.state.flavorTimerInterval).not.toBeNull(); + }); + + it('should clear timer interval when hiding thinking indicator', () => { + deps.state.responseStartTime = performance.now(); + + controller.showThinkingIndicator(); + jest.advanceTimersByTime(500); + expect(deps.state.flavorTimerInterval).not.toBeNull(); + + controller.hideThinkingIndicator(); + + expect(deps.state.flavorTimerInterval).toBeNull(); + }); + + it('uses the content owner window for thinking timers', () => { + const ownerSetTimeout = jest.fn, Parameters>( + (callback, timeout) => globalThis.setTimeout(callback, timeout) as unknown as number, + ); + const ownerClearTimeout = jest.fn((handle) => { + globalThis.clearTimeout(handle as unknown as ReturnType); + }); + const ownerSetInterval = jest.fn, Parameters>( + (callback, timeout) => globalThis.setInterval(callback, timeout) as unknown as number, + ); + const ownerClearInterval = jest.fn((handle) => { + globalThis.clearInterval(handle as unknown as ReturnType); + }); + const ownerWindow = { + ...deps.state.currentContentEl!.ownerDocument.defaultView, + setTimeout: ownerSetTimeout, + clearTimeout: ownerClearTimeout, + setInterval: ownerSetInterval, + clearInterval: ownerClearInterval, + }; + Object.defineProperty(deps.state.currentContentEl!.ownerDocument, 'defaultView', { + configurable: true, + value: ownerWindow, + }); + + deps.state.responseStartTime = performance.now(); + + controller.showThinkingIndicator(); + expect(ownerSetTimeout).toHaveBeenCalledWith(expect.any(Function), 400); + + controller.hideThinkingIndicator(); + expect(ownerClearTimeout).toHaveBeenCalled(); + + controller.showThinkingIndicator(); + jest.advanceTimersByTime(500); + expect(ownerSetInterval).toHaveBeenCalledWith(expect.any(Function), 1000); + + controller.hideThinkingIndicator(); + expect(ownerClearInterval).toHaveBeenCalled(); + }); + + it('should clear timer interval in resetStreamingState', () => { + deps.state.responseStartTime = performance.now(); + + controller.showThinkingIndicator(); + jest.advanceTimersByTime(500); + expect(deps.state.flavorTimerInterval).not.toBeNull(); + + controller.resetStreamingState(); + + expect(deps.state.flavorTimerInterval).toBeNull(); + }); + + it('should not create duplicate intervals on multiple showThinkingIndicator calls', () => { + deps.state.responseStartTime = performance.now(); + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + controller.showThinkingIndicator(); + jest.advanceTimersByTime(500); + const firstInterval = deps.state.flavorTimerInterval; + + // Second call while indicator exists should not create a new interval + controller.showThinkingIndicator(); + jest.advanceTimersByTime(500); + + // Should still have the same interval (no new one created since element exists) + expect(deps.state.flavorTimerInterval).toBe(firstInterval); + + clearIntervalSpy.mockRestore(); + }); + }); + + describe('Tool handling - continued', () => { + it('should handle multiple pending tools and flush in order', async () => { + const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.md' } }, + msg + ); + await controller.handleStreamChunk( + { type: 'tool_use', id: 'grep-1', name: 'Grep', input: { pattern: 'test' } }, + msg + ); + await controller.handleStreamChunk( + { type: 'tool_use', id: 'glob-1', name: 'Glob', input: { pattern: '*.md' } }, + msg + ); + + expect(deps.state.pendingTools.size).toBe(3); + expect(renderToolCall).not.toHaveBeenCalled(); + + await controller.handleStreamChunk({ type: 'done' }, msg); + + expect(deps.state.pendingTools.size).toBe(0); + expect(renderToolCall).toHaveBeenCalledTimes(3); + + // Verify tools were rendered in order (Map preserves insertion order) + const calls = renderToolCall.mock.calls; + expect(calls[0][1].id).toBe('read-1'); + expect(calls[1][1].id).toBe('grep-1'); + expect(calls[2][1].id).toBe('glob-1'); + }); + }); + + describe('Usage handling - edge cases', () => { + it('should skip usage when subagentsSpawnedThisStream > 0', async () => { + const msg = createTestMessage(); + (deps.subagentManager as any).subagentsSpawnedThisStream = 1; + + const usage = createMockUsage({ inputTokens: 100, contextWindow: 200, contextTokens: 100, percentage: 50 }); + + await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-1' }, msg); + + expect(deps.state.usage).toBeNull(); + }); + + it('should skip usage when chunk has sessionId but currentSessionId is null', async () => { + const nullSessionDeps = createMockDeps(); + nullSessionDeps.getAgentService = () => ({ getSessionId: jest.fn().mockReturnValue(null) }) as any; + nullSessionDeps.state.currentContentEl = createMockEl(); + const nullSessionController = new StreamController(nullSessionDeps); + + const msg = createTestMessage(); + const usage = createMockUsage(); + + await nullSessionController.handleStreamChunk({ type: 'usage', usage, sessionId: 'some-session' }, msg); + + expect(nullSessionDeps.state.usage).toBeNull(); + }); + + it('should update usage when no sessionId on chunk', async () => { + const msg = createTestMessage(); + const usage = createMockUsage(); + + await controller.handleStreamChunk({ type: 'usage', usage } as any, msg); + + expect(deps.state.usage).toEqual(usage); + }); + + it('uses authoritative usage chunks directly', async () => { + const msg = createTestMessage(); + const usage = createMockUsage({ + model: TEST_CODEX_MODEL, + contextWindow: 258400, + contextWindowIsAuthoritative: true, + contextTokens: 129200, + percentage: 50, + }); + + await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-1' }, msg); + + expect(deps.state.usage).toEqual(usage); + }); + + it('should not update usage when ignoreUsageUpdates is true', async () => { + const msg = createTestMessage(); + deps.state.ignoreUsageUpdates = true; + + const usage = createMockUsage(); + + await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-1' }, msg); + + expect(deps.state.usage).toBeNull(); + }); + }); + + describe('Thinking indicator - edge cases', () => { + it('should not show indicator when no currentContentEl', () => { + deps.state.currentContentEl = null; + + controller.showThinkingIndicator(); + jest.advanceTimersByTime(500); + + expect(deps.state.thinkingEl).toBeNull(); + }); + + it('should not show indicator when currentThinkingState is active', () => { + deps.state.currentThinkingState = { content: 'thinking...', container: {}, contentEl: {}, startTime: Date.now() } as any; + + controller.showThinkingIndicator(); + jest.advanceTimersByTime(500); + + expect(deps.state.thinkingEl).toBeNull(); + }); + + it('should re-append existing indicator to bottom when called again', () => { + deps.state.responseStartTime = performance.now(); + + controller.showThinkingIndicator(); + jest.advanceTimersByTime(500); + + const thinkingEl = deps.state.thinkingEl; + expect(thinkingEl).not.toBeNull(); + + controller.showThinkingIndicator(); + + expect(deps.state.thinkingEl).toBe(thinkingEl); + expect(deps.updateQueueIndicator).toHaveBeenCalled(); + }); + }); + + describe('scrollToBottom - settings', () => { + it('should not scroll when enableAutoScroll setting is false', async () => { + (deps.plugin.settings as any).enableAutoScroll = false; + const messagesEl = deps.getMessagesEl(); + Object.defineProperty(messagesEl, 'scrollHeight', { value: 1000, configurable: true }); + messagesEl.scrollTop = 0; + + const msg = createTestMessage(); + deps.state.currentTextEl = createMockEl(); + await controller.handleStreamChunk({ type: 'text', content: 'Hello' }, msg); + + expect(messagesEl.scrollTop).toBe(0); + }); + + it('should not scroll when autoScrollEnabled state is false', async () => { + deps.state.autoScrollEnabled = false; + const messagesEl = deps.getMessagesEl(); + Object.defineProperty(messagesEl, 'scrollHeight', { value: 1000, configurable: true }); + messagesEl.scrollTop = 0; + + const msg = createTestMessage(); + deps.state.currentTextEl = createMockEl(); + await controller.handleStreamChunk({ type: 'text', content: 'Hello' }, msg); + + expect(messagesEl.scrollTop).toBe(0); + }); + }); + + describe('Subagent chunk handling', () => { + it('should handle subagent tool_result chunk', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + const toolCall = { id: 'read-1', name: 'Read', input: {}, status: 'running' }; + (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce({ + info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [toolCall] }, + }); + + await controller.handleStreamChunk( + { type: 'subagent_tool_result', id: 'read-1', subagentId: 'task-1', content: 'file content' }, + msg + ); + + expect(deps.subagentManager.updateSyncToolResult).toHaveBeenCalledWith( + 'task-1', + 'read-1', + expect.objectContaining({ status: 'completed', result: 'file content' }) + ); + }); + + it('should handle subagent tool_use chunk', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce({ + info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] }, + }); + + await controller.handleStreamChunk( + { type: 'subagent_tool_use', id: 'grep-1', name: 'Grep', input: { pattern: 'test' }, subagentId: 'task-1' }, + msg + ); + + expect(deps.subagentManager.addSyncToolCall).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ id: 'grep-1', name: 'Grep', status: 'running' }) + ); + }); + + it('should skip subagent chunk when no sync subagent found', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce(undefined); + + await controller.handleStreamChunk( + { type: 'subagent_tool_use', id: 'orphan-read', name: 'Read', input: { file_path: 'test.md' }, subagentId: 'unknown-task' }, + msg + ); + + // Should not throw + expect(msg.content).toBe(''); + }); + }); + + describe('Async subagent handling', () => { + it('should handle created_async action from Task tool use', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + (deps.subagentManager.handleTaskToolUse as jest.Mock).mockReturnValueOnce({ + action: 'created_async', + info: { id: 'task-1', description: 'background task', status: 'running', toolCalls: [], mode: 'async' }, + }); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', run_in_background: true } }, + msg + ); + + expect(msg.toolCalls).toContainEqual( + expect.objectContaining({ + id: 'task-1', + name: TOOL_TASK, + subagent: expect.objectContaining({ + id: 'task-1', + mode: 'async', + }), + }) + ); + expect(msg.contentBlocks).toContainEqual({ type: 'subagent', subagentId: 'task-1', mode: 'async' }); + }); + + it('should handle label_updated action from Task tool use (no-op for message)', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + (deps.subagentManager.handleTaskToolUse as jest.Mock).mockReturnValueOnce({ + action: 'label_updated', + }); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Updated' } }, + msg + ); + + expect(msg.toolCalls).toContainEqual( + expect.objectContaining({ + id: 'task-1', + name: TOOL_TASK, + }) + ); + expect(msg.contentBlocks).toEqual([]); + }); + }); + + describe('onAsyncSubagentStateChange', () => { + it('should update subagent in messages', () => { + const subagent = { id: 'task-1', description: 'test', status: 'completed', result: 'done', toolCalls: [] } as any; + deps.state.messages = [{ + id: 'a1', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [{ + id: 'task-1', + name: TOOL_TASK, + input: { description: 'test' }, + status: 'running', + subagent: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] }, + }], + }] as any; + + controller.onAsyncSubagentStateChange(subagent); + + const taskTool = deps.state.messages[0].toolCalls![0]; + expect(taskTool.status).toBe('completed'); + expect(taskTool.subagent?.status).toBe('completed'); + expect(taskTool.subagent?.result).toBe('done'); + }); + + it('should not crash when subagent not found in messages', () => { + const subagent = { id: 'unknown', description: 'test', status: 'completed', toolCalls: [] } as any; + deps.state.messages = [{ + id: 'a1', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [{ + id: 'task-1', + name: TOOL_TASK, + input: { description: 'test' }, + status: 'running', + }], + }] as any; + + expect(() => controller.onAsyncSubagentStateChange(subagent)).not.toThrow(); + }); + }); + + describe('Thinking block finalization', () => { + it('should finalize thinking block and add to contentBlocks', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + deps.state.currentThinkingState = { + content: 'Let me think...', + container: createMockEl(), + contentEl: createMockEl(), + startTime: Date.now(), + } as any; + + await controller.finalizeCurrentThinkingBlock(msg); + + expect(msg.contentBlocks).toContainEqual( + expect.objectContaining({ type: 'thinking', content: 'Let me think...' }) + ); + expect(deps.state.currentThinkingState).toBeNull(); + }); + + it('should not add to contentBlocks when no thinking content', async () => { + const msg = createTestMessage(); + deps.state.currentThinkingState = { + content: '', + container: createMockEl(), + contentEl: createMockEl(), + startTime: Date.now(), + } as any; + + await controller.finalizeCurrentThinkingBlock(msg); + + expect(msg.contentBlocks).toEqual([]); + }); + + it('should be a no-op when no thinking state', async () => { + const msg = createTestMessage(); + deps.state.currentThinkingState = null; + + await controller.finalizeCurrentThinkingBlock(msg); + + expect(msg.contentBlocks).toEqual([]); + }); + + it('should coalesce thinking renders until the next animation frame', async () => { + const { createThinkingBlock } = jest.requireMock('@/features/chat/rendering/ThinkingBlockRenderer'); + const msg = createTestMessage(); + const contentEl = createMockEl(); + createThinkingBlock.mockReturnValueOnce({ + wrapperEl: createMockEl(), + contentEl, + labelEl: createMockEl(), + content: '', + startTime: Date.now(), + }); + + await controller.handleStreamChunk({ type: 'thinking', content: 'Let ' }, msg); + await controller.handleStreamChunk({ type: 'thinking', content: 'me think' }, msg); + + expect(deps.renderer.renderContent).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(16); + await Promise.resolve(); + + expect(deps.renderer.renderContent).toHaveBeenCalledTimes(1); + expect(deps.renderer.renderContent).toHaveBeenCalledWith(contentEl, 'Let me think'); + }); + + it('should defer math rendering during live thinking renders', async () => { + const { createThinkingBlock } = jest.requireMock('@/features/chat/rendering/ThinkingBlockRenderer'); + const msg = createTestMessage(); + const contentEl = createMockEl(); + createThinkingBlock.mockReturnValueOnce({ + wrapperEl: createMockEl(), + contentEl, + labelEl: createMockEl(), + content: '', + startTime: Date.now(), + }); + + await controller.handleStreamChunk({ type: 'thinking', content: 'Reasoning $x^2$' }, msg); + + jest.advanceTimersByTime(16); + await Promise.resolve(); + + expect(deps.renderer.renderContent).toHaveBeenCalledWith( + contentEl, + 'Reasoning $x^2$', + { deferMath: true } + ); + }); + + it('should render original math once when finalizing a deferred thinking block', async () => { + const { createThinkingBlock } = jest.requireMock('@/features/chat/rendering/ThinkingBlockRenderer'); + const msg = createTestMessage(); + const contentEl = createMockEl(); + createThinkingBlock.mockReturnValueOnce({ + wrapperEl: createMockEl(), + contentEl, + labelEl: createMockEl(), + content: '', + startTime: Date.now(), + }); + + await controller.handleStreamChunk({ type: 'thinking', content: 'Reasoning $x^2$' }, msg); + await controller.finalizeCurrentThinkingBlock(msg); + + expect(deps.renderer.renderContent).toHaveBeenNthCalledWith( + 1, + contentEl, + 'Reasoning $x^2$', + { deferMath: true } + ); + expect(deps.renderer.renderContent).toHaveBeenNthCalledWith( + 2, + contentEl, + 'Reasoning $x^2$' + ); + expect(msg.contentBlocks).toContainEqual( + expect.objectContaining({ type: 'thinking', content: 'Reasoning $x^2$' }) + ); + }); + + it('should flush a pending thinking render before finalizing', async () => { + const msg = createTestMessage(); + + await controller.handleStreamChunk({ type: 'thinking', content: 'Reasoning' }, msg); + await controller.finalizeCurrentThinkingBlock(msg); + + expect(deps.renderer.renderContent).toHaveBeenCalledWith( + expect.anything(), + 'Reasoning' + ); + expect(msg.contentBlocks).toContainEqual( + expect.objectContaining({ type: 'thinking', content: 'Reasoning' }) + ); + }); + }); + + describe('Pending Task tool handling', () => { + it('should render pending Task as sync when child chunk arrives', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + // Task without run_in_background - manager returns buffered + await controller.handleStreamChunk( + { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', subagent_type: 'general-purpose' } }, + msg + ); + + // Manager's handleTaskToolUse should have been called + expect(deps.subagentManager.handleTaskToolUse).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ prompt: 'Do something' }), + expect.anything() + ); + + // Configure manager for child chunk: pending task exists, render returns sync + (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true); + (deps.subagentManager.renderPendingTask as jest.Mock).mockReturnValueOnce({ + mode: 'sync', + subagentState: { + info: { id: 'task-1', description: 'Do something', status: 'running', toolCalls: [] }, + }, + }); + // Also configure getSyncSubagent for the child chunk routing + (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce({ + info: { id: 'task-1', description: 'Do something', status: 'running', toolCalls: [] }, + }); + + // Child chunk arrives with parentToolUseId - should trigger render + await controller.handleStreamChunk( + { type: 'subagent_tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' }, subagentId: 'task-1' }, + msg + ); + + // Task toolCall should carry linked subagent + expect(msg.toolCalls).toContainEqual( + expect.objectContaining({ + id: 'task-1', + name: TOOL_TASK, + subagent: expect.objectContaining({ id: 'task-1' }), + }) + ); + expect(deps.subagentManager.renderPendingTask).toHaveBeenCalledWith('task-1', deps.state.currentContentEl); + }); + + it('should not crash stream when pending Task rendering returns null via child chunk', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + // Task without run_in_background - manager returns buffered + await controller.handleStreamChunk( + { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', subagent_type: 'general-purpose' } }, + msg + ); + + // Configure manager: pending task exists but render returns null (error case) + (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true); + (deps.subagentManager.renderPendingTask as jest.Mock).mockReturnValueOnce(null); + + // Child chunk arrives - renderPendingTask returns null but shouldn't crash + await controller.handleStreamChunk( + { type: 'subagent_tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' }, subagentId: 'task-1' }, + msg + ); + + // Should not throw - manager handled errors internally + expect(deps.subagentManager.renderPendingTask).toHaveBeenCalledWith('task-1', deps.state.currentContentEl); + }); + + it('should not crash stream when pending Task rendering returns null via tool_result', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + // Task without run_in_background - manager returns buffered + await controller.handleStreamChunk( + { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', subagent_type: 'general-purpose' } }, + msg + ); + + // Configure manager: pending task exists but render returns null + (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true); + (deps.subagentManager.renderPendingTaskFromTaskResult as jest.Mock).mockReturnValueOnce(null); + + // Tool result arrives - pending resolver returns null but stream should continue + await controller.handleStreamChunk( + { type: 'tool_result', id: 'task-1', content: 'Task completed' }, + msg + ); + + // Should not throw - manager handled errors internally + expect(deps.subagentManager.renderPendingTaskFromTaskResult).toHaveBeenCalledWith( + 'task-1', + 'Task completed', + false, + deps.state.currentContentEl, + undefined + ); + }); + + it('should resolve pending Task as async via tool_result and continue async lifecycle', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something' } }, + msg + ); + + (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true); + (deps.subagentManager.renderPendingTaskFromTaskResult as jest.Mock).mockReturnValueOnce({ + mode: 'async', + info: { + id: 'task-1', + description: 'Do something', + prompt: 'Do something', + mode: 'async', + isExpanded: false, + status: 'running', + toolCalls: [], + asyncStatus: 'pending', + }, + }); + (deps.subagentManager.isPendingAsyncTask as jest.Mock).mockReturnValueOnce(true); + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'task-1', content: '{"agent_id":"agent-1"}' }, + msg + ); + + expect(deps.subagentManager.renderPendingTaskFromTaskResult).toHaveBeenCalledWith( + 'task-1', + '{"agent_id":"agent-1"}', + false, + deps.state.currentContentEl, + undefined + ); + expect(deps.subagentManager.handleTaskToolResult).toHaveBeenCalledWith( + 'task-1', + '{"agent_id":"agent-1"}', + undefined, + undefined + ); + expect(msg.contentBlocks).toContainEqual({ + type: 'subagent', + subagentId: 'task-1', + mode: 'async', + }); + expect(msg.toolCalls).toContainEqual( + expect.objectContaining({ + id: 'task-1', + name: TOOL_TASK, + subagent: expect.objectContaining({ mode: 'async' }), + }) + ); + }); + + it('should pass task toolUseResult into pending Task resolver', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something' } }, + msg + ); + + const toolUseResult = { isAsync: true, status: 'async_launched', agentId: 'agent-1' }; + (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true); + (deps.subagentManager.renderPendingTaskFromTaskResult as jest.Mock).mockReturnValueOnce(null); + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'task-1', content: 'Launching...', toolUseResult } as any, + msg + ); + + expect(deps.subagentManager.renderPendingTaskFromTaskResult).toHaveBeenCalledWith( + 'task-1', + 'Launching...', + false, + deps.state.currentContentEl, + toolUseResult + ); + }); + }); + + describe('Text ↔ Thinking transitions', () => { + it('text arrives while thinking state is active → finalizeCurrentThinkingBlock is called', async () => { + const { finalizeThinkingBlock } = jest.requireMock('@/features/chat/rendering/ThinkingBlockRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + deps.state.currentThinkingState = { + content: 'Let me think...', + container: createMockEl(), + contentEl: createMockEl(), + startTime: Date.now(), + } as any; + + await controller.handleStreamChunk({ type: 'text', content: 'Hello' }, msg); + + expect(finalizeThinkingBlock).toHaveBeenCalled(); + expect(deps.state.currentThinkingState).toBeNull(); + expect(msg.contentBlocks).toContainEqual( + expect.objectContaining({ type: 'thinking', content: 'Let me think...' }) + ); + }); + + it('thinking arrives while textEl exists → finalizeCurrentTextBlock is called', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + deps.state.currentTextEl = createMockEl(); + deps.state.currentTextContent = 'Some text'; + + await controller.handleStreamChunk({ type: 'thinking', content: 'Hmm...' }, msg); + + expect(deps.state.currentTextEl).toBeNull(); + expect(msg.contentBlocks).toContainEqual( + expect.objectContaining({ type: 'text', content: 'Some text' }) + ); + expect(deps.renderer.addTextCopyButton).toHaveBeenCalledWith( + expect.anything(), + 'Some text' + ); + }); + + it('tool_use arrives while thinking state → finalizeCurrentThinkingBlock is called', async () => { + const { finalizeThinkingBlock } = jest.requireMock('@/features/chat/rendering/ThinkingBlockRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + deps.state.currentThinkingState = { + content: 'Reasoning...', + container: createMockEl(), + contentEl: createMockEl(), + startTime: Date.now(), + } as any; + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } }, + msg + ); + + expect(finalizeThinkingBlock).toHaveBeenCalled(); + expect(deps.state.currentThinkingState).toBeNull(); + expect(msg.contentBlocks).toContainEqual( + expect.objectContaining({ type: 'thinking', content: 'Reasoning...' }) + ); + }); + }); + + describe('Agent output tool use/result', () => { + it('TOOL_AGENT_OUTPUT chunk creates tool call and delegates to subagentManager.handleAgentOutputToolUse', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'agent-out-1', name: TOOL_AGENT_OUTPUT, input: { task_id: 'task-1' } }, + msg + ); + + expect(deps.subagentManager.handleAgentOutputToolUse).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'agent-out-1', + name: TOOL_AGENT_OUTPUT, + status: 'running', + }) + ); + expect(msg.toolCalls).toEqual([]); + expect(msg.contentBlocks).toEqual([]); + }); + + it('Agent output tool result handled via handleAgentOutputToolResult returning true', async () => { + const { updateToolCallResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true); + (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce({}); + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'agent-out-1', content: 'agent result', toolUseResult: { foo: 'bar' } as any }, + msg + ); + + expect(deps.subagentManager.handleAgentOutputToolResult).toHaveBeenCalledWith( + 'agent-out-1', + 'agent result', + false, + { foo: 'bar' } + ); + expect(updateToolCallResult).not.toHaveBeenCalled(); + }); + + it('async_subagent_result finalizes and hydrates the matching background subagent', async () => { + const runtime = deps.getAgentService!() as any; + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + const completedSubagent = { + id: 'task-1', + description: 'Background task', + prompt: 'Do work', + mode: 'async', + status: 'completed', + toolCalls: [], + isExpanded: false, + asyncStatus: 'completed', + agentId: 'agent-1', + result: 'Notification summary', + }; + + (deps.subagentManager.handleAsyncSubagentResult as jest.Mock).mockReturnValueOnce(completedSubagent); + runtime.loadSubagentFinalResult.mockResolvedValueOnce('Recovered final result'); + + await controller.handleStreamChunk( + { + type: 'async_subagent_result', + agentId: 'agent-1', + status: 'completed', + result: 'Notification summary', + } as any, + msg + ); + + expect(deps.subagentManager.handleAsyncSubagentResult).toHaveBeenCalledWith( + 'agent-1', + 'completed', + 'Notification summary' + ); + expect(runtime.loadSubagentToolCalls).toHaveBeenCalledWith('agent-1'); + expect(runtime.loadSubagentFinalResult).toHaveBeenCalledWith('agent-1'); + expect(completedSubagent.result).toBe('Recovered final result'); + expect(deps.subagentManager.refreshAsyncSubagent).toHaveBeenCalledWith(completedSubagent); + }); + + it('hydrates async subagent tool calls from sidecar during streaming completion', async () => { + const runtime = deps.getAgentService!() as any; + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + const completedSubagent = { + id: 'task-1', + description: 'Background task', + prompt: 'Do work', + mode: 'async', + status: 'completed', + toolCalls: [], + isExpanded: false, + asyncStatus: 'completed', + agentId: 'agent-1', + result: 'Done', + }; + + (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true); + (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce(completedSubagent); + runtime.loadSubagentToolCalls.mockResolvedValueOnce([ + { + id: 'read-1', + name: 'Read', + input: { file_path: 'notes.md' }, + status: 'completed', + result: 'content', + isExpanded: false, + }, + ]); + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'agent-out-1', content: 'agent result' }, + msg + ); + + expect(runtime.loadSubagentToolCalls).toHaveBeenCalledWith('agent-1'); + expect(runtime.loadSubagentFinalResult).toHaveBeenCalledWith('agent-1'); + expect(completedSubagent.toolCalls).toHaveLength(1); + expect(deps.subagentManager.refreshAsyncSubagent).toHaveBeenCalledWith(completedSubagent); + }); + + it('hydrates async subagent final result from sidecar even when tool calls already exist', async () => { + const runtime = deps.getAgentService!() as any; + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + const completedSubagent = { + id: 'task-2', + description: 'Background task', + prompt: 'Do work', + mode: 'async', + status: 'completed', + toolCalls: [ + { + id: 'existing-tool', + name: 'Read', + input: { file_path: 'notes.md' }, + status: 'completed', + result: 'existing', + isExpanded: false, + }, + ], + isExpanded: false, + asyncStatus: 'completed', + agentId: 'agent-2', + result: 'Short placeholder', + }; + + (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true); + (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce(completedSubagent); + runtime.loadSubagentFinalResult.mockResolvedValueOnce('Recovered final result from sidecar'); + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'agent-out-2', content: 'agent result' }, + msg + ); + + expect(runtime.loadSubagentToolCalls).not.toHaveBeenCalled(); + expect(runtime.loadSubagentFinalResult).toHaveBeenCalledWith('agent-2'); + expect(completedSubagent.result).toBe('Recovered final result from sidecar'); + expect(deps.subagentManager.refreshAsyncSubagent).toHaveBeenCalledWith(completedSubagent); + }); + + it('does not retry async subagent final result hydration when sidecar matches current result', async () => { + const runtime = deps.getAgentService!() as any; + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + const completedSubagent = { + id: 'task-2b', + description: 'Background task', + prompt: 'Do work', + mode: 'async', + status: 'completed', + toolCalls: [ + { + id: 'existing-tool', + name: 'Read', + input: { file_path: 'notes.md' }, + status: 'completed', + result: 'existing', + isExpanded: false, + }, + ], + isExpanded: false, + asyncStatus: 'completed', + agentId: 'agent-2b', + result: 'Already final', + }; + + (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true); + (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce(completedSubagent); + runtime.loadSubagentFinalResult.mockResolvedValueOnce('Already final'); + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'agent-out-2b', content: 'agent result' }, + msg + ); + + expect(runtime.loadSubagentToolCalls).not.toHaveBeenCalled(); + expect(runtime.loadSubagentFinalResult).toHaveBeenCalledTimes(1); + expect(deps.subagentManager.refreshAsyncSubagent).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(3000); + await Promise.resolve(); + await Promise.resolve(); + + expect(runtime.loadSubagentFinalResult).toHaveBeenCalledTimes(1); + }); + + it('retries async subagent final result hydration when first sidecar read is stale', async () => { + const runtime = deps.getAgentService!() as any; + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + const completedSubagent = { + id: 'task-3', + description: 'Background task', + prompt: 'Do work', + mode: 'async', + status: 'completed', + toolCalls: [ + { + id: 'existing-tool', + name: 'Read', + input: { file_path: 'notes.md' }, + status: 'completed', + result: 'existing', + isExpanded: false, + }, + ], + isExpanded: false, + asyncStatus: 'completed', + agentId: 'agent-3', + result: 'Intermediate line', + }; + + (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true); + (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce(completedSubagent); + runtime.loadSubagentFinalResult + .mockResolvedValueOnce(null) + .mockResolvedValueOnce('Recovered final result after delayed flush'); + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'agent-out-3', content: 'agent result' }, + msg + ); + + expect(runtime.loadSubagentToolCalls).not.toHaveBeenCalled(); + expect(runtime.loadSubagentFinalResult).toHaveBeenCalledTimes(1); + expect(deps.subagentManager.refreshAsyncSubagent).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(200); + await Promise.resolve(); + await Promise.resolve(); + + expect(runtime.loadSubagentFinalResult).toHaveBeenCalledTimes(2); + expect(completedSubagent.result).toBe('Recovered final result after delayed flush'); + expect(deps.subagentManager.refreshAsyncSubagent).toHaveBeenCalledWith(completedSubagent); + }); + }); + + describe('Tool header update on input re-dispatch', () => { + it('second tool_use with same id updates existing tool input and header', async () => { + const { getToolName, getToolSummary } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + // First tool_use - creates the tool call + await controller.handleStreamChunk( + { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } }, + msg + ); + + // Flush the tool so it transitions from pending to rendered + await controller.handleStreamChunk({ type: 'done' }, msg); + + // Manually set up a rendered tool element with name + summary children + // (the mock renderToolCall doesn't actually populate toolCallElements) + const toolEl = createMockEl(); + const nameChild = toolEl.createDiv({ cls: 'claudian-tool-name' }); + nameChild.setText('Read'); + const summaryChild = toolEl.createDiv({ cls: 'claudian-tool-summary' }); + summaryChild.setText('test.md'); + deps.state.toolCallElements.set('read-1', toolEl); + + getToolName.mockReturnValueOnce('Read'); + getToolSummary.mockReturnValueOnce('updated.md'); + + // Second tool_use with same id - should update input and header + await controller.handleStreamChunk( + { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'updated.md' } }, + msg + ); + + // Input should be merged + expect(msg.toolCalls![0].input).toEqual( + expect.objectContaining({ file_path: 'updated.md' }) + ); + // getToolName/getToolSummary should have been called with updated input + expect(getToolName).toHaveBeenCalledWith('Read', expect.objectContaining({ file_path: 'updated.md' })); + expect(getToolSummary).toHaveBeenCalledWith('Read', expect.objectContaining({ file_path: 'updated.md' })); + // Header texts should be updated + expect(nameChild.textContent).toBe('Read'); + expect(summaryChild.textContent).toBe('updated.md'); + }); + }); + + describe('Sync subagent finalization', () => { + it('tool_result for a sync subagent calls finalizeSyncSubagent and updates Task toolCall', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + msg.toolCalls = [ + { + id: 'task-1', + name: TOOL_TASK, + input: { description: 'Do something' }, + status: 'running', + subagent: { id: 'task-1', description: 'Do something', status: 'running', toolCalls: [], isExpanded: false }, + } as any, + ]; + + // getSyncSubagent returns a subagent state (indicating this is a sync subagent) + (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce({ + info: { id: 'task-1', description: 'Do something', status: 'running', toolCalls: [], isExpanded: false }, + }); + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'task-1', content: 'Task completed successfully' }, + msg + ); + + expect(deps.subagentManager.finalizeSyncSubagent).toHaveBeenCalledWith( + 'task-1', + 'Task completed successfully', + false, + undefined + ); + + expect(msg.toolCalls![0].status).toBe('completed'); + expect(msg.toolCalls![0].result).toBe('Task completed successfully'); + expect(msg.toolCalls![0].subagent?.status).toBe('completed'); + expect(msg.toolCalls![0].subagent?.result).toBe('Task completed successfully'); + }); + }); + + describe('Codex subagent lifecycle', () => { + it('renders prompt immediately and final result after wait_agent resolves', async () => { + const { createSubagentBlock, finalizeSubagentBlock } = jest.requireMock('@/features/chat/rendering/SubagentRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + deps.getAgentService = () => ({ + providerId: 'codex', + getCapabilities: jest.fn().mockReturnValue({ + providerId: 'codex', + supportsPlanMode: true, + planPathPrefix: '/.codex/plans/', + }), + }) as any; + + const subagentState = { + info: { id: 'spawn-1', description: 'Codex subagent', prompt: '', status: 'running', toolCalls: [] }, + labelEl: { setText: jest.fn() }, + }; + createSubagentBlock.mockReturnValueOnce(subagentState); + + await controller.handleStreamChunk( + { + type: 'tool_use', + id: 'spawn-1', + name: TOOL_SPAWN_AGENT, + input: { message: 'Inspect utils.ts and return the final patch summary.', model: 'gpt-5.4-mini' }, + }, + msg, + ); + + await controller.handleStreamChunk( + { + type: 'tool_result', + id: 'spawn-1', + content: '{"agent_id":"agent-1","nickname":"Zeno"}', + }, + msg, + ); + + await controller.handleStreamChunk( + { + type: 'tool_use', + id: 'wait-1', + name: TOOL_WAIT_AGENT, + input: { targets: ['agent-1'], timeout_ms: 30000 }, + }, + msg, + ); + + await controller.handleStreamChunk( + { + type: 'tool_result', + id: 'wait-1', + content: '{"status":{"agent-1":{"completed":"Patched utils.ts and verified imports."}},"timed_out":false}', + }, + msg, + ); + + expect(createSubagentBlock).toHaveBeenCalledWith( + expect.anything(), + 'spawn-1', + expect.objectContaining({ + description: 'Codex subagent (gpt-5.4-mini)', + prompt: 'Inspect utils.ts and return the final patch summary.', + }), + ); + expect(subagentState.info.description).toBe('Zeno (gpt-5.4-mini)'); + expect(finalizeSubagentBlock).toHaveBeenCalledWith( + subagentState, + 'Patched utils.ts and verified imports.', + false, + ); + }); + }); + + describe('Async task tool result', () => { + it('tool_result for a pending async task returns true from handleAsyncTaskToolResult', async () => { + const { updateToolCallResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + + (deps.subagentManager.isPendingAsyncTask as jest.Mock).mockReturnValueOnce(true); + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'task-1', content: 'Task started in background' }, + msg + ); + + expect(deps.subagentManager.handleTaskToolResult).toHaveBeenCalledWith( + 'task-1', + 'Task started in background', + undefined, + undefined + ); + + expect(updateToolCallResult).not.toHaveBeenCalled(); + expect(msg.toolCalls).toEqual([]); + }); + + it('passes structured toolUseResult through to async Task result handler', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = createMockEl(); + (deps.subagentManager.isPendingAsyncTask as jest.Mock).mockReturnValueOnce(true); + + const structured = { data: { agent_id: 'agent-from-structured' } }; + await controller.handleStreamChunk( + { type: 'tool_result', id: 'task-1', content: 'Task started', toolUseResult: structured } as any, + msg + ); + + expect(deps.subagentManager.handleTaskToolResult).toHaveBeenCalledWith( + 'task-1', + 'Task started', + undefined, + structured + ); + }); + + it('normalizes structured tool_result content before storing it on tool calls', async () => { + const { updateToolCallResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + const msg = createTestMessage(); + msg.toolCalls = [ + { + id: 'mcp-1', + name: 'mcp__stitch__create_project', + input: {}, + status: 'running', + isExpanded: false, + } as any, + ]; + + await controller.handleStreamChunk( + { + type: 'tool_result', + id: 'mcp-1', + content: [{ type: 'text', text: 'Created project successfully' }], + } as any, + msg, + ); + + expect(msg.toolCalls[0].status).toBe('completed'); + expect(msg.toolCalls[0].result).toBe('Created project successfully'); + expect(updateToolCallResult).toHaveBeenCalled(); + }); + }); + + describe('showThinkingIndicator - timer disconnection cleanup', () => { + it('should clear interval when timerSpan becomes disconnected from DOM', () => { + // Use a non-zero value: with fake timers, performance.now() starts at 0, + // and !0 is truthy which would cause updateTimer to return early. + jest.advanceTimersByTime(1); + deps.state.responseStartTime = performance.now(); + + controller.showThinkingIndicator(); + jest.advanceTimersByTime(500); // Past debounce delay + + expect(deps.state.flavorTimerInterval).not.toBeNull(); + + const thinkingEl = deps.state.thinkingEl; + expect(thinkingEl).not.toBeNull(); + + // The timer span is the second child (first is flavor text, second is hint) + const timerSpan = thinkingEl!.children[1]; + expect(timerSpan).toBeDefined(); + + // Mock elements don't have isConnected by default (undefined = falsy), + // so first set it to true so the timer runs normally on its first tick. + Object.defineProperty(timerSpan, 'isConnected', { value: true, writable: true, configurable: true }); + + // Advance time - interval should still run (isConnected is true) + jest.advanceTimersByTime(1000); + expect(deps.state.flavorTimerInterval).not.toBeNull(); + // Verify the interval callback actually ran by checking the timer text was updated + expect((timerSpan as any).textContent).toContain('esc to interrupt'); + + // Now simulate disconnection from DOM + (timerSpan as any).isConnected = false; + + // Advance time to trigger the interval callback + jest.advanceTimersByTime(1000); + + // Interval should have been cleared because isConnected is false + expect(deps.state.flavorTimerInterval).toBeNull(); + }); + }); + + describe('showThinkingIndicator - pre-existing interval', () => { + it('should clear pre-existing interval before creating new one', () => { + // Advance fake clock so performance.now() returns non-zero + jest.advanceTimersByTime(1); + deps.state.responseStartTime = performance.now(); + const activeWindow = deps.state.currentContentEl!.ownerDocument.defaultView!; + const clearIntervalSpy = jest.spyOn(activeWindow, 'clearInterval'); + + // Manually set a pre-existing interval + deps.state.setFlavorTimerInterval(activeWindow.setInterval(() => {}, 9999), activeWindow); + + controller.showThinkingIndicator(); + jest.advanceTimersByTime(500); + + // clearInterval should have been called for the pre-existing interval + expect(clearIntervalSpy).toHaveBeenCalled(); + + // A new interval should have been created + expect(deps.state.flavorTimerInterval).not.toBeNull(); + + clearIntervalSpy.mockRestore(); + }); + }); + + describe('appendThinking - no currentContentEl', () => { + it('should not create thinking state when currentContentEl is null', async () => { + const msg = createTestMessage(); + deps.state.currentContentEl = null; + + await controller.handleStreamChunk({ type: 'thinking', content: 'test thinking' }, msg); + + // No thinking state should be created + expect(deps.state.currentThinkingState).toBeNull(); + }); + }); + + describe('showThinkingIndicator - responseStartTime null in timer', () => { + it('should not update timer text when responseStartTime is null', () => { + // Advance fake clock so performance.now() returns non-zero + jest.advanceTimersByTime(1); + deps.state.responseStartTime = performance.now(); + + controller.showThinkingIndicator(); + jest.advanceTimersByTime(500); + + expect(deps.state.thinkingEl).not.toBeNull(); + + // Get timerSpan and set isConnected to true for proper timer operation + const timerSpan = deps.state.thinkingEl!.children[1]; + Object.defineProperty(timerSpan, 'isConnected', { value: true, configurable: true }); + + // Clear responseStartTime to trigger early return in updateTimer + deps.state.responseStartTime = null; + + // Advance time to trigger timer callback - should not throw + jest.advanceTimersByTime(1000); + + // Timer should still be set (interval not cleared by the null check) + expect(deps.state.flavorTimerInterval).not.toBeNull(); + }); + }); +}); + +describe('StreamController - Plan Mode', () => { + let controller: StreamController; + let deps: StreamControllerDeps; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers(); + installTestWindow(); + deps = createMockDeps(); + controller = new StreamController(deps); + deps.state.currentContentEl = createMockEl(); + }); + + afterEach(() => { + deps.state.resetStreamingState(); + restoreTestWindow(); + jest.useRealTimers(); + }); + + describe('capturePlanFilePath', () => { + it('should capture plan file path from Write tool_use', async () => { + const msg = createTestMessage(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: '/home/user/.claude/plans/plan.md' } }, + msg + ); + + expect(deps.state.planFilePath).toBe('/home/user/.claude/plans/plan.md'); + }); + + it('should capture plan file path with Windows backslashes', async () => { + const msg = createTestMessage(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: 'C:\\.claude\\plans\\plan.md' } }, + msg + ); + + expect(deps.state.planFilePath).toBe('C:\\.claude\\plans\\plan.md'); + }); + + it('should not capture non-plan Write paths', async () => { + const msg = createTestMessage(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: '/home/user/notes/todo.md' } }, + msg + ); + + expect(deps.state.planFilePath).toBeNull(); + }); + + it('should not capture plan path from non-Write tools', async () => { + const msg = createTestMessage(); + + await controller.handleStreamChunk( + { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: '/home/user/.claude/plans/plan.md' } }, + msg + ); + + expect(deps.state.planFilePath).toBeNull(); + }); + + it('should capture plan file path on subsequent tool_use input update', async () => { + const msg = createTestMessage(); + msg.toolCalls = [{ + id: 'write-1', + name: 'Write', + input: { content: 'plan content' }, + status: 'running', + }]; + + // Second tool_use chunk with same ID updates the input (file_path arrives later) + await controller.handleStreamChunk( + { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: '/home/user/.claude/plans/plan.md' } }, + msg + ); + + expect(deps.state.planFilePath).toBe('/home/user/.claude/plans/plan.md'); + }); + }); + + describe('blocked detection bypass', () => { + it('should hydrate AskUserQuestion resolvedAnswers from result text fallback', async () => { + const coreTools = jest.requireMock('@/core/tools/toolInput'); + (coreTools.extractResolvedAnswers as jest.Mock).mockReturnValueOnce(undefined); + (coreTools.extractResolvedAnswersFromResultText as jest.Mock).mockReturnValueOnce({ + 'Color?': 'Blue', + }); + + const msg = createTestMessage(); + msg.toolCalls = [{ + id: 'ask-1', + name: 'AskUserQuestion', + input: { questions: [{ question: 'Color?' }] }, + status: 'running', + }]; + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'ask-1', content: '"Color?"="Blue"' }, + msg + ); + + expect(msg.toolCalls![0].resolvedAnswers).toEqual({ 'Color?': 'Blue' }); + }); + + it('should not mark AskUserQuestion as blocked even when result looks blocked', async () => { + const { isBlockedToolResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + (isBlockedToolResult as jest.Mock).mockReturnValueOnce(true); + + const msg = createTestMessage(); + msg.toolCalls = [{ + id: 'ask-1', + name: 'AskUserQuestion', + input: {}, + status: 'running', + }]; + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'ask-1', content: 'User denied this action.' }, + msg + ); + + expect(msg.toolCalls![0].status).toBe('completed'); + }); + + it('should not mark ExitPlanMode as blocked even when result looks blocked', async () => { + const { isBlockedToolResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + (isBlockedToolResult as jest.Mock).mockReturnValueOnce(true); + + const msg = createTestMessage(); + msg.toolCalls = [{ + id: 'exit-1', + name: 'ExitPlanMode', + input: {}, + status: 'running', + }]; + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'exit-1', content: 'User denied.' }, + msg + ); + + expect(msg.toolCalls![0].status).toBe('completed'); + }); + + it('should mark regular tool as blocked when result is blocked', async () => { + const { isBlockedToolResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer'); + (isBlockedToolResult as jest.Mock).mockReturnValueOnce(true); + + const msg = createTestMessage(); + msg.toolCalls = [{ + id: 'bash-1', + name: 'Bash', + input: { command: 'rm -rf /' }, + status: 'running', + }]; + + await controller.handleStreamChunk( + { type: 'tool_result', id: 'bash-1', content: 'Access denied by user approval' }, + msg + ); + + expect(msg.toolCalls![0].status).toBe('blocked'); + }); + }); +}); diff --git a/tests/unit/features/chat/controllers/TurnCoordinator.test.ts b/tests/unit/features/chat/controllers/TurnCoordinator.test.ts new file mode 100644 index 0000000..5e61efc --- /dev/null +++ b/tests/unit/features/chat/controllers/TurnCoordinator.test.ts @@ -0,0 +1,31 @@ +import { TurnCoordinator } from '@/features/chat/controllers/TurnCoordinator'; + +describe('TurnCoordinator', () => { + it('owns the active execution and clears it after success', async () => { + let release!: () => void; + const pending = new Promise(resolve => { release = resolve; }); + const owner = { activeTurn: null as Promise | null }; + const coordinator = new TurnCoordinator(() => pending, owner); + + const run = coordinator.run(); + expect(coordinator.current).toBe(pending); + expect(owner.activeTurn).toBe(pending); + + release(); + await run; + expect(coordinator.current).toBeNull(); + expect(owner.activeTurn).toBeNull(); + }); + + it('clears ownership without swallowing execution failures', async () => { + const owner = { activeTurn: null as Promise | null }; + const coordinator = new TurnCoordinator( + async () => { throw new Error('turn failed'); }, + owner, + ); + + await expect(coordinator.run()).rejects.toThrow('turn failed'); + expect(coordinator.current).toBeNull(); + expect(owner.activeTurn).toBeNull(); + }); +}); diff --git a/tests/unit/features/chat/controllers/index.test.ts b/tests/unit/features/chat/controllers/index.test.ts new file mode 100644 index 0000000..5519366 --- /dev/null +++ b/tests/unit/features/chat/controllers/index.test.ts @@ -0,0 +1,16 @@ +import { ConversationController } from '@/features/chat/controllers/ConversationController'; +import { InputController } from '@/features/chat/controllers/InputController'; +import { NavigationController } from '@/features/chat/controllers/NavigationController'; +import { SelectionController } from '@/features/chat/controllers/SelectionController'; +import { StreamController } from '@/features/chat/controllers/StreamController'; + +describe('features/chat/controllers index', () => { + it('re-exports runtime symbols', () => { + expect(ConversationController).toBeDefined(); + expect(InputController).toBeDefined(); + expect(NavigationController).toBeDefined(); + expect(SelectionController).toBeDefined(); + expect(StreamController).toBeDefined(); + }); +}); + diff --git a/tests/unit/features/chat/rendering/DiffRenderer.test.ts b/tests/unit/features/chat/rendering/DiffRenderer.test.ts new file mode 100644 index 0000000..a61f96c --- /dev/null +++ b/tests/unit/features/chat/rendering/DiffRenderer.test.ts @@ -0,0 +1,355 @@ +import { createMockEl } from '@test/helpers/mockElement'; + +import type { DiffLine, StructuredPatchHunk } from '@/core/types/diff'; +import { renderDiffContent, splitIntoHunks } from '@/features/chat/rendering/DiffRenderer'; +import { countLineChanges, structuredPatchToDiffLines } from '@/utils/diff'; + +/** Recursively count elements matching a class. */ +function countByClass(el: any, cls: string): number { + let count = el.hasClass(cls) ? 1 : 0; + for (const child of el._children) count += countByClass(child, cls); + return count; +} + +/** Generate N insert DiffLines. */ +function makeInsertLines(n: number): DiffLine[] { + return Array.from({ length: n }, (_, i) => ({ + type: 'insert' as const, + text: `line ${i + 1}`, + newLineNum: i + 1, + })); +} + +describe('DiffRenderer', () => { + describe('structuredPatchToDiffLines', () => { + it('should return empty array for empty hunks', () => { + const result = structuredPatchToDiffLines([]); + expect(result).toEqual([]); + }); + + it('should convert a simple insertion hunk', () => { + const hunks: StructuredPatchHunk[] = [{ + oldStart: 1, oldLines: 2, newStart: 1, newLines: 3, + lines: [' line1', '+inserted', ' line2'], + }]; + const result = structuredPatchToDiffLines(hunks); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }); + expect(result[1]).toEqual({ type: 'insert', text: 'inserted', newLineNum: 2 }); + expect(result[2]).toEqual({ type: 'equal', text: 'line2', oldLineNum: 2, newLineNum: 3 }); + }); + + it('should convert a simple deletion hunk', () => { + const hunks: StructuredPatchHunk[] = [{ + oldStart: 1, oldLines: 3, newStart: 1, newLines: 2, + lines: [' line1', '-deleted', ' line2'], + }]; + const result = structuredPatchToDiffLines(hunks); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }); + expect(result[1]).toEqual({ type: 'delete', text: 'deleted', oldLineNum: 2 }); + expect(result[2]).toEqual({ type: 'equal', text: 'line2', oldLineNum: 3, newLineNum: 2 }); + }); + + it('should convert a replacement (delete + insert)', () => { + const hunks: StructuredPatchHunk[] = [{ + oldStart: 1, oldLines: 3, newStart: 1, newLines: 3, + lines: [' line1', '-old', '+new', ' line3'], + }]; + const result = structuredPatchToDiffLines(hunks); + + expect(result).toHaveLength(4); + expect(result[0]).toEqual({ type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }); + expect(result[1]).toEqual({ type: 'delete', text: 'old', oldLineNum: 2 }); + expect(result[2]).toEqual({ type: 'insert', text: 'new', newLineNum: 2 }); + expect(result[3]).toEqual({ type: 'equal', text: 'line3', oldLineNum: 3, newLineNum: 3 }); + }); + + it('should handle multiple hunks', () => { + const hunks: StructuredPatchHunk[] = [ + { + oldStart: 1, oldLines: 2, newStart: 1, newLines: 2, + lines: [' ctx', '-old1', '+new1'], + }, + { + oldStart: 10, oldLines: 2, newStart: 10, newLines: 2, + lines: [' ctx2', '-old2', '+new2'], + }, + ]; + const result = structuredPatchToDiffLines(hunks); + + expect(result).toHaveLength(6); + // First hunk + expect(result[0]).toEqual({ type: 'equal', text: 'ctx', oldLineNum: 1, newLineNum: 1 }); + expect(result[1]).toEqual({ type: 'delete', text: 'old1', oldLineNum: 2 }); + expect(result[2]).toEqual({ type: 'insert', text: 'new1', newLineNum: 2 }); + // Second hunk + expect(result[3]).toEqual({ type: 'equal', text: 'ctx2', oldLineNum: 10, newLineNum: 10 }); + expect(result[4]).toEqual({ type: 'delete', text: 'old2', oldLineNum: 11 }); + expect(result[5]).toEqual({ type: 'insert', text: 'new2', newLineNum: 11 }); + }); + + it('should handle hunk with only insertions (new file)', () => { + const hunks: StructuredPatchHunk[] = [{ + oldStart: 0, oldLines: 0, newStart: 1, newLines: 3, + lines: ['+line1', '+line2', '+line3'], + }]; + const result = structuredPatchToDiffLines(hunks); + + expect(result).toHaveLength(3); + expect(result.every(l => l.type === 'insert')).toBe(true); + expect(result[0]).toEqual({ type: 'insert', text: 'line1', newLineNum: 1 }); + expect(result[1]).toEqual({ type: 'insert', text: 'line2', newLineNum: 2 }); + expect(result[2]).toEqual({ type: 'insert', text: 'line3', newLineNum: 3 }); + }); + + it('should handle lines with special characters', () => { + const hunks: StructuredPatchHunk[] = [{ + oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, + lines: ['-return "bar";', '+return `bar`;'], + }]; + const result = structuredPatchToDiffLines(hunks); + + expect(result[0].text).toBe('return "bar";'); + expect(result[1].text).toBe('return `bar`;'); + }); + + it('should handle unicode content', () => { + const hunks: StructuredPatchHunk[] = [{ + oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, + lines: ['-こんにちは', '+さようなら'], + }]; + const result = structuredPatchToDiffLines(hunks); + + expect(result[0]).toEqual({ type: 'delete', text: 'こんにちは', oldLineNum: 1 }); + expect(result[1]).toEqual({ type: 'insert', text: 'さようなら', newLineNum: 1 }); + }); + + it('should track line numbers correctly across mixed operations', () => { + const hunks: StructuredPatchHunk[] = [{ + oldStart: 5, oldLines: 4, newStart: 5, newLines: 5, + lines: [' ctx', '-del1', '-del2', '+ins1', '+ins2', '+ins3', ' ctx2'], + }]; + const result = structuredPatchToDiffLines(hunks); + + // Context: oldLine=5, newLine=5 + expect(result[0]).toEqual({ type: 'equal', text: 'ctx', oldLineNum: 5, newLineNum: 5 }); + // Deletes: oldLine 6,7 + expect(result[1]).toEqual({ type: 'delete', text: 'del1', oldLineNum: 6 }); + expect(result[2]).toEqual({ type: 'delete', text: 'del2', oldLineNum: 7 }); + // Inserts: newLine 6,7,8 + expect(result[3]).toEqual({ type: 'insert', text: 'ins1', newLineNum: 6 }); + expect(result[4]).toEqual({ type: 'insert', text: 'ins2', newLineNum: 7 }); + expect(result[5]).toEqual({ type: 'insert', text: 'ins3', newLineNum: 8 }); + // Context: oldLine=8, newLine=9 + expect(result[6]).toEqual({ type: 'equal', text: 'ctx2', oldLineNum: 8, newLineNum: 9 }); + }); + }); + + describe('countLineChanges', () => { + it('should return zeros for no changes', () => { + const diffLines: DiffLine[] = [ + { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, + { type: 'equal', text: 'line2', oldLineNum: 2, newLineNum: 2 }, + ]; + const stats = countLineChanges(diffLines); + expect(stats).toEqual({ added: 0, removed: 0 }); + }); + + it('should count inserted lines', () => { + const diffLines: DiffLine[] = [ + { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, + { type: 'insert', text: 'new1', newLineNum: 2 }, + { type: 'insert', text: 'new2', newLineNum: 3 }, + { type: 'equal', text: 'line2', oldLineNum: 2, newLineNum: 4 }, + ]; + const stats = countLineChanges(diffLines); + expect(stats).toEqual({ added: 2, removed: 0 }); + }); + + it('should count deleted lines', () => { + const diffLines: DiffLine[] = [ + { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, + { type: 'delete', text: 'old1', oldLineNum: 2 }, + { type: 'delete', text: 'old2', oldLineNum: 3 }, + { type: 'equal', text: 'line2', oldLineNum: 4, newLineNum: 2 }, + ]; + const stats = countLineChanges(diffLines); + expect(stats).toEqual({ added: 0, removed: 2 }); + }); + + it('should count both insertions and deletions', () => { + const diffLines: DiffLine[] = [ + { type: 'delete', text: 'old', oldLineNum: 1 }, + { type: 'insert', text: 'new1', newLineNum: 1 }, + { type: 'insert', text: 'new2', newLineNum: 2 }, + ]; + const stats = countLineChanges(diffLines); + expect(stats).toEqual({ added: 2, removed: 1 }); + }); + + it('should return zeros for empty array', () => { + const stats = countLineChanges([]); + expect(stats).toEqual({ added: 0, removed: 0 }); + }); + }); + + describe('splitIntoHunks', () => { + it('should return empty array for no changes', () => { + const diffLines: DiffLine[] = [ + { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, + { type: 'equal', text: 'line2', oldLineNum: 2, newLineNum: 2 }, + ]; + const hunks = splitIntoHunks(diffLines); + expect(hunks).toEqual([]); + }); + + it('should return empty array for empty diff', () => { + const hunks = splitIntoHunks([]); + expect(hunks).toEqual([]); + }); + + it('should create single hunk for adjacent changes', () => { + const diffLines: DiffLine[] = [ + { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, + { type: 'delete', text: 'old', oldLineNum: 2 }, + { type: 'insert', text: 'new', newLineNum: 2 }, + { type: 'equal', text: 'line2', oldLineNum: 3, newLineNum: 3 }, + ]; + const hunks = splitIntoHunks(diffLines, 3); + + expect(hunks).toHaveLength(1); + expect(hunks[0].lines).toHaveLength(4); + }); + + it('should include context lines around changes', () => { + const lines: DiffLine[] = []; + // 10 equal lines, then 1 change, then 10 equal lines + for (let i = 1; i <= 10; i++) { + lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i }); + } + lines.push({ type: 'insert', text: 'inserted', newLineNum: 11 }); + for (let i = 11; i <= 20; i++) { + lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i + 1 }); + } + + const hunks = splitIntoHunks(lines, 3); + + expect(hunks).toHaveLength(1); + // Should include 3 context lines before, 1 change, 3 context lines after = 7 lines + expect(hunks[0].lines.length).toBe(7); + }); + + it('should create multiple hunks for distant changes', () => { + const lines: DiffLine[] = []; + // 10 equal lines + for (let i = 1; i <= 10; i++) { + lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i }); + } + // 1 change + lines.push({ type: 'insert', text: 'change1', newLineNum: 11 }); + // 20 equal lines (more than 2*context, so hunks will be separate) + for (let i = 11; i <= 30; i++) { + lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i + 1 }); + } + // Another change + lines.push({ type: 'insert', text: 'change2', newLineNum: 32 }); + // 10 more equal lines + for (let i = 31; i <= 40; i++) { + lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i + 2 }); + } + + const hunks = splitIntoHunks(lines, 3); + + expect(hunks).toHaveLength(2); + }); + + it('should merge overlapping context regions into single hunk', () => { + const lines: DiffLine[] = []; + // 3 equal lines + for (let i = 1; i <= 3; i++) { + lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i }); + } + // Change 1 + lines.push({ type: 'insert', text: 'change1', newLineNum: 4 }); + // 4 equal lines (less than 2*3=6, so contexts overlap) + for (let i = 4; i <= 7; i++) { + lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i + 1 }); + } + // Change 2 + lines.push({ type: 'insert', text: 'change2', newLineNum: 9 }); + // 3 equal lines + for (let i = 8; i <= 10; i++) { + lines.push({ type: 'equal', text: `line${i}`, oldLineNum: i, newLineNum: i + 2 }); + } + + const hunks = splitIntoHunks(lines, 3); + + // Should merge into single hunk since context regions overlap + expect(hunks).toHaveLength(1); + }); + + it('should calculate correct starting line numbers for hunks', () => { + const lines: DiffLine[] = [ + { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, + { type: 'equal', text: 'line2', oldLineNum: 2, newLineNum: 2 }, + { type: 'equal', text: 'line3', oldLineNum: 3, newLineNum: 3 }, + { type: 'delete', text: 'old', oldLineNum: 4 }, + { type: 'insert', text: 'new', newLineNum: 4 }, + { type: 'equal', text: 'line5', oldLineNum: 5, newLineNum: 5 }, + ]; + + const hunks = splitIntoHunks(lines, 2); + + expect(hunks).toHaveLength(1); + expect(hunks[0].oldStart).toBe(2); // Context starts at line 2 + expect(hunks[0].newStart).toBe(2); + }); + }); + + describe('renderDiffContent', () => { + it('should render all lines when all-inserts count is within cap', () => { + const container = createMockEl(); + const lines = makeInsertLines(20); + + renderDiffContent(container, lines); + + // All 20 insert lines rendered, no separator + expect(countByClass(container, 'claudian-diff-insert')).toBe(20); + expect(countByClass(container, 'claudian-diff-separator')).toBe(0); + }); + + it('should cap all-inserts diff at 20 lines with remainder message', () => { + const container = createMockEl(); + const lines = makeInsertLines(100); + + renderDiffContent(container, lines); + + // Only 20 insert lines rendered + expect(countByClass(container, 'claudian-diff-insert')).toBe(20); + + // Separator shows remaining count + const separator = container._children.find( + (c: any) => c.hasClass('claudian-diff-separator'), + ); + expect(separator).toBeDefined(); + expect(separator.textContent).toBe('... 80 more lines'); + }); + + it('should not cap mixed diff lines (edits with context)', () => { + const container = createMockEl(); + // Build a diff with equal + insert lines — not all-inserts + const lines: DiffLine[] = [ + { type: 'equal', text: 'ctx', oldLineNum: 1, newLineNum: 1 }, + ...makeInsertLines(30), + ]; + + renderDiffContent(container, lines); + + // All 30 insert lines rendered (not capped because not all-inserts) + expect(countByClass(container, 'claudian-diff-insert')).toBe(30); + }); + }); +}); diff --git a/tests/unit/features/chat/rendering/InlineAskUserQuestion.test.ts b/tests/unit/features/chat/rendering/InlineAskUserQuestion.test.ts new file mode 100644 index 0000000..0ee9686 --- /dev/null +++ b/tests/unit/features/chat/rendering/InlineAskUserQuestion.test.ts @@ -0,0 +1,1035 @@ +import { createMockEl } from '@test/helpers/mockElement'; + +import { type InlineAskQuestionConfig, InlineAskUserQuestion } from '@/features/chat/rendering/InlineAskUserQuestion'; + +beforeAll(() => { + globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { + cb(0); + return 0; + }; + // Mock document.activeElement for focus checks in updateFocusIndicator + (globalThis as any).document = { activeElement: null }; +}); + +function makeInput( + questions: Array<{ + question: string; + options: unknown[]; + multiSelect?: boolean; + header?: string; + isOther?: boolean; + }>, +): Record { + return { questions }; +} + +function renderWidget( + input: Record, + signal?: AbortSignal, +): { container: any; resolve: jest.Mock; widget: InlineAskUserQuestion } { + const container = createMockEl(); + const resolve = jest.fn(); + const widget = new InlineAskUserQuestion(container, input, resolve, signal); + widget.render(); + return { container, resolve, widget }; +} + +function fireKeyDown( + root: any, + key: string, + opts: { shiftKey?: boolean } = {}, +): void { + const event = { + type: 'keydown', + key, + shiftKey: opts.shiftKey ?? false, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + root.dispatchEvent(event); +} + +function findRoot(container: any): any { + return container.querySelector('.claudian-ask-question-inline'); +} + +function findItems(container: any): any[] { + return container.querySelectorAll('claudian-ask-item'); +} + +describe('InlineAskUserQuestion', () => { + describe('parseQuestions', () => { + it('resolves null when input has no questions', () => { + const { resolve } = renderWidget({}); + expect(resolve).toHaveBeenCalledWith(null); + }); + + it('resolves null when questions is not an array', () => { + const { resolve } = renderWidget({ questions: 'bad' }); + expect(resolve).toHaveBeenCalledWith(null); + }); + + it('resolves null when questions array is empty', () => { + const { resolve } = renderWidget({ questions: [] }); + expect(resolve).toHaveBeenCalledWith(null); + }); + + it('filters out questions with no options', () => { + const input = makeInput([ + { question: 'Q1', options: [] }, + { question: 'Q2', options: ['A'] }, + ]); + const { resolve } = renderWidget(input); + // Should render — Q2 is valid + expect(resolve).not.toHaveBeenCalled(); + }); + + it('resolves null when all questions have empty options', () => { + const input = makeInput([ + { question: 'Q1', options: [] }, + { question: 'Q2', options: [] }, + ]); + const { resolve } = renderWidget(input); + expect(resolve).toHaveBeenCalledWith(null); + }); + + it('keeps questions that only allow free-form input', () => { + const input = { + questions: [ + { + id: 'secret_q', + question: 'Enter token', + header: 'Token', + options: null, + isOther: true, + isSecret: true, + }, + ], + }; + const { container, resolve } = renderWidget(input); + + expect(resolve).not.toHaveBeenCalled(); + expect(container.querySelector('claudian-ask-custom-item')).not.toBeNull(); + }); + + it('filters out entries missing required fields', () => { + const input = { + questions: [ + { question: 'Valid', options: ['A'] }, + { options: ['B'] }, // missing question + 'not an object', + null, + ], + }; + const { resolve } = renderWidget(input); + // Only "Valid" survives — widget should render + expect(resolve).not.toHaveBeenCalled(); + }); + + it('deduplicates options with the same label', () => { + const input = makeInput([ + { question: 'Pick', options: ['A', 'A', 'B'] }, + ]); + const { container } = renderWidget(input); + // Find option items (excluding custom input row) + const items = container.querySelectorAll('claudian-ask-item'); + // 2 unique options + 1 custom input row = 3 + const optionLabels = items + .filter((item: any) => !item.hasClass('claudian-ask-custom-item')) + .map((item: any) => { + const labelEl = item.querySelector('claudian-ask-item-label'); + return labelEl?.textContent; + }); + expect(optionLabels).toEqual(['A', 'B']); + }); + + it('uses header when provided, falls back to Q index', () => { + const input = makeInput([ + { question: 'First', options: ['A'], header: 'MyHeader' }, + { question: 'Second', options: ['B'] }, + ]); + const { container } = renderWidget(input); + const tabLabels = container.querySelectorAll('claudian-ask-tab-label'); + // Tab labels: MyHeader, Q2, Submit + expect(tabLabels[0]?.textContent).toBe('MyHeader'); + expect(tabLabels[1]?.textContent).toBe('Q2'); + }); + + it('treats non-boolean multiSelect values as false', () => { + const input = { + questions: [ + { question: 'Pick one', options: ['A', 'B'], multiSelect: 'false' }, + ], + }; + const { container } = renderWidget(input); + + const items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + items[0]?.click(); + + expect(container.querySelector('claudian-ask-review-title')?.textContent).toBe('Review your answers'); + }); + + it('truncates header to 12 characters', () => { + const input = makeInput([ + { question: 'Q', options: ['A'], header: 'VeryLongHeaderText' }, + ]); + const { container } = renderWidget(input); + const tabLabels = container.querySelectorAll('claudian-ask-tab-label'); + expect(tabLabels[0]?.textContent).toBe('VeryLongHead'); + }); + }); + + describe('coerceOption / extractLabel', () => { + it('handles string options', () => { + const input = makeInput([{ question: 'Q', options: ['Yes', 'No'] }]); + const { container } = renderWidget(input); + const labels = container + .querySelectorAll('claudian-ask-item-label') + .map((el: any) => el.textContent); + expect(labels).toContain('Yes'); + expect(labels).toContain('No'); + }); + + it('extracts label from object with label property', () => { + const input = makeInput([ + { + question: 'Q', + options: [ + { label: 'Option A', description: 'desc A' }, + { value: 'Option B' }, + { text: 'Option C' }, + { name: 'Option D' }, + ], + }, + ]); + const { container } = renderWidget(input); + const labels = container + .querySelectorAll('claudian-ask-item-label') + .map((el: any) => el.textContent); + expect(labels).toContain('Option A'); + expect(labels).toContain('Option B'); + expect(labels).toContain('Option C'); + expect(labels).toContain('Option D'); + }); + + it('shows description when provided', () => { + const input = makeInput([ + { question: 'Q', options: [{ label: 'A', description: 'Some desc' }] }, + ]); + const { container } = renderWidget(input); + const descEl = container.querySelector('claudian-ask-item-desc'); + expect(descEl?.textContent).toBe('Some desc'); + }); + + it('coerces non-string/non-object options to string', () => { + const input = makeInput([{ question: 'Q', options: [42] }]); + const { container } = renderWidget(input); + const labels = container + .querySelectorAll('claudian-ask-item-label') + .map((el: any) => el.textContent); + expect(labels).toContain('42'); + }); + + it('uses option value for resolution when provided', () => { + const input = makeInput([ + { + question: 'Q', + options: [{ label: 'Approve and remember', value: 'allow_with_policy' }], + }, + ]); + const { container } = renderWidget(input); + const labels = container + .querySelectorAll('claudian-ask-item-label') + .map((el: any) => el.textContent); + expect(labels).toContain('Approve and remember'); + }); + }); + + describe('selectOption', () => { + it('selects single-select option via click', () => { + jest.useFakeTimers(); + const input = makeInput([ + { question: 'Pick one', options: ['A', 'B'] }, + ]); + const { container, resolve } = renderWidget(input); + + // Click first option + const items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + items[0]?.click(); + jest.advanceTimersByTime(200); + + // Auto-advanced to submit tab — now submit + const submitItems = container.querySelectorAll('claudian-ask-item'); + const submitRow = submitItems.find( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + submitRow?.click(); + + expect(resolve).toHaveBeenCalledWith({ 'Pick one': 'A' }); + jest.useRealTimers(); + }); + + it('toggles multi-select options', () => { + const input = makeInput([ + { question: 'Pick many', options: ['X', 'Y', 'Z'], multiSelect: true }, + ]); + const { container } = renderWidget(input); + + const items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + // Select X and Y + items[0]?.click(); + items[1]?.click(); + + // Check marks for multi-select + const checks = container.querySelectorAll('claudian-ask-check'); + const checkedCount = checks.filter((c: any) => c.hasClass('is-checked')).length; + expect(checkedCount).toBe(2); + + // Deselect X + items[0]?.click(); + const checksAfter = container.querySelectorAll('claudian-ask-check'); + const checkedAfter = checksAfter.filter((c: any) => c.hasClass('is-checked')).length; + expect(checkedAfter).toBe(1); + }); + }); + + describe('handleSubmit', () => { + it('does not submit when not all questions are answered', () => { + const input = makeInput([ + { question: 'Q1', options: ['A'] }, + { question: 'Q2', options: ['B'] }, + ]); + const { container, resolve } = renderWidget(input); + + // Navigate to submit tab without answering + const root = findRoot(container); + fireKeyDown(root, 'Tab'); + + // Try to submit + fireKeyDown(root, 'Enter'); + // Should navigate to submit tab first, not resolve + // Eventually press Enter on submit tab + fireKeyDown(root, 'Tab'); + fireKeyDown(root, 'Enter'); + // Still not submitted because not all answered + expect(resolve).not.toHaveBeenCalled(); + }); + + it('submits answers with correct question-answer mapping', () => { + jest.useFakeTimers(); + const input = makeInput([ + { question: 'Color?', options: ['Red', 'Blue'] }, + { question: 'Size?', options: ['S', 'M', 'L'] }, + ]); + const { container, resolve } = renderWidget(input); + + // Select "Red" for Q1 + const items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + items[0]?.click(); + jest.advanceTimersByTime(200); + + // Now on Q2 — select "M" (index 1) + const q2Items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + q2Items[1]?.click(); + jest.advanceTimersByTime(200); + + // Now on submit tab — click submit + const submitItems = container.querySelectorAll('claudian-ask-item'); + const submitRow = submitItems.find( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + submitRow?.click(); + + expect(resolve).toHaveBeenCalledWith({ + 'Color?': 'Red', + 'Size?': 'M', + }); + jest.useRealTimers(); + }); + + it('submits multi-select answers as arrays instead of joining them into one string', () => { + const input = makeInput([ + { question: 'Pick many', options: ['X', 'Y', 'Z'], multiSelect: true }, + ]); + const { container, resolve } = renderWidget(input); + + const items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + items[0]?.click(); + items[1]?.click(); + + const root = findRoot(container); + fireKeyDown(root, 'Tab'); + fireKeyDown(root, 'Enter'); + + expect(resolve).toHaveBeenCalledWith({ + 'Pick many': ['X', 'Y'], + }); + }); + }); + + describe('question metadata', () => { + it('shows custom input only when the question allows "other"', () => { + const disallowOtherInput = { + questions: [{ question: 'Pick one', options: ['A', 'B'], isOther: false }], + }; + const { container: noOther } = renderWidget(disallowOtherInput); + expect(noOther.querySelectorAll('claudian-ask-custom-item')).toHaveLength(0); + + const allowOtherInput = { + questions: [{ question: 'Pick one', options: ['A', 'B'], isOther: true }], + }; + const { container: withOther } = renderWidget(allowOtherInput); + expect(withOther.querySelectorAll('claudian-ask-custom-item')).toHaveLength(1); + }); + + it('renders secret free-form questions with password input', () => { + const input = { + questions: [ + { + question: 'Enter API token', + options: null, + isOther: true, + isSecret: true, + }, + ], + }; + const { container } = renderWidget(input); + + const customInput = container.querySelector('claudian-ask-custom-text'); + expect(customInput?.getAttribute('type')).toBe('password'); + }); + }); + + describe('question id keying', () => { + it('keys submit results by question id when id is provided', () => { + jest.useFakeTimers(); + const input = { + questions: [ + { id: 'color_q', question: 'Favorite color?', options: ['Red', 'Blue'], header: 'Color' }, + { id: 'size_q', question: 'Preferred size?', options: ['S', 'M'], header: 'Size' }, + ], + }; + const { container, resolve } = renderWidget(input); + + // Select "Red" for Q1 + const items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + items[0]?.click(); + jest.advanceTimersByTime(200); + + // Select "M" for Q2 + const q2Items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + q2Items[1]?.click(); + jest.advanceTimersByTime(200); + + // Submit + const submitItems = container.querySelectorAll('claudian-ask-item'); + const submitRow = submitItems.find( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + submitRow?.click(); + + expect(resolve).toHaveBeenCalledWith({ + color_q: 'Red', + size_q: 'M', + }); + jest.useRealTimers(); + }); + + it('falls back to question text when id is not provided', () => { + jest.useFakeTimers(); + const input = makeInput([{ question: 'Pick one', options: ['A', 'B'] }]); + const { container, resolve } = renderWidget(input); + + const items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + items[0]?.click(); + jest.advanceTimersByTime(200); + + const submitItems = container.querySelectorAll('claudian-ask-item'); + const submitRow = submitItems.find( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + submitRow?.click(); + + expect(resolve).toHaveBeenCalledWith({ 'Pick one': 'A' }); + jest.useRealTimers(); + }); + }); + + describe('abort lifecycle', () => { + it('resolves null when signal is aborted', () => { + const controller = new AbortController(); + const input = makeInput([{ question: 'Q', options: ['A'] }]); + const { resolve } = renderWidget(input, controller.signal); + + expect(resolve).not.toHaveBeenCalled(); + controller.abort(); + expect(resolve).toHaveBeenCalledWith(null); + }); + + it('does not double-resolve on abort after manual resolve', () => { + const controller = new AbortController(); + const input = makeInput([{ question: 'Q', options: ['A'] }]); + const { container, resolve } = renderWidget(input, controller.signal); + + // Cancel via Escape + const root = findRoot(container); + fireKeyDown(root, 'Escape'); + expect(resolve).toHaveBeenCalledTimes(1); + + // Abort should not trigger a second resolve + controller.abort(); + expect(resolve).toHaveBeenCalledTimes(1); + }); + + it('cleans up abort listener on resolve', () => { + const controller = new AbortController(); + const input = makeInput([{ question: 'Q', options: ['A'] }]); + const { container, resolve } = renderWidget(input, controller.signal); + + // Cancel via Escape + const root = findRoot(container); + fireKeyDown(root, 'Escape'); + expect(resolve).toHaveBeenCalledTimes(1); + expect(resolve).toHaveBeenCalledWith(null); + }); + }); + + describe('destroy', () => { + it('resolves null on destroy', () => { + const input = makeInput([{ question: 'Q', options: ['A'] }]); + const { resolve, widget } = renderWidget(input); + + widget.destroy(); + expect(resolve).toHaveBeenCalledWith(null); + }); + + it('does not double-resolve if already resolved', () => { + const input = makeInput([{ question: 'Q', options: ['A'] }]); + const { container, resolve, widget } = renderWidget(input); + + const root = findRoot(container); + fireKeyDown(root, 'Escape'); + expect(resolve).toHaveBeenCalledTimes(1); + + widget.destroy(); + expect(resolve).toHaveBeenCalledTimes(1); + }); + }); + + describe('keyboard navigation', () => { + it('Escape resolves null', () => { + const input = makeInput([{ question: 'Q', options: ['A', 'B'] }]); + const { container, resolve } = renderWidget(input); + + const root = findRoot(container); + fireKeyDown(root, 'Escape'); + expect(resolve).toHaveBeenCalledWith(null); + }); + + it('ArrowDown moves focus down', () => { + const input = makeInput([{ question: 'Q', options: ['A', 'B'] }]); + const { container } = renderWidget(input); + const root = findRoot(container); + + // Initially focused on item 0 + fireKeyDown(root, 'ArrowDown'); + + const items = findItems(container); + // Item 1 should now be focused + expect(items[1]?.hasClass('is-focused')).toBe(true); + }); + + it('ArrowDown clamps at max index', () => { + const input = makeInput([{ question: 'Q', options: ['A'] }]); + const { container } = renderWidget(input); + const root = findRoot(container); + + // Press ArrowDown many times + fireKeyDown(root, 'ArrowDown'); + fireKeyDown(root, 'ArrowDown'); + fireKeyDown(root, 'ArrowDown'); + + // Should not crash, max focus is 1 (option A + custom input) + const items = findItems(container); + // Last item (custom input) should be focused + expect(items[items.length - 1]?.hasClass('is-focused')).toBe(true); + }); + + it('ArrowDown clamps at last option when custom input is hidden', () => { + const input = makeInput([{ question: 'Q', options: ['A', 'B'] }]); + const container = createMockEl(); + const resolve = jest.fn(); + const widget = new InlineAskUserQuestion(container, input, resolve, undefined, { showCustomInput: false }); + widget.render(); + const root = findRoot(container); + + fireKeyDown(root, 'ArrowDown'); + fireKeyDown(root, 'ArrowDown'); + fireKeyDown(root, 'ArrowDown'); + + const items = findItems(container); + expect(items).toHaveLength(2); + expect(items[1]?.hasClass('is-focused')).toBe(true); + }); + + it('ArrowUp moves focus up and clamps at 0', () => { + const input = makeInput([{ question: 'Q', options: ['A', 'B'] }]); + const { container } = renderWidget(input); + const root = findRoot(container); + + // Move down then back up past 0 + fireKeyDown(root, 'ArrowDown'); + fireKeyDown(root, 'ArrowUp'); + fireKeyDown(root, 'ArrowUp'); + + const items = findItems(container); + expect(items[0]?.hasClass('is-focused')).toBe(true); + }); + + it('Tab navigates to next question tab', () => { + const input = makeInput([ + { question: 'Q1', options: ['A'] }, + { question: 'Q2', options: ['B'] }, + ]); + const { container } = renderWidget(input); + const root = findRoot(container); + + fireKeyDown(root, 'Tab'); + + // Should now be on Q2 — check tab bar + const tabs = container.querySelectorAll('claudian-ask-tab'); + expect(tabs[1]?.hasClass('is-active')).toBe(true); + }); + + it('Shift+Tab navigates to previous tab', () => { + const input = makeInput([ + { question: 'Q1', options: ['A'] }, + { question: 'Q2', options: ['B'] }, + ]); + const { container } = renderWidget(input); + const root = findRoot(container); + + // Go to Q2 then back + fireKeyDown(root, 'Tab'); + fireKeyDown(root, 'Tab', { shiftKey: true }); + + const tabs = container.querySelectorAll('claudian-ask-tab'); + expect(tabs[0]?.hasClass('is-active')).toBe(true); + }); + + it('ArrowRight navigates forward on question tab', () => { + const input = makeInput([ + { question: 'Q1', options: ['A'] }, + { question: 'Q2', options: ['B'] }, + ]); + const { container } = renderWidget(input); + const root = findRoot(container); + + fireKeyDown(root, 'ArrowRight'); + + const tabs = container.querySelectorAll('claudian-ask-tab'); + expect(tabs[1]?.hasClass('is-active')).toBe(true); + }); + + it('Enter on submit tab calls handleSubmit', () => { + jest.useFakeTimers(); + const input = makeInput([{ question: 'Q', options: ['A'] }]); + const { container, resolve } = renderWidget(input); + const root = findRoot(container); + + // Select option A + const items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + items[0]?.click(); + jest.advanceTimersByTime(200); + + // Now on submit tab, Enter should submit + fireKeyDown(root, 'Enter'); + + expect(resolve).toHaveBeenCalledWith({ Q: 'A' }); + jest.useRealTimers(); + }); + + it('Enter on cancel row resolves null', () => { + jest.useFakeTimers(); + const input = makeInput([{ question: 'Q', options: ['A'] }]); + const { container, resolve } = renderWidget(input); + const root = findRoot(container); + + // Select A and auto-advance to submit + const items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + items[0]?.click(); + jest.advanceTimersByTime(200); + + // Move focus to cancel row + fireKeyDown(root, 'ArrowDown'); + fireKeyDown(root, 'Enter'); + + expect(resolve).toHaveBeenCalledWith(null); + jest.useRealTimers(); + }); + + it('Enter on question option selects it', () => { + jest.useFakeTimers(); + const input = makeInput([{ question: 'Q', options: ['A', 'B'] }]); + const { container } = renderWidget(input); + const root = findRoot(container); + + // Focus is on item 0, press Enter to select + fireKeyDown(root, 'Enter'); + jest.advanceTimersByTime(200); + + // After auto-advance we should be on submit tab + const tabs = container.querySelectorAll('claudian-ask-tab'); + const submitTab = tabs[tabs.length - 1]; + expect(submitTab?.hasClass('is-active')).toBe(true); + + jest.useRealTimers(); + }); + + it('click on custom row focuses it', () => { + const input = makeInput([{ question: 'Q', options: ['A', 'B'], isOther: true }]); + const { container } = renderWidget(input); + + const items = findItems(container); + const customItem = items.find((i: any) => i.hasClass('claudian-ask-custom-item')); + customItem?.click(); + + expect(customItem?.hasClass('is-focused')).toBe(true); + }); + + it('ArrowUp from custom row blurs input focus and moves to option above', () => { + const input = makeInput([{ question: 'Q', options: ['A', 'B'], isOther: true }]); + const { container } = renderWidget(input); + const root = findRoot(container); + + const items = findItems(container); + const customItem = items.find((i: any) => i.hasClass('claudian-ask-custom-item')); + // Simulate click on custom row (focusedItemIndex = options.length, isInputFocused = true) + customItem?.click(); + + fireKeyDown(root, 'ArrowUp'); + + // Focus should move to the last regular option (index = options.length - 1) + const updatedItems = findItems(container); + const lastOption = updatedItems.filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + expect(lastOption[lastOption.length - 1]?.hasClass('is-focused')).toBe(true); + expect(customItem?.hasClass('is-focused')).toBe(false); + }); + + it('Enter on custom item activates input without advancing tab', () => { + const input = makeInput([ + { question: 'Q1', options: ['A'], isOther: true }, + { question: 'Q2', options: ['B'] }, + ]); + const { container } = renderWidget(input); + const root = findRoot(container); + + // Arrow down to custom item + fireKeyDown(root, 'ArrowDown'); // Focus on option A (index 0) + fireKeyDown(root, 'ArrowDown'); // Focus on custom item (index 1) + + // Press Enter — should activate input, NOT advance tab + fireKeyDown(root, 'Enter'); + + // Should still be on Q1 tab + const tabs = container.querySelectorAll('claudian-ask-tab'); + expect(tabs[0]?.hasClass('is-active')).toBe(true); + }); + + it('Enter on custom item then Enter again advances to next tab', () => { + const input = makeInput([ + { question: 'Q1', options: ['A'], isOther: true }, + { question: 'Q2', options: ['B'] }, + ]); + const { container } = renderWidget(input); + const root = findRoot(container); + + // Arrow down to custom item + fireKeyDown(root, 'ArrowDown'); + fireKeyDown(root, 'ArrowDown'); + + // First Enter activates input + fireKeyDown(root, 'Enter'); + // Second Enter advances + fireKeyDown(root, 'Enter'); + + // Should be on Q2 tab now + const tabs = container.querySelectorAll('claudian-ask-tab'); + expect(tabs[1]?.hasClass('is-active')).toBe(true); + }); + + it('ArrowDown from custom input blurs and clamps at max', () => { + const input = makeInput([{ question: 'Q', options: ['A', 'B'], isOther: true }]); + const { container } = renderWidget(input); + const root = findRoot(container); + + const items = findItems(container); + const customItem = items.find((i: any) => i.hasClass('claudian-ask-custom-item')); + customItem?.click(); + + fireKeyDown(root, 'ArrowDown'); + + // Custom row is last item, ArrowDown clamps — focus stays on custom row (navigation mode) + expect(customItem?.hasClass('is-focused')).toBe(true); + }); + + it('Escape from custom input returns to navigation without cancelling dialog', () => { + const input = makeInput([ + { question: 'Q1', options: ['A'], isOther: true }, + { question: 'Q2', options: ['B'] }, + ]); + const { container, resolve } = renderWidget(input); + const root = findRoot(container); + + // Navigate to custom input and activate + fireKeyDown(root, 'ArrowDown'); // focus on custom row (index 1) + fireKeyDown(root, 'Enter'); // activate input + + // Escape should exit input mode, not cancel + fireKeyDown(root, 'Escape'); + + // Dialog should NOT be resolved + expect(resolve).not.toHaveBeenCalled(); + + // Should still be on Q1 tab + const tabs = container.querySelectorAll('claudian-ask-tab'); + expect(tabs[0]?.hasClass('is-active')).toBe(true); + + // Custom row should still be focused in navigation mode + const items = findItems(container); + const customItem = items.find((i: any) => i.hasClass('claudian-ask-custom-item')); + expect(customItem?.hasClass('is-focused')).toBe(true); + }); + + it('Enter from custom input commits text and advances tab', () => { + const input = makeInput([ + { question: 'Q1', options: ['A'], isOther: true }, + { question: 'Q2', options: ['B'] }, + ]); + const { container } = renderWidget(input); + const root = findRoot(container); + + // Navigate to custom input and activate + fireKeyDown(root, 'ArrowDown'); // focus on custom row (index 1) + fireKeyDown(root, 'Enter'); // activate input + + // Simulate typing text + const customItem = findItems(container).find((i: any) => + i.hasClass('claudian-ask-custom-item'), + ); + const inputEl = customItem?.querySelector('.claudian-ask-custom-text'); + inputEl.value = 'my custom text'; + inputEl.dispatchEvent({ type: 'input' }); + + // Enter should commit and advance + fireKeyDown(root, 'Enter'); + + // Should be on Q2 tab + const tabs = container.querySelectorAll('claudian-ask-tab'); + expect(tabs[1]?.hasClass('is-active')).toBe(true); + }); + }); +}); + +function renderImmediateWidget( + input: Record, + config?: InlineAskQuestionConfig, +): { container: any; resolve: jest.Mock; widget: InlineAskUserQuestion } { + const container = createMockEl(); + const resolve = jest.fn(); + const widget = new InlineAskUserQuestion( + container, + input, + resolve, + undefined, + { immediateSelect: true, showCustomInput: false, ...config }, + ); + widget.render(); + return { container, resolve, widget }; +} + +describe('InlineAskUserQuestion - immediateSelect mode', () => { + describe('multi-question fallback', () => { + it('falls back to tab-bar rendering when questions.length !== 1', () => { + const input = makeInput([ + { question: 'Q1', options: ['A'] }, + { question: 'Q2', options: ['B'] }, + ]); + const { container, resolve } = renderImmediateWidget(input); + + // Should render tab bar (immediateSelect disabled due to multi-question) + const tabBar = container.querySelector('claudian-ask-tab-bar'); + expect(tabBar).not.toBeNull(); + const tabs = container.querySelectorAll('claudian-ask-tab'); + expect(tabs.length).toBeGreaterThan(0); + + // Should NOT resolve immediately on click (normal multi-tab flow) + const items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + items[0]?.click(); + expect(resolve).not.toHaveBeenCalled(); + }); + }); + + describe('rendering', () => { + it('does not render tab bar', () => { + const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); + const { container } = renderImmediateWidget(input); + const tabBar = container.querySelector('claudian-ask-tab-bar'); + expect(tabBar).toBeNull(); + const tabs = container.querySelectorAll('claudian-ask-tab'); + expect(tabs).toHaveLength(0); + }); + + it('does not render custom input row', () => { + const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); + const { container } = renderImmediateWidget(input); + const customItems = container.querySelectorAll('claudian-ask-custom-item'); + expect(customItems).toHaveLength(0); + }); + + it('uses custom title when provided', () => { + const input = makeInput([{ question: 'Pick', options: ['A'] }]); + const { container } = renderImmediateWidget(input, { title: 'Permission required' }); + const title = container.querySelector('claudian-ask-inline-title'); + expect(title?.textContent).toBe('Permission required'); + }); + + it('renders headerEl between title and content', () => { + const headerEl = createMockEl('div'); + headerEl.addClass('claudian-ask-approval-info'); + const input = makeInput([{ question: 'Pick', options: ['A'] }]); + const { container } = renderImmediateWidget(input, { headerEl: headerEl as any }); + const root = findRoot(container); + expect(root.children.some((c: any) => c.hasClass('claudian-ask-approval-info'))).toBe(true); + }); + }); + + describe('selection', () => { + it('resolves immediately on click', () => { + const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); + const { container, resolve } = renderImmediateWidget(input); + + const items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + items[0]?.click(); + + expect(resolve).toHaveBeenCalledWith({ Pick: 'A' }); + }); + + it('resolves with second option on click', () => { + const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); + const { container, resolve } = renderImmediateWidget(input); + + const items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + items[1]?.click(); + + expect(resolve).toHaveBeenCalledWith({ Pick: 'B' }); + }); + + it('keys immediate-select result by id when provided', () => { + const input = { + questions: [ + { id: 'approval_q', question: 'Allow execution?', options: ['Yes', 'No'], header: 'Approve' }, + ], + }; + const { container, resolve } = renderImmediateWidget(input); + + const items = findItems(container).filter( + (i: any) => !i.hasClass('claudian-ask-custom-item'), + ); + items[0]?.click(); + + expect(resolve).toHaveBeenCalledWith({ approval_q: 'Yes' }); + }); + }); + + describe('keyboard navigation', () => { + it('ArrowDown/Up navigates focus', () => { + const input = makeInput([{ question: 'Pick', options: ['A', 'B', 'C'] }]); + const { container } = renderImmediateWidget(input); + const root = findRoot(container); + + fireKeyDown(root, 'ArrowDown'); + const items = findItems(container); + expect(items[1]?.hasClass('is-focused')).toBe(true); + + fireKeyDown(root, 'ArrowUp'); + const items2 = findItems(container); + expect(items2[0]?.hasClass('is-focused')).toBe(true); + }); + + it('Enter selects and resolves immediately', () => { + const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); + const { container, resolve } = renderImmediateWidget(input); + const root = findRoot(container); + + // Move to second option and press Enter + fireKeyDown(root, 'ArrowDown'); + fireKeyDown(root, 'Enter'); + + expect(resolve).toHaveBeenCalledWith({ Pick: 'B' }); + }); + + it('Escape cancels', () => { + const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); + const { container, resolve } = renderImmediateWidget(input); + const root = findRoot(container); + + fireKeyDown(root, 'Escape'); + expect(resolve).toHaveBeenCalledWith(null); + }); + + it('Tab does not switch tabs (no-op in immediateSelect)', () => { + const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); + const { container, resolve } = renderImmediateWidget(input); + const root = findRoot(container); + + fireKeyDown(root, 'Tab'); + expect(resolve).not.toHaveBeenCalled(); + const items = findItems(container); + expect(items.length).toBeGreaterThan(0); + }); + + it('ArrowDown clamps at last option', () => { + const input = makeInput([{ question: 'Pick', options: ['A', 'B'] }]); + const { container } = renderImmediateWidget(input); + const root = findRoot(container); + + fireKeyDown(root, 'ArrowDown'); + fireKeyDown(root, 'ArrowDown'); + fireKeyDown(root, 'ArrowDown'); + + const items = findItems(container); + expect(items[1]?.hasClass('is-focused')).toBe(true); + }); + }); +}); diff --git a/tests/unit/features/chat/rendering/InlineExitPlanMode.test.ts b/tests/unit/features/chat/rendering/InlineExitPlanMode.test.ts new file mode 100644 index 0000000..4c66d42 --- /dev/null +++ b/tests/unit/features/chat/rendering/InlineExitPlanMode.test.ts @@ -0,0 +1,191 @@ +import { createMockEl } from '@test/helpers/mockElement'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { InlineExitPlanMode } from '@/features/chat/rendering/InlineExitPlanMode'; + +beforeAll(() => { + globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { + cb(0); + return 0; + }; + (globalThis as any).document = { activeElement: null }; +}); + +function fireKeyDown(root: any, key: string): void { + root.dispatchEvent({ + type: 'keydown', + key, + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }); +} + +function findRoot(container: any): any { + return container.querySelector('.claudian-plan-approval-inline'); +} + +function findItems(root: any): any[] { + return root.querySelectorAll('claudian-ask-item'); +} + +describe('InlineExitPlanMode', () => { + it('resolves with approve-new-session and includes plan content when readable', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claudian-')); + const plansDir = path.join(tmpDir, '.claude', 'plans'); + fs.mkdirSync(plansDir, { recursive: true }); + const planFilePath = path.join(plansDir, 'plan.md'); + fs.writeFileSync(planFilePath, 'Step 1\nStep 2\n', 'utf8'); + + const container = createMockEl(); + const resolve = jest.fn(); + const renderContent = jest.fn().mockResolvedValue(undefined); + + const widget = new InlineExitPlanMode( + container, + { + planFilePath, + allowedPrompts: [{ tool: 'Bash', prompt: 'Run bash commands' }], + }, + resolve, + undefined, + renderContent, + '/.claude/plans/', + ); + + widget.render(); + + const root = findRoot(container); + expect(root).toBeTruthy(); + expect(root.getEventListenerCount('keydown')).toBe(1); + expect(container.querySelector('.claudian-plan-permissions-list')).toBeTruthy(); + expect(renderContent).toHaveBeenCalled(); + + fireKeyDown(root, 'Enter'); + + expect(resolve).toHaveBeenCalledTimes(1); + expect(resolve).toHaveBeenCalledWith({ + type: 'approve-new-session', + planContent: 'Implement this plan:\n\nStep 1\nStep 2', + }); + expect(root.getEventListenerCount('keydown')).toBe(0); + }); + + it('shows a read error when plan file cannot be read', () => { + const container = createMockEl(); + const resolve = jest.fn(); + + const widget = new InlineExitPlanMode( + container, + { planFilePath: '/path/.claude/plans/does-not-exist.md' }, + resolve, + undefined, + undefined, + '/.claude/plans/', + ); + + widget.render(); + + const root = findRoot(container); + expect(root).toBeTruthy(); + expect(container.querySelector('.claudian-plan-read-error')).toBeTruthy(); + + fireKeyDown(root, 'Enter'); + expect(resolve).toHaveBeenCalledWith({ + type: 'approve-new-session', + planContent: 'Implement the approved plan.', + }); + }); + + it('rejects plan file paths outside .claude/plans/', () => { + const container = createMockEl(); + const resolve = jest.fn(); + + const widget = new InlineExitPlanMode( + container, + { planFilePath: '/etc/passwd' }, + resolve, + undefined, + undefined, + '/.claude/plans/', + ); + + widget.render(); + + const root = findRoot(container); + expect(root).toBeTruthy(); + expect(container.querySelector('.claudian-plan-read-error')).toBeTruthy(); + + fireKeyDown(root, 'Enter'); + expect(resolve).toHaveBeenCalledWith({ + type: 'approve-new-session', + planContent: 'Implement the approved plan.', + }); + }); + + it('supports keyboard navigation for approve/current-session', () => { + const container = createMockEl(); + const resolve = jest.fn(); + + const widget = new InlineExitPlanMode(container, {}, resolve); + widget.render(); + + const root = findRoot(container); + expect(root).toBeTruthy(); + + fireKeyDown(root, 'ArrowDown'); + fireKeyDown(root, 'Enter'); + + expect(resolve).toHaveBeenCalledWith({ type: 'approve' }); + }); + + it('supports feedback flow and Escape when input is focused', () => { + const container = createMockEl(); + const resolve = jest.fn(); + + const widget = new InlineExitPlanMode(container, {}, resolve); + widget.render(); + + const root = findRoot(container); + expect(root).toBeTruthy(); + + fireKeyDown(root, 'ArrowDown'); + fireKeyDown(root, 'ArrowDown'); + fireKeyDown(root, 'Enter'); + + const items = findItems(root); + const feedbackRow = items[2]; + const feedbackInput = feedbackRow.querySelector('claudian-ask-custom-text'); + + expect(resolve).not.toHaveBeenCalled(); + + feedbackInput.dispatchEvent('focus'); + + fireKeyDown(root, 'Escape'); + expect(resolve).not.toHaveBeenCalled(); + + feedbackInput.value = 'Please revise the plan'; + feedbackInput.dispatchEvent('focus'); + + fireKeyDown(root, 'Enter'); + expect(resolve).toHaveBeenCalledWith({ type: 'feedback', text: 'Please revise the plan' }); + }); + + it('resolves null on abort and does not resolve twice', () => { + const container = createMockEl(); + const resolve = jest.fn(); + const controller = new AbortController(); + + const widget = new InlineExitPlanMode(container, {}, resolve, controller.signal); + widget.render(); + + controller.abort(); + + expect(resolve).toHaveBeenCalledTimes(1); + expect(resolve).toHaveBeenCalledWith(null); + + widget.destroy(); + expect(resolve).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/features/chat/rendering/InlinePlanApproval.test.ts b/tests/unit/features/chat/rendering/InlinePlanApproval.test.ts new file mode 100644 index 0000000..31dced9 --- /dev/null +++ b/tests/unit/features/chat/rendering/InlinePlanApproval.test.ts @@ -0,0 +1,126 @@ +import { createMockEl } from '@test/helpers/mockElement'; + +import { + InlinePlanApproval, + type PlanApprovalDecision, +} from '@/features/chat/rendering/InlinePlanApproval'; + +beforeAll(() => { + globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { + cb(0); + return 0; + }; +}); + +function createApproval(): { + approval: InlinePlanApproval; + resolve: jest.Mock; + container: ReturnType; + fireKey: (key: string) => void; +} { + const container = createMockEl(); + const resolve = jest.fn(); + const approval = new InlinePlanApproval(container as any, resolve); + approval.render(); + + // The component binds keydown to rootEl via addEventListener. + // Our mock's dispatchEvent forwards {type, key} objects to listeners. + const fireKey = (key: string) => { + const rootEl = (approval as any).rootEl; + rootEl.dispatchEvent({ + type: 'keydown', + key, + preventDefault: () => {}, + stopPropagation: () => {}, + }); + }; + + return { approval, resolve, container, fireKey }; +} + +describe('InlinePlanApproval', () => { + describe('decisions', () => { + it('resolves with implement when Enter on first item (default focus)', () => { + const { resolve, fireKey } = createApproval(); + fireKey('Enter'); + expect(resolve).toHaveBeenCalledWith({ type: 'implement' }); + }); + + it('resolves with cancel when Cancel is selected', () => { + const { approval, resolve, fireKey } = createApproval(); + fireKey('ArrowDown'); // -> Revise (auto-focuses input) + // Esc out of input focus to navigate further + (approval as any).isInputFocused = false; + fireKey('ArrowDown'); // -> Cancel + fireKey('Enter'); + expect(resolve).toHaveBeenCalledWith({ type: 'cancel' }); + }); + + it('resolves with revise containing text when feedback is submitted', () => { + const { approval, resolve, fireKey } = createApproval(); + fireKey('ArrowDown'); // -> Revise + fireKey('Enter'); // focuses input + + // Simulate typing: set feedbackInput value and submit + const feedbackInput = (approval as any).feedbackInput; + feedbackInput.value = 'Add error handling'; + // The input is now "focused" — simulate Enter in input-focused mode + (approval as any).isInputFocused = true; + fireKey('Enter'); + + expect(resolve).toHaveBeenCalledWith({ type: 'revise', text: 'Add error handling' }); + }); + }); + + describe('keyboard navigation', () => { + it('moves focus down with ArrowDown', () => { + const { approval, fireKey } = createApproval(); + fireKey('ArrowDown'); + expect((approval as any).focusedIndex).toBe(1); + }); + + it('moves focus up with ArrowUp', () => { + const { approval, fireKey } = createApproval(); + fireKey('ArrowDown'); // -> Revise (auto-focuses input) + (approval as any).isInputFocused = false; + fireKey('ArrowUp'); + expect((approval as any).focusedIndex).toBe(0); + }); + + it('does not go below last item', () => { + const { approval, fireKey } = createApproval(); + fireKey('ArrowDown'); // -> Revise (auto-focuses input) + (approval as any).isInputFocused = false; + fireKey('ArrowDown'); // -> Cancel + fireKey('ArrowDown'); // clamp at Cancel + expect((approval as any).focusedIndex).toBe(2); + }); + + it('does not go above first item', () => { + const { approval, fireKey } = createApproval(); + fireKey('ArrowUp'); + expect((approval as any).focusedIndex).toBe(0); + }); + }); + + describe('Esc and destroy', () => { + it('resolves with null on Esc', () => { + const { resolve, fireKey } = createApproval(); + fireKey('Escape'); + expect(resolve).toHaveBeenCalledWith(null); + }); + + it('resolves with null on destroy()', () => { + const { approval, resolve } = createApproval(); + approval.destroy(); + expect(resolve).toHaveBeenCalledWith(null); + }); + + it('does not resolve twice on double destroy', () => { + const { approval, resolve } = createApproval(); + approval.destroy(); + approval.destroy(); + expect(resolve).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/unit/features/chat/rendering/MessageRenderer.test.ts b/tests/unit/features/chat/rendering/MessageRenderer.test.ts new file mode 100644 index 0000000..b438d3c --- /dev/null +++ b/tests/unit/features/chat/rendering/MessageRenderer.test.ts @@ -0,0 +1,2100 @@ +import '@/providers'; + +import { createMockEl } from '@test/helpers/mockElement'; +import { Menu } from 'obsidian'; + +import { + TOOL_AGENT_OUTPUT, + TOOL_APPLY_PATCH, + TOOL_SPAWN_AGENT, + TOOL_TASK, + TOOL_WAIT_AGENT, + TOOL_WRITE_STDIN, +} from '@/core/tools/toolNames'; +import type { ChatMessage, ImageAttachment } from '@/core/types'; +import { MessageRenderer } from '@/features/chat/rendering/MessageRenderer'; +import { renderStoredAsyncSubagent, renderStoredSubagent } from '@/features/chat/rendering/SubagentRenderer'; +import { renderStoredThinkingBlock } from '@/features/chat/rendering/ThinkingBlockRenderer'; +import { renderStoredToolCall } from '@/features/chat/rendering/ToolCallRenderer'; +import { renderStoredWriteEdit } from '@/features/chat/rendering/WriteEditRenderer'; + +jest.mock('@/features/chat/rendering/SubagentRenderer', () => ({ + renderStoredAsyncSubagent: jest.fn().mockReturnValue({ wrapperEl: {}, cleanup: jest.fn() }), + renderStoredSubagent: jest.fn(), +})); +jest.mock('@/features/chat/rendering/ThinkingBlockRenderer', () => ({ + renderStoredThinkingBlock: jest.fn(), +})); +jest.mock('@/features/chat/rendering/ToolCallRenderer', () => ({ + renderStoredToolCall: jest.fn(), +})); +jest.mock('@/features/chat/rendering/WriteEditRenderer', () => ({ + renderStoredWriteEdit: jest.fn(), +})); +jest.mock('@/utils/imageEmbed', () => ({ + replaceImageEmbedsWithHtml: jest.fn().mockImplementation((md: string) => md), +})); +jest.mock('@/utils/fileLink', () => ({ + processFileLinks: jest.fn(), + registerFileLinkHandler: jest.fn(), +})); + +function createMockComponent() { + return { + registerDomEvent: jest.fn(), + register: jest.fn(), + addChild: jest.fn(), + load: jest.fn(), + unload: jest.fn(), + }; +} + +function mockCapabilities(providerId: 'claude' | 'codex' = 'claude') { + return () => ({ + providerId, + supportsPersistentRuntime: true, + supportsNativeHistory: providerId === 'claude', + supportsPlanMode: true, + supportsRewind: true, + supportsFork: true, + supportsProviderCommands: true, + supportsImageAttachments: true, + supportsInstructionMode: true, + supportsMcpTools: true, + reasoningControl: 'effort' as const, + }); +} + +function createRenderer( + messagesEl?: any, + providerId: 'claude' | 'codex' = 'claude', + settings: Record = {}, +) { + const el = messagesEl ?? createMockEl(); + const comp = createMockComponent(); + const plugin = { + app: {}, + settings: { mediaFolder: '', ...settings }, + }; + return { + renderer: new MessageRenderer( + plugin as any, + comp as any, + el, + undefined, + undefined, + mockCapabilities(providerId), + ), + messagesEl: el, + }; +} + +describe('MessageRenderer', () => { + beforeEach(() => { + jest.clearAllMocks(); + (Menu as typeof Menu & { instances: unknown[] }).instances.length = 0; + }); + + // ============================================ + // renderMessages + // ============================================ + + it('renders welcome element and calls renderStoredMessage for each message', () => { + const messagesEl = createMockEl(); + const emptySpy = jest.spyOn(messagesEl, 'empty'); + const mockComponent = createMockComponent(); + const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl); + const renderStoredSpy = jest.spyOn(renderer, 'renderStoredMessage').mockImplementation(() => {}); + + const messages: ChatMessage[] = [ + { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [], contentBlocks: [] }, + ]; + + const welcomeEl = renderer.renderMessages(messages, () => 'Hello'); + + expect(emptySpy).toHaveBeenCalled(); + expect(renderStoredSpy).toHaveBeenCalledTimes(1); + expect(welcomeEl.hasClass('claudian-welcome')).toBe(true); + expect(welcomeEl.children[0].textContent).toBe('Hello'); + }); + + it('renders empty messages list with just welcome element', () => { + const { renderer } = createRenderer(); + const renderStoredSpy = jest.spyOn(renderer, 'renderStoredMessage').mockImplementation(() => {}); + + const welcomeEl = renderer.renderMessages([], () => 'Welcome!'); + + expect(renderStoredSpy).not.toHaveBeenCalled(); + expect(welcomeEl.hasClass('claudian-welcome')).toBe(true); + }); + + // ============================================ + // renderStoredMessage + // ============================================ + + it('renders interrupt messages with interrupt styling instead of user bubble', () => { + const messagesEl = createMockEl(); + const mockComponent = createMockComponent(); + const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl); + + const interruptMsg: ChatMessage = { + id: 'interrupt-1', + role: 'user', + content: '[Request interrupted by user]', + timestamp: Date.now(), + isInterrupt: true, + }; + + renderer.renderStoredMessage(interruptMsg); + + // Should create assistant-style message with interrupt content + expect(messagesEl.children.length).toBe(1); + const msgEl = messagesEl.children[0]; + expect(msgEl.hasClass('claudian-message-assistant')).toBe(true); + // Check the content contains interrupt styling + const contentEl = msgEl.children[0]; + const textEl = contentEl.children[0]; + const interruptedEl = textEl.children[0]; + expect(interruptedEl.hasClass('claudian-interrupted')).toBe(true); + expect(interruptedEl.textContent).toBe('Interrupted'); + }); + + it('renders interrupted assistant message with content + interrupt indicator', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + + const interruptMsg: ChatMessage = { + id: 'interrupt-codex-1', + role: 'assistant', + content: 'Starting to work on the feature...', + timestamp: Date.now(), + isInterrupt: true, + contentBlocks: [{ type: 'text', content: 'Starting to work on the feature...' }], + }; + + renderer.renderStoredMessage(interruptMsg); + + // Should create an assistant message (not a bare interrupt marker) + expect(messagesEl.children.length).toBe(1); + const msgEl = messagesEl.children[0]; + expect(msgEl.hasClass('claudian-message-assistant')).toBe(true); + + // The content div should have both content rendering and an interrupt indicator + const contentEl = msgEl.children[0]; + const lastChild = contentEl.children[contentEl.children.length - 1]; + const interruptedEl = lastChild.children[0]; + expect(interruptedEl.hasClass('claudian-interrupted')).toBe(true); + expect(interruptedEl.textContent).toBe('Interrupted'); + }); + + it('renders bare interrupt marker for empty interrupted assistant message', () => { + const messagesEl = createMockEl(); + const mockComponent = createMockComponent(); + const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl); + + const interruptMsg: ChatMessage = { + id: 'interrupt-codex-2', + role: 'assistant', + content: '', + timestamp: Date.now(), + isInterrupt: true, + }; + + renderer.renderStoredMessage(interruptMsg); + + // Should create a bare interrupt marker (same as Claude-style) + expect(messagesEl.children.length).toBe(1); + const msgEl = messagesEl.children[0]; + expect(msgEl.hasClass('claudian-message-assistant')).toBe(true); + const contentEl = msgEl.children[0]; + const textEl = contentEl.children[0]; + expect(textEl.children[0].hasClass('claudian-interrupted')).toBe(true); + }); + + it('skips rebuilt context messages', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + + const msg: ChatMessage = { + id: 'rebuilt-1', + role: 'user', + content: 'rebuilt context', + timestamp: Date.now(), + isRebuiltContext: true, + }; + + renderer.renderStoredMessage(msg); + + expect(messagesEl.children.length).toBe(0); + }); + + it('renders user message with text content', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const msg: ChatMessage = { + id: 'u1', + role: 'user', + content: 'Hello world', + timestamp: Date.now(), + }; + + renderer.renderStoredMessage(msg); + + expect(messagesEl.children.length).toBe(1); + const msgEl = messagesEl.children[0]; + expect(msgEl.hasClass('claudian-message-user')).toBe(true); + }); + + it('renders user message with displayContent instead of content', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const msg: ChatMessage = { + id: 'u1', + role: 'user', + content: 'full prompt with context', + displayContent: 'user input only', + timestamp: Date.now(), + }; + + renderer.renderStoredMessage(msg); + + expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'user input only'); + }); + + it('renders extracted user display content when stored message has hidden XML context', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const msg: ChatMessage = { + id: 'u1', + role: 'user', + content: 'Explain this\n\n\nnotes/test.md\n', + timestamp: Date.now(), + }; + + renderer.renderStoredMessage(msg); + + expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Explain this'); + }); + + it('skips empty user message bubble (image-only)', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {}); + + const msg: ChatMessage = { + id: 'u1', + role: 'user', + content: '', + timestamp: Date.now(), + images: [{ id: 'img-1', name: 'img.png', mediaType: 'image/png', data: 'abc', size: 100, source: 'paste' as const }], + }; + + renderer.renderStoredMessage(msg); + + // Images should still be rendered, but no message bubble + expect(renderer.renderMessageImages).toHaveBeenCalled(); + // Only the images container, no message bubble + const bubbles = messagesEl.children.filter( + (c: any) => c.hasClass('claudian-message') + ); + expect(bubbles.length).toBe(0); + }); + + it('renders user message with images above bubble', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + const renderImagesSpy = jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {}); + + const images: ImageAttachment[] = [ + { id: 'img-1', name: 'photo.png', mediaType: 'image/png', data: 'base64data', size: 200, source: 'file' }, + ]; + + const msg: ChatMessage = { + id: 'u1', + role: 'user', + content: 'Check this image', + timestamp: Date.now(), + images, + }; + + renderer.renderStoredMessage(msg); + + expect(renderImagesSpy).toHaveBeenCalledWith(messagesEl, images); + }); + + it('adds a rewind button for eligible stored user messages', () => { + const messagesEl = createMockEl(); + const rewindCallback = jest.fn().mockResolvedValue(undefined); + const renderer = new MessageRenderer({ app: {}, settings: { mediaFolder: '' } } as any, createMockComponent() as any, messagesEl, rewindCallback, undefined, mockCapabilities()); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const allMessages: ChatMessage[] = [ + { id: 'a1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'prev-a' }, + { id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' }, + { id: 'a2', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'resp-a' }, + ]; + + renderer.renderStoredMessage(allMessages[1], allMessages, 1); + + expect(messagesEl.querySelector('.claudian-message-rewind-btn')).not.toBeNull(); + }); + + it('adds rewind but not fork for a completed first user message', () => { + const messagesEl = createMockEl(); + const rewindCallback = jest.fn().mockResolvedValue(undefined); + const forkCallback = jest.fn().mockResolvedValue(undefined); + const renderer = new MessageRenderer( + { app: {}, settings: { mediaFolder: '' } } as any, + createMockComponent() as any, + messagesEl, + rewindCallback, + forkCallback, + mockCapabilities(), + ); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const allMessages: ChatMessage[] = [ + { id: 'u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'user-u' }, + { id: 'a1', role: 'assistant', content: 'response', timestamp: 2, assistantMessageId: 'resp-a' }, + ]; + + renderer.renderStoredMessage(allMessages[0], allMessages, 0); + + expect(messagesEl.querySelector('.claudian-message-rewind-btn')).not.toBeNull(); + expect(messagesEl.querySelector('.claudian-message-fork-btn')).toBeNull(); + expect((renderer as any).liveMessageEls.has('u1')).toBe(false); + }); + + it('does not add a rewind button when stored render is called without context', () => { + const messagesEl = createMockEl(); + const rewindCallback = jest.fn().mockResolvedValue(undefined); + const renderer = new MessageRenderer({ app: {}, settings: { mediaFolder: '' } } as any, createMockComponent() as any, messagesEl, rewindCallback, undefined, mockCapabilities()); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const msg: ChatMessage = { + id: 'u1', + role: 'user', + content: 'hello', + timestamp: 1, + userMessageId: 'user-u', + }; + + renderer.renderStoredMessage(msg); + + expect(messagesEl.querySelector('.claudian-message-rewind-btn')).toBeNull(); + }); + + it('shows rewind mode menu for eligible streamed user messages', async () => { + const messagesEl = createMockEl(); + const rewindCallback = jest.fn().mockResolvedValue(undefined); + const renderer = new MessageRenderer({ app: {}, settings: { mediaFolder: '' } } as any, createMockComponent() as any, messagesEl, rewindCallback, undefined, mockCapabilities()); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const userMsg: ChatMessage = { + id: 'u1', + role: 'user', + content: 'hello', + timestamp: 2, + userMessageId: 'user-u', + }; + renderer.addMessage(userMsg); + + const allMessages: ChatMessage[] = [ + { id: 'a1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'prev-a' }, + userMsg, + { id: 'a2', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'resp-a' }, + ]; + + renderer.refreshActionButtons(userMsg, allMessages, 1); + + const btn = messagesEl.querySelector('.claudian-message-rewind-btn'); + expect(btn).not.toBeNull(); + + btn!.click(); + const menu = (Menu as typeof Menu & { instances: any[] }).instances[0]; + expect(menu.items.map((item: any) => item.title)).toEqual([ + 'Rewind conversation only', + 'Rewind code + conversation', + ]); + + menu.items[0].clickHandler?.(); + await Promise.resolve(); + + expect(rewindCallback).toHaveBeenCalledWith('u1', 'conversation'); + }); + + it('refreshes rewind but not fork for a streamed first user message', () => { + const messagesEl = createMockEl(); + const rewindCallback = jest.fn().mockResolvedValue(undefined); + const forkCallback = jest.fn().mockResolvedValue(undefined); + const renderer = new MessageRenderer( + { app: {}, settings: { mediaFolder: '' } } as any, + createMockComponent() as any, + messagesEl, + rewindCallback, + forkCallback, + mockCapabilities(), + ); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const userMsg: ChatMessage = { + id: 'u1', + role: 'user', + content: 'hello', + timestamp: 1, + userMessageId: 'user-u', + }; + renderer.addMessage(userMsg); + + renderer.refreshActionButtons(userMsg, [ + userMsg, + { id: 'a1', role: 'assistant', content: 'response', timestamp: 2, assistantMessageId: 'resp-a' }, + ], 0); + + expect(messagesEl.querySelector('.claudian-message-rewind-btn')).not.toBeNull(); + expect(messagesEl.querySelector('.claudian-message-fork-btn')).toBeNull(); + }); + + // ============================================ + // renderAssistantContent + // ============================================ + + it('renders assistant content blocks using specialized renderers', () => { + const messagesEl = createMockEl(); + const mockComponent = createMockComponent(); + const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl); + const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { id: 'todo', name: 'TodoWrite', input: { items: [] } } as any, + { id: 'edit', name: 'Edit', input: { file_path: 'notes/test.md' } } as any, + { id: 'read', name: 'Read', input: { file_path: 'notes/test.md' } } as any, + { + id: 'sub-1', + name: TOOL_TASK, + input: { description: 'Async subagent' }, + status: 'running', + subagent: { id: 'sub-1', mode: 'async', status: 'running', toolCalls: [], isExpanded: false }, + } as any, + { + id: 'sub-2', + name: TOOL_TASK, + input: { description: 'Sync subagent' }, + status: 'running', + subagent: { id: 'sub-2', mode: 'sync', status: 'running', toolCalls: [], isExpanded: false }, + } as any, + ], + contentBlocks: [ + { type: 'thinking', content: 'thinking', durationSeconds: 2 } as any, + { type: 'text', content: 'Text block' } as any, + { type: 'tool_use', toolId: 'todo' } as any, + { type: 'tool_use', toolId: 'edit' } as any, + { type: 'tool_use', toolId: 'read' } as any, + { type: 'subagent', subagentId: 'sub-1', mode: 'async' } as any, + { type: 'subagent', subagentId: 'sub-2' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredThinkingBlock).toHaveBeenCalled(); + expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Text block'); + // TodoWrite is not rendered inline - only in bottom panel + expect(renderStoredWriteEdit).toHaveBeenCalled(); + expect(renderStoredToolCall).toHaveBeenCalled(); + expect(renderStoredAsyncSubagent).toHaveBeenCalled(); + expect(renderStoredSubagent).toHaveBeenCalled(); + }); + + it('passes collapsed file-edit default to stored Write/Edit renderer', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + + const msg: ChatMessage = { + id: 'm-write-default', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { id: 'edit-1', name: 'Edit', input: { file_path: 'notes/test.md' }, status: 'completed' } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'edit-1' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredWriteEdit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'edit-1', name: 'Edit' }), + { initiallyExpanded: false }, + ); + }); + + it('passes expanded file-edit default to stored Write/Edit renderer', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl, 'claude', { expandFileEditsByDefault: true }); + + const msg: ChatMessage = { + id: 'm-write-expanded', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { id: 'write-1', name: 'Write', input: { file_path: 'notes/test.md' }, status: 'completed' } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'write-1' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredWriteEdit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'write-1', name: 'Write' }), + { initiallyExpanded: true }, + ); + }); + + it('skips empty or whitespace-only text blocks', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + contentBlocks: [ + { type: 'text', content: '' } as any, + { type: 'text', content: ' ' } as any, + { type: 'text', content: 'Real content' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + // Only the non-empty text block should trigger renderContent + expect(renderContentSpy).toHaveBeenCalledTimes(1); + expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Real content'); + }); + + it('does not render stored Codex write_stdin transport tools', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl, 'codex'); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { + id: 'stdin-1', + name: TOOL_WRITE_STDIN, + input: { session_id: '2404', chars: '' }, + status: 'completed', + result: 'poll output', + } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'stdin-1' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredToolCall).not.toHaveBeenCalled(); + expect(messagesEl.children).toHaveLength(0); + }); + + it('renders stored Codex write_stdin tools when they send real input', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl, 'codex'); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { + id: 'stdin-1', + name: TOOL_WRITE_STDIN, + input: { session_id: '2404', chars: 'y\n' }, + status: 'completed', + result: 'Input sent.', + } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'stdin-1' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredToolCall).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'stdin-1', + name: TOOL_WRITE_STDIN, + input: { session_id: '2404', chars: 'y\n' }, + }), + { initiallyExpanded: false }, + ); + expect(messagesEl.children).toHaveLength(1); + }); + + it('passes expanded file-edit default to stored apply_patch renderer', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl, 'codex', { expandFileEditsByDefault: true }); + + const msg: ChatMessage = { + id: 'm-apply-patch-expanded', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { + id: 'patch-1', + name: TOOL_APPLY_PATCH, + input: { changes: [{ path: 'src/main.ts', kind: 'update' }] }, + status: 'completed', + result: 'Applied patch', + } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'patch-1' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredToolCall).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'patch-1', name: TOOL_APPLY_PATCH }), + { initiallyExpanded: true }, + ); + }); + + it('renders response duration footer when durationSeconds is present', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + contentBlocks: [ + { type: 'text', content: 'Response text' } as any, + ], + durationSeconds: 65, + durationFlavorWord: 'Baked', + }; + + renderer.renderStoredMessage(msg); + + // Find the footer element + const msgEl = messagesEl.children[0]; + const contentEl = msgEl.children[0]; // claudian-message-content + const footerEl = contentEl.children.find((c: any) => c.hasClass('claudian-response-footer')); + expect(footerEl).toBeDefined(); + const durationSpan = footerEl!.children[0]; + expect(durationSpan.textContent).toContain('Baked'); + expect(durationSpan.textContent).toContain('1m 5s'); + }); + + it('does not render footer when durationSeconds is 0', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + contentBlocks: [ + { type: 'text', content: 'Response' } as any, + ], + durationSeconds: 0, + }; + + renderer.renderStoredMessage(msg); + + const msgEl = messagesEl.children[0]; + const contentEl = msgEl.children[0]; + const footerEl = contentEl.children.find((c: any) => c.hasClass('claudian-response-footer')); + expect(footerEl).toBeUndefined(); + }); + + it('uses default flavor word "Baked" when durationFlavorWord is not set', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + contentBlocks: [ + { type: 'text', content: 'Response' } as any, + ], + durationSeconds: 30, + }; + + renderer.renderStoredMessage(msg); + + const msgEl = messagesEl.children[0]; + const contentEl = msgEl.children[0]; + const footerEl = contentEl.children.find((c: any) => c.hasClass('claudian-response-footer')); + expect(footerEl).toBeDefined(); + expect(footerEl!.children[0].textContent).toContain('Baked'); + }); + + it('renders fallback content for old conversations without contentBlocks', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + const addCopySpy = jest.spyOn(renderer, 'addTextCopyButton').mockImplementation(() => {}); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: 'Legacy response text', + timestamp: Date.now(), + toolCalls: [ + { id: 'read-1', name: 'Read', input: { file_path: 'test.md' }, status: 'completed' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + // Should render content text + expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Legacy response text'); + // Should add copy button for fallback text + expect(addCopySpy).toHaveBeenCalledWith(expect.anything(), 'Legacy response text'); + // Should render tool call + expect(renderStoredToolCall).toHaveBeenCalled(); + }); + + it('renders unreferenced tool calls when contentBlocks miss tool_use blocks', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + (renderStoredToolCall as jest.Mock).mockClear(); + + const msg: ChatMessage = { + id: 'm-unreferenced-tool', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { id: 'read-1', name: 'Read', input: { file_path: 'a.md' }, status: 'completed' } as any, + ], + contentBlocks: [ + { type: 'text', content: 'Only text block persisted' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Only text block persisted'); + expect(renderStoredToolCall).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'read-1', name: 'Read' }), + { initiallyExpanded: false }, + ); + }); + + it('renders Task tool calls as subagents for backward compatibility', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + + (renderStoredSubagent as jest.Mock).mockClear(); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { + id: 'task-1', + name: TOOL_TASK, + input: { description: 'Run tests' }, + status: 'completed', + result: 'All passed', + } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'task-1' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredSubagent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'task-1', + description: 'Run tests', + status: 'completed', + result: 'All passed', + }) + ); + }); + + it('renders Task tool as async subagent when linked subagent mode is async', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + + (renderStoredAsyncSubagent as jest.Mock).mockClear(); + (renderStoredSubagent as jest.Mock).mockClear(); + + const msg: ChatMessage = { + id: 'm-task-async', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { + id: 'task-async-1', + name: TOOL_TASK, + input: { description: 'Background task', run_in_background: true }, + status: 'completed', + result: 'Task running', + subagent: { + id: 'task-async-1', + description: 'Background task', + mode: 'async', + asyncStatus: 'running', + status: 'running', + toolCalls: [], + isExpanded: false, + }, + } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'task-async-1' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredAsyncSubagent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'task-async-1', + mode: 'async', + asyncStatus: 'running', + }) + ); + expect(renderStoredSubagent).not.toHaveBeenCalled(); + }); + + it('infers async running state from structured Task result content', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + + (renderStoredAsyncSubagent as jest.Mock).mockClear(); + + const msg: ChatMessage = { + id: 'm-task-async-structured', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { + id: 'task-async-structured-1', + name: TOOL_TASK, + input: { description: 'Background task', run_in_background: true }, + status: 'completed', + result: [{ type: 'text', text: '{"status":"running"}' }] as any, + } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'task-async-structured-1' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredAsyncSubagent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'task-async-structured-1', + asyncStatus: 'running', + }) + ); + }); + + it('uses subagent block mode hint when linked subagent mode is missing', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + + (renderStoredAsyncSubagent as jest.Mock).mockClear(); + (renderStoredSubagent as jest.Mock).mockClear(); + + const msg: ChatMessage = { + id: 'm-task-mode-hint', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { + id: 'task-hint-1', + name: TOOL_TASK, + input: { description: 'Background task from block hint' }, + status: 'running', + subagent: { + id: 'task-hint-1', + description: 'Background task from block hint', + status: 'running', + toolCalls: [], + isExpanded: false, + }, + } as any, + ], + contentBlocks: [ + { type: 'subagent', subagentId: 'task-hint-1', mode: 'async' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredAsyncSubagent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'task-hint-1', + mode: 'async', + }) + ); + expect(renderStoredSubagent).not.toHaveBeenCalled(); + }); + + // ============================================ + // TaskOutput skipping + // ============================================ + + it('should skip TaskOutput tool calls (internal async subagent communication)', () => { + const messagesEl = createMockEl(); + const mockComponent = createMockComponent(); + const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl); + + (renderStoredToolCall as jest.Mock).mockClear(); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { id: 'agent-output-1', name: TOOL_AGENT_OUTPUT, input: { task_id: 'abc', block: true } } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'agent-output-1' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredToolCall).not.toHaveBeenCalled(); + }); + + it('should render other tool calls but skip TaskOutput when mixed', () => { + const messagesEl = createMockEl(); + const mockComponent = createMockComponent(); + const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl); + + (renderStoredToolCall as jest.Mock).mockClear(); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { id: 'read-1', name: 'Read', input: { file_path: 'test.md' }, status: 'completed' } as any, + { id: 'agent-output-1', name: TOOL_AGENT_OUTPUT, input: { task_id: 'abc' } } as any, + { id: 'grep-1', name: 'Grep', input: { pattern: 'test' }, status: 'completed' } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'read-1' } as any, + { type: 'tool_use', toolId: 'agent-output-1' } as any, + { type: 'tool_use', toolId: 'grep-1' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredToolCall).toHaveBeenCalledTimes(2); + expect(renderStoredToolCall).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'read-1', name: 'Read' }), + { initiallyExpanded: false }, + ); + expect(renderStoredToolCall).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ id: 'grep-1', name: 'Grep' }), + { initiallyExpanded: false }, + ); + }); + + // ============================================ + // addMessage (streaming) + // ============================================ + + it('addMessage creates user message bubble with text', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const msg: ChatMessage = { + id: 'u1', + role: 'user', + content: 'Hello', + timestamp: Date.now(), + }; + + const msgEl = renderer.addMessage(msg); + + expect(msgEl.hasClass('claudian-message-user')).toBe(true); + }); + + it('addMessage stores a truncated first-line table-of-contents title for user messages', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const msg: ChatMessage = { + id: 'u1', + role: 'user', + content: `${'x'.repeat(90)}\nsecond line`, + timestamp: Date.now(), + }; + + const msgEl = renderer.addMessage(msg); + + expect(msgEl.getAttribute('data-toc-title')).toBe(`${'x'.repeat(77)}...`); + }); + + it('addMessage renders images for user messages', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + const renderImagesSpy = jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {}); + + const images: ImageAttachment[] = [ + { id: 'img-1', name: 'photo.png', mediaType: 'image/png', data: 'base64data', size: 200, source: 'file' }, + ]; + + const msg: ChatMessage = { + id: 'u1', + role: 'user', + content: 'Look at this', + timestamp: Date.now(), + images, + }; + + renderer.addMessage(msg); + + expect(renderImagesSpy).toHaveBeenCalledWith(messagesEl, images); + }); + + it('addMessage skips empty bubble for image-only user messages', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {}); + const scrollSpy = jest.spyOn(renderer, 'scrollToBottom').mockImplementation(() => {}); + + const msg: ChatMessage = { + id: 'u1', + role: 'user', + content: '', + timestamp: Date.now(), + images: [{ id: 'img-1', name: 'img.png', mediaType: 'image/png', data: 'abc', size: 100, source: 'paste' as const }], + }; + + const result = renderer.addMessage(msg); + + // Should still return an element (last child or messagesEl) + expect(result).toBeDefined(); + expect(scrollSpy).toHaveBeenCalled(); + }); + + it('addMessage creates assistant message element without user-specific rendering', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + + const msg: ChatMessage = { + id: 'a1', + role: 'assistant', + content: '', + timestamp: Date.now(), + }; + + const msgEl = renderer.addMessage(msg); + + expect(msgEl.hasClass('claudian-message-assistant')).toBe(true); + }); + + // ============================================ + // setMessagesEl + // ============================================ + + it('setMessagesEl updates the container element', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + const newEl = createMockEl(); + + renderer.setMessagesEl(newEl); + + // Verify by using scrollToBottom which references messagesEl + renderer.scrollToBottom(); + // The new element should have been used (scrollTop set) + expect(newEl.scrollTop).toBe(newEl.scrollHeight); + }); + + // ============================================ + // Image rendering + // ============================================ + + it('renderMessageImages creates image elements', () => { + const containerEl = createMockEl(); + const { renderer } = createRenderer(); + jest.spyOn(renderer, 'setImageSrc').mockImplementation(() => {}); + + const images: ImageAttachment[] = [ + { id: 'img-1', name: 'photo.png', mediaType: 'image/png', data: 'base64data1', size: 200, source: 'file' }, + { id: 'img-2', name: 'avatar.jpg', mediaType: 'image/jpeg', data: 'base64data2', size: 300, source: 'file' }, + ]; + + renderer.renderMessageImages(containerEl, images); + + // Should create images container with 2 image wrappers + expect(containerEl.children.length).toBe(1); + const imagesContainer = containerEl.children[0]; + expect(imagesContainer.hasClass('claudian-message-images')).toBe(true); + expect(imagesContainer.children.length).toBe(2); + }); + + it('setImageSrc sets data URI on image element', () => { + const { renderer } = createRenderer(); + const imgEl = createMockEl('img'); + + const image: ImageAttachment = { + id: 'img-1', + name: 'test.png', + mediaType: 'image/png', + data: 'abc123', + size: 100, + source: 'file', + }; + + renderer.setImageSrc(imgEl as any, image); + + expect(imgEl.getAttribute('src')).toBe('data:image/png;base64,abc123'); + }); + + it('showFullImage creates overlay with image', () => { + const { renderer } = createRenderer(); + const image: ImageAttachment = { + id: 'img-1', + name: 'test.png', + mediaType: 'image/png', + data: 'abc123', + size: 100, + source: 'file', + }; + + // Mock document.body.createDiv (document may not exist in node env) + const overlayEl = createMockEl(); + const mockBody = { createDiv: jest.fn().mockReturnValue(overlayEl) }; + const origDocument = globalThis.document; + (globalThis as any).document = { body: mockBody, addEventListener: jest.fn(), removeEventListener: jest.fn() }; + + try { + renderer.showFullImage(image); + expect(mockBody.createDiv).toHaveBeenCalledWith({ cls: 'claudian-image-modal-overlay' }); + } finally { + (globalThis as any).document = origDocument; + } + }); + + // ============================================ + // Copy button + // ============================================ + + it('addTextCopyButton adds a copy button element', () => { + const textEl = createMockEl(); + const { renderer } = createRenderer(); + + renderer.addTextCopyButton(textEl, 'some markdown'); + + expect(textEl.children.length).toBe(1); + const copyBtn = textEl.children[0]; + expect(copyBtn.hasClass('claudian-text-copy-btn')).toBe(true); + }); + + // ============================================ + // Scroll utilities + // ============================================ + + it('scrollToBottom sets scrollTop to scrollHeight', () => { + const messagesEl = createMockEl(); + messagesEl.scrollHeight = 1000; + const { renderer } = createRenderer(messagesEl); + + renderer.scrollToBottom(); + + expect(messagesEl.scrollTop).toBe(1000); + }); + + it('scrollToBottomIfNeeded scrolls when near bottom', () => { + const messagesEl = createMockEl(); + messagesEl.scrollHeight = 1000; + messagesEl.scrollTop = 950; + Object.defineProperty(messagesEl, 'clientHeight', { value: 0, configurable: true }); + const { renderer } = createRenderer(messagesEl); + + // Mock requestAnimationFrame + const origRAF = globalThis.requestAnimationFrame; + (globalThis as any).requestAnimationFrame = (cb: () => void) => { cb(); return 0; }; + + try { + renderer.scrollToBottomIfNeeded(); + // Near bottom (1000 - 950 - 0 = 50, < 100 threshold) → scrolls + expect(messagesEl.scrollTop).toBe(1000); + } finally { + (globalThis as any).requestAnimationFrame = origRAF; + } + }); + + it('scrollToBottomIfNeeded does not scroll when far from bottom', () => { + const messagesEl = createMockEl(); + messagesEl.scrollHeight = 1000; + messagesEl.scrollTop = 100; + Object.defineProperty(messagesEl, 'clientHeight', { value: 0, configurable: true }); + const { renderer } = createRenderer(messagesEl); + + const originalScrollTop = messagesEl.scrollTop; + renderer.scrollToBottomIfNeeded(); + + // scrollTop should not change (900 > 100 threshold) + expect(messagesEl.scrollTop).toBe(originalScrollTop); + }); + + // ============================================ + // renderContent + // ============================================ + + it('renderContent should not throw on valid markdown', async () => { + const { renderer } = createRenderer(); + const el = createMockEl(); + + // Should not throw even if internal rendering fails (graceful error handling) + await expect(renderer.renderContent(el, '**Hello** world')).resolves.not.toThrow(); + }); + + it('renderContent should empty the element before rendering', async () => { + const { renderer } = createRenderer(); + const el = createMockEl(); + el.createDiv({ text: 'old content' }); + expect(el.children.length).toBe(1); + + await renderer.renderContent(el, 'new content'); + + // After render, old content should be gone (empty() was called before rendering) + expect(el.children.length).toBe(0); + }); + + it('renderContent should skip file-link post-processing when markdown has no wikilinks', async () => { + const { processFileLinks } = await import('@/utils/fileLink'); + const { renderer } = createRenderer(); + const el = createMockEl(); + + await renderer.renderContent(el, 'plain markdown without links'); + + expect(processFileLinks).not.toHaveBeenCalled(); + }); + + it('renderContent escapes math delimiters only when requested for streaming', async () => { + const { MarkdownRenderer } = await import('obsidian'); + const { renderer } = createRenderer(); + const el = createMockEl(); + + await renderer.renderContent( + el, + 'Live $x + y$ and `echo $PATH`', + { deferMath: true } + ); + + expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalledWith( + 'Live \\$x + y\\$ and `echo $PATH`', + el, + '', + expect.anything() + ); + }); + + // ============================================ + // addTextCopyButton - click behavior + // ============================================ + + describe('addTextCopyButton - click behavior', () => { + let originalNavigator: Navigator; + + beforeEach(() => { + originalNavigator = globalThis.navigator; + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + writable: true, + configurable: true, + }); + }); + + it('click should copy and show feedback', async () => { + const { renderer } = createRenderer(); + const textEl = createMockEl(); + + const writeTextMock = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(globalThis, 'navigator', { + value: { clipboard: { writeText: writeTextMock } }, + writable: true, + configurable: true, + }); + + renderer.addTextCopyButton(textEl, 'markdown content'); + + const copyBtn = textEl.children[0]; + expect(copyBtn.hasClass('claudian-text-copy-btn')).toBe(true); + + // Simulate click + const clickHandlers = copyBtn._eventListeners.get('click'); + expect(clickHandlers).toBeDefined(); + + await clickHandlers![0]({ stopPropagation: jest.fn() }); + + expect(writeTextMock).toHaveBeenCalledWith('markdown content'); + expect(copyBtn.textContent).toBe('Copied!'); + expect(copyBtn.classList.contains('copied')).toBe(true); + }); + + it('should handle clipboard API failure gracefully', async () => { + const { renderer } = createRenderer(); + const textEl = createMockEl(); + + const writeTextMock = jest.fn().mockRejectedValue(new Error('not allowed')); + Object.defineProperty(globalThis, 'navigator', { + value: { clipboard: { writeText: writeTextMock } }, + writable: true, + configurable: true, + }); + + renderer.addTextCopyButton(textEl, 'content'); + + const copyBtn = textEl.children[0]; + const clickHandlers = copyBtn._eventListeners.get('click'); + + // Should not throw + await clickHandlers![0]({ stopPropagation: jest.fn() }); + + // Should not show feedback on error + expect(copyBtn.textContent).not.toBe('copied!'); + }); + }); + + // ============================================ + // renderMessages (entry point) + // ============================================ + + it('renderMessages should render stored messages and return welcome element', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {}); + + const messages: ChatMessage[] = [ + { id: 'u1', role: 'user', content: 'Hello', timestamp: Date.now() }, + { id: 'a1', role: 'assistant', content: 'Hi there', timestamp: Date.now(), contentBlocks: [{ type: 'text', content: 'Hi there' }] as any }, + ]; + + const welcomeEl = renderer.renderMessages(messages, () => 'Good morning!'); + + expect(welcomeEl).toBeDefined(); + expect(welcomeEl!.hasClass('claudian-welcome')).toBe(true); + }); + + it('renderMessages should store table-of-contents title from displayContent before content', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const messages: ChatMessage[] = [ + { + id: 'u1', + role: 'user', + content: 'Expanded prompt that should not appear', + displayContent: 'Visible slash command\nwith details', + timestamp: Date.now(), + }, + ]; + + renderer.renderMessages(messages, () => 'Hello'); + + const msgEl = messagesEl.querySelector('.claudian-message-user'); + expect(msgEl?.getAttribute('data-toc-title')).toBe('Visible slash command'); + }); + + it('renderMessages should hide welcome when messages exist', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {}); + + const messages: ChatMessage[] = [ + { id: 'u1', role: 'user', content: 'Hello', timestamp: Date.now() }, + ]; + + const welcomeEl = renderer.renderMessages(messages, () => 'Hello'); + + // When messages exist, welcome should be hidden + expect(welcomeEl).toBeDefined(); + }); + + it('renderMessages should return welcome element when no messages', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + + const welcomeEl = renderer.renderMessages([], () => 'Welcome'); + + expect(welcomeEl).toBeDefined(); + expect(welcomeEl!.hasClass('claudian-welcome')).toBe(true); + }); + + // ============================================ + // Task tool rendering - error and running status + // ============================================ + + describe('Task tool rendering - error and running status', () => { + it('renders Task tool with error status as subagent with status error', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl, 'codex'); + + (renderStoredSubagent as jest.Mock).mockClear(); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { + id: 'task-err', + name: TOOL_TASK, + input: { description: 'Failing task' }, + status: 'error', + result: 'Something went wrong', + } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'task-err' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredSubagent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'task-err', + description: 'Failing task', + status: 'error', + result: 'Something went wrong', + }) + ); + }); + + it('renders Task tool with running status (default case in switch)', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl, 'codex'); + + (renderStoredSubagent as jest.Mock).mockClear(); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { + id: 'task-run', + name: TOOL_TASK, + input: { description: 'Running task' }, + status: 'pending', + } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'task-run' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredSubagent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'task-run', + description: 'Running task', + status: 'running', + }) + ); + }); + + it('renders Task tool with no description uses fallback Subagent task', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + + (renderStoredSubagent as jest.Mock).mockClear(); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { + id: 'task-no-desc', + name: TOOL_TASK, + input: {}, + status: 'completed', + result: 'Done', + } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'task-no-desc' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredSubagent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'task-no-desc', + description: 'Subagent task', + status: 'completed', + }) + ); + }); + + it('renders Codex spawn_agent with the same prompt and result recovered on reload', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl, 'codex'); + + (renderStoredSubagent as jest.Mock).mockClear(); + + const msg: ChatMessage = { + id: 'm-codex-subagent', + role: 'assistant', + content: '', + timestamp: Date.now(), + toolCalls: [ + { + id: 'spawn-1', + name: TOOL_SPAWN_AGENT, + input: { + message: 'Inspect utils.ts and return the final patch summary.', + model: 'gpt-5.4-mini', + }, + status: 'completed', + result: '{"agent_id":"agent-1","nickname":"Zeno"}', + } as any, + { + id: 'wait-1', + name: TOOL_WAIT_AGENT, + input: { targets: ['agent-1'], timeout_ms: 30000 }, + status: 'completed', + result: '{"status":{"agent-1":{"completed":"Patched utils.ts and verified imports."}},"timed_out":false}', + } as any, + ], + contentBlocks: [ + { type: 'tool_use', toolId: 'spawn-1' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredSubagent).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + id: 'spawn-1', + description: 'Zeno (gpt-5.4-mini)', + prompt: 'Inspect utils.ts and return the final patch summary.', + status: 'completed', + result: 'Patched utils.ts and verified imports.', + }) + ); + }); + }); + + // ============================================ + // showFullImage - close behaviors + // ============================================ + + describe('showFullImage - close behaviors', () => { + const image: ImageAttachment = { + id: 'img-1', + name: 'test.png', + mediaType: 'image/png', + data: 'abc123', + size: 100, + source: 'file', + }; + + function setupDocumentMock() { + const overlayEl = createMockEl(); + const mockBody = { createDiv: jest.fn().mockReturnValue(overlayEl) }; + const docListeners = new Map void)[]>(); + const origDocument = globalThis.document; + + (globalThis as any).document = { + body: mockBody, + addEventListener: jest.fn((event: string, handler: (...args: any[]) => void) => { + if (!docListeners.has(event)) docListeners.set(event, []); + docListeners.get(event)!.push(handler); + }), + removeEventListener: jest.fn((event: string, handler: (...args: any[]) => void) => { + const handlers = docListeners.get(event); + if (handlers) { + const idx = handlers.indexOf(handler); + if (idx !== -1) handlers.splice(idx, 1); + } + }), + }; + + return { overlayEl, docListeners, origDocument }; + } + + it('closeBtn click removes overlay', () => { + const { renderer } = createRenderer(); + const { overlayEl, origDocument } = setupDocumentMock(); + + try { + renderer.showFullImage(image); + + // The overlay has a modal child, which has a close button child + const modalEl = overlayEl.children[0]; // claudian-image-modal + // Children: img (index 0), closeBtn (index 1) + const closeBtn = modalEl.children[1]; + expect(closeBtn.hasClass('claudian-image-modal-close')).toBe(true); + + const removeSpy = jest.spyOn(overlayEl, 'remove'); + closeBtn.click(); + + expect(removeSpy).toHaveBeenCalled(); + } finally { + (globalThis as any).document = origDocument; + } + }); + + it('clicking overlay background removes overlay', () => { + const { renderer } = createRenderer(); + const { overlayEl, origDocument } = setupDocumentMock(); + + try { + renderer.showFullImage(image); + + const removeSpy = jest.spyOn(overlayEl, 'remove'); + + // Simulate click on the overlay itself (e.target === overlay) + const clickHandlers = overlayEl._eventListeners.get('click'); + expect(clickHandlers).toBeDefined(); + clickHandlers![0]({ target: overlayEl }); + + expect(removeSpy).toHaveBeenCalled(); + } finally { + (globalThis as any).document = origDocument; + } + }); + + it('ESC key removes overlay', () => { + const { renderer } = createRenderer(); + const { overlayEl, docListeners, origDocument } = setupDocumentMock(); + + try { + renderer.showFullImage(image); + + const removeSpy = jest.spyOn(overlayEl, 'remove'); + + // Simulate ESC key press via the document keydown listener + const keydownHandlers = docListeners.get('keydown'); + expect(keydownHandlers).toBeDefined(); + expect(keydownHandlers!.length).toBeGreaterThan(0); + keydownHandlers![0]({ key: 'Escape' }); + + expect(removeSpy).toHaveBeenCalled(); + // After close, the keydown handler should be removed + expect(document.removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function)); + } finally { + (globalThis as any).document = origDocument; + } + }); + }); + + // ============================================ + // renderContent - code block wrapping (error path) + // ============================================ + + describe('renderContent - error handling', () => { + it('renderContent shows error div when MarkdownRenderer throws', async () => { + const { MarkdownRenderer } = await import('obsidian'); + (MarkdownRenderer.renderMarkdown as jest.Mock).mockRejectedValueOnce( + new Error('Render failed') + ); + + const { renderer } = createRenderer(); + const el = createMockEl(); + + await renderer.renderContent(el, '**broken markdown**'); + + const errorDiv = el.children.find( + (c: any) => c.hasClass('claudian-render-error') + ); + expect(errorDiv).toBeDefined(); + expect(errorDiv!.textContent).toBe('Failed to render message content.'); + }); + }); + + // ============================================ + // addTextCopyButton - rapid click handling + // ============================================ + + describe('addTextCopyButton - rapid click handling', () => { + let originalNavigator: Navigator; + + beforeEach(() => { + originalNavigator = globalThis.navigator; + jest.useFakeTimers(); + Object.defineProperty(globalThis, 'navigator', { + value: { clipboard: { writeText: jest.fn().mockResolvedValue(undefined) } }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + jest.useRealTimers(); + Object.defineProperty(globalThis, 'navigator', { + value: originalNavigator, + writable: true, + configurable: true, + }); + }); + + it('rapid clicks clear previous timeout', async () => { + const { renderer } = createRenderer(); + const textEl = createMockEl(); + const clearTimeoutSpy = jest.spyOn(globalThis, 'clearTimeout'); + + renderer.addTextCopyButton(textEl, 'content to copy'); + + const copyBtn = textEl.children[0]; + const clickHandlers = copyBtn._eventListeners.get('click'); + expect(clickHandlers).toBeDefined(); + + // First click + await clickHandlers![0]({ stopPropagation: jest.fn() }); + expect(copyBtn.textContent).toBe('Copied!'); + + // Second rapid click before timeout expires + await clickHandlers![0]({ stopPropagation: jest.fn() }); + + // clearTimeout should have been called for the first pending timeout + expect(clearTimeoutSpy).toHaveBeenCalled(); + expect(copyBtn.textContent).toBe('Copied!'); + + clearTimeoutSpy.mockRestore(); + }); + + it('feedback timeout restores icon after delay', async () => { + const { renderer } = createRenderer(); + const textEl = createMockEl(); + + renderer.addTextCopyButton(textEl, 'content to copy'); + + const copyBtn = textEl.children[0]; + const originalInnerHTML = copyBtn.innerHTML; + const clickHandlers = copyBtn._eventListeners.get('click'); + + // Click to copy + await clickHandlers![0]({ stopPropagation: jest.fn() }); + expect(copyBtn.textContent).toBe('Copied!'); + expect(copyBtn.classList.contains('copied')).toBe(true); + + // Advance timers by 1500ms (the feedback duration) + jest.advanceTimersByTime(1500); + + // Icon should be restored and copied class removed + expect(copyBtn.innerHTML).toBe(originalInnerHTML); + expect(copyBtn.classList.contains('copied')).toBe(false); + }); + }); + + // ============================================ + // renderContent - code block wrapping + // ============================================ + + describe('renderContent - code block wrapping', () => { + it('passes image-processed markdown directly to MarkdownRenderer', async () => { + const { MarkdownRenderer } = await import('obsidian'); + const { replaceImageEmbedsWithHtml } = await import('@/utils/imageEmbed'); + const { processFileLinks } = await import('@/utils/fileLink'); + const { renderer } = createRenderer(); + const el = createMockEl(); + + (replaceImageEmbedsWithHtml as jest.Mock).mockReturnValueOnce( + 'raw html\n [[note.md]]' + ); + + await renderer.renderContent(el, 'before-images ![[image.png]] [[note.md]]'); + + expect(replaceImageEmbedsWithHtml).toHaveBeenCalledWith( + 'before-images ![[image.png]] [[note.md]]', + expect.anything(), + { mediaFolder: '' } + ); + expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalledWith( + 'raw html\n [[note.md]]', + el, + '', + expect.anything() + ); + expect(processFileLinks).toHaveBeenCalledWith(expect.anything(), el); + }); + + it('should wrap pre elements in code wrapper divs', async () => { + const { MarkdownRenderer } = await import('obsidian'); + const { renderer } = createRenderer(); + const el = createMockEl(); + + // Mock renderMarkdown to create a pre element in the container + (MarkdownRenderer.renderMarkdown as jest.Mock).mockImplementationOnce( + async (_md: string, container: any) => { + const pre = container.createEl('pre'); + pre.createEl('code', { text: 'console.log("hello")' }); + } + ); + + await renderer.renderContent(el, '```js\nconsole.log("hello")\n```'); + + // The pre should be wrapped in a claudian-code-wrapper + // Due to mock limitations, check that querySelectorAll was called on el + // The actual wrapping logic runs on real DOM, but the mock captures calls + expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalled(); + }); + + it('should skip wrapping already-wrapped pre elements', async () => { + const { MarkdownRenderer } = await import('obsidian'); + const { renderer } = createRenderer(); + const el = createMockEl(); + + // Mock renderMarkdown to create an already-wrapped pre element + (MarkdownRenderer.renderMarkdown as jest.Mock).mockImplementationOnce( + async (_md: string, container: any) => { + const wrapper = container.createDiv({ cls: 'claudian-code-wrapper' }); + wrapper.createEl('pre'); + } + ); + + await renderer.renderContent(el, '```\nalready wrapped\n```'); + + // Should not throw and should complete normally + expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalled(); + }); + }); + + // ============================================ + // renderMessageImages - click handler + // ============================================ + + describe('renderMessageImages - click handler', () => { + it('should add click handler on image elements', () => { + const containerEl = createMockEl(); + const { renderer } = createRenderer(); + const showFullImageSpy = jest.spyOn(renderer, 'showFullImage').mockImplementation(() => {}); + jest.spyOn(renderer, 'setImageSrc').mockImplementation(() => {}); + + const images: ImageAttachment[] = [ + { id: 'img-1', name: 'photo.png', mediaType: 'image/png', data: 'base64data', size: 200, source: 'file' }, + ]; + + renderer.renderMessageImages(containerEl, images); + + // Find the img element and check for click handler + const imagesContainer = containerEl.children[0]; + const wrapper = imagesContainer.children[0]; + const imgEl = wrapper.children[0]; // The img element + + // Check click handler is registered + const clickHandlers = imgEl._eventListeners?.get('click'); + expect(clickHandlers).toBeDefined(); + expect(clickHandlers!.length).toBe(1); + + // Trigger click and verify showFullImage is called + clickHandlers![0](); + expect(showFullImageSpy).toHaveBeenCalledWith(images[0]); + }); + }); + + // ============================================ + // renderContent - code block wrapping with language labels + // ============================================ + + describe('renderContent - language label and copy', () => { + it('should add language label when code block has language class', async () => { + const { MarkdownRenderer } = await import('obsidian'); + const { renderer } = createRenderer(); + const el = createMockEl(); + + (MarkdownRenderer.renderMarkdown as jest.Mock).mockImplementationOnce( + async (_md: string, container: any) => { + const pre = container.createEl('pre'); + const code = pre.createEl('code'); + code.className = 'language-typescript'; + code.textContent = 'const x = 1;'; + } + ); + + await renderer.renderContent(el, '```typescript\nconst x = 1;\n```'); + + expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalled(); + }); + + it('should move copy-code-button outside pre into wrapper', async () => { + const { MarkdownRenderer } = await import('obsidian'); + const { renderer } = createRenderer(); + const el = createMockEl(); + + (MarkdownRenderer.renderMarkdown as jest.Mock).mockImplementationOnce( + async (_md: string, container: any) => { + const pre = container.createEl('pre'); + pre.createEl('code', { text: 'some code' }); + const copyBtn = pre.createEl('button'); + copyBtn.className = 'copy-code-button'; + } + ); + + await renderer.renderContent(el, '```\nsome code\n```'); + + expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalled(); + }); + }); + + // ============================================ + // addMessage - displayContent for user messages + // ============================================ + + it('addMessage renders displayContent instead of content when available', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + const msg: ChatMessage = { + id: 'u1', + role: 'user', + content: 'full prompt with context', + displayContent: 'user input only', + timestamp: Date.now(), + }; + + renderer.addMessage(msg); + + expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'user input only'); + }); + + // ============================================ + // renderStoredThinkingBlock - durationSeconds parameter + // ============================================ + + describe('renderStoredThinkingBlock - durationSeconds parameter', () => { + it('should pass durationSeconds to renderStoredThinkingBlock', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + (renderStoredThinkingBlock as jest.Mock).mockClear(); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + contentBlocks: [ + { type: 'thinking', content: 'deep thought', durationSeconds: 42 } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredThinkingBlock).toHaveBeenCalledWith( + expect.anything(), + 'deep thought', + 42, + expect.any(Function) + ); + }); + + it('should pass undefined durationSeconds when not set', () => { + const messagesEl = createMockEl(); + const { renderer } = createRenderer(messagesEl); + jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined); + + (renderStoredThinkingBlock as jest.Mock).mockClear(); + + const msg: ChatMessage = { + id: 'm1', + role: 'assistant', + content: '', + timestamp: Date.now(), + contentBlocks: [ + { type: 'thinking', content: 'thought without duration' } as any, + ], + }; + + renderer.renderStoredMessage(msg); + + expect(renderStoredThinkingBlock).toHaveBeenCalledWith( + expect.anything(), + 'thought without duration', + undefined, + expect.any(Function) + ); + }); + }); +}); diff --git a/tests/unit/features/chat/rendering/SubagentRenderer.test.ts b/tests/unit/features/chat/rendering/SubagentRenderer.test.ts new file mode 100644 index 0000000..c4f2393 --- /dev/null +++ b/tests/unit/features/chat/rendering/SubagentRenderer.test.ts @@ -0,0 +1,917 @@ +import { createMockEl, type MockElement } from '@test/helpers/mockElement'; +import { setIcon } from 'obsidian'; + +import type { SubagentInfo, ToolCallInfo } from '@/core/types'; +import { + addSubagentToolCall, + createAsyncSubagentBlock, + createSubagentBlock, + finalizeAsyncSubagent, + finalizeSubagentBlock, + markAsyncSubagentOrphaned, + renderStoredAsyncSubagent, + renderStoredSubagent, + updateAsyncSubagentRunning, + updateSubagentToolResult, +} from '@/features/chat/rendering/SubagentRenderer'; + +const getTextByClass = (el: MockElement, cls: string): string[] => { + const results: string[] = []; + const visit = (node: MockElement) => { + if (node.hasClass(cls)) { + results.push(node.textContent); + } + node.children.forEach(visit); + }; + visit(el); + return results; +}; + +describe('Sync Subagent Renderer', () => { + let parentEl: MockElement; + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl('div'); + }); + + describe('createSubagentBlock', () => { + it('should start collapsed by default', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + expect(state.info.isExpanded).toBe(false); + expect((state.wrapperEl as any).hasClass('expanded')).toBe(false); + }); + + it('should set aria-expanded to false by default', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + expect(state.headerEl.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should hide content by default', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + expect((state.contentEl as any).style.display).toBe('none'); + }); + + it('should set correct ARIA attributes for accessibility', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + expect(state.headerEl.getAttribute('role')).toBe('button'); + expect(state.headerEl.getAttribute('tabindex')).toBe('0'); + expect(state.headerEl.getAttribute('aria-expanded')).toBe('false'); + expect(state.headerEl.getAttribute('aria-label')).toContain('click to expand'); + }); + + it('should toggle expand/collapse on header click', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + // Initially collapsed + expect(state.info.isExpanded).toBe(false); + expect((state.wrapperEl as any).hasClass('expanded')).toBe(false); + expect((state.contentEl as any).style.display).toBe('none'); + + // Trigger click + (state.headerEl as any).click(); + + // Should be expanded + expect(state.info.isExpanded).toBe(true); + expect((state.wrapperEl as any).hasClass('expanded')).toBe(true); + expect((state.contentEl as any).hasClass('claudian-hidden')).toBe(false); + + // Click again to collapse + (state.headerEl as any).click(); + expect(state.info.isExpanded).toBe(false); + expect((state.wrapperEl as any).hasClass('expanded')).toBe(false); + expect((state.contentEl as any).style.display).toBe('none'); + }); + + it('should update aria-expanded on toggle', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + // Initially collapsed + expect(state.headerEl.getAttribute('aria-expanded')).toBe('false'); + + // Expand + (state.headerEl as any).click(); + expect(state.headerEl.getAttribute('aria-expanded')).toBe('true'); + + // Collapse + (state.headerEl as any).click(); + expect(state.headerEl.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should show description in label', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'My task description' }); + + expect(state.labelEl.textContent).toBe('My task description'); + }); + + it('should not show a tool count badge in the header', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + expect(getTextByClass(state.wrapperEl as any, 'claudian-subagent-count')).toEqual([]); + }); + }); + + describe('renderStoredSubagent', () => { + it('should start collapsed by default', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Test task', + status: 'completed', + toolCalls: [], + isExpanded: false, + }; + + const wrapperEl = renderStoredSubagent(parentEl as any, subagent); + + expect((wrapperEl as any).hasClass('expanded')).toBe(false); + }); + + it('should set aria-expanded to false by default', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Test task', + status: 'completed', + toolCalls: [], + isExpanded: false, + }; + + const wrapperEl = renderStoredSubagent(parentEl as any, subagent); + + const headerEl = (wrapperEl as any).children[0]; + expect(headerEl.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should hide content by default', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Test task', + status: 'completed', + toolCalls: [], + isExpanded: false, + }; + + const wrapperEl = renderStoredSubagent(parentEl as any, subagent); + + const contentEl = (wrapperEl as any).children[1]; + expect(contentEl.style.display).toBe('none'); + }); + + it('should toggle expand/collapse on click', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Test task', + status: 'completed', + toolCalls: [], + isExpanded: false, + }; + + const wrapperEl = renderStoredSubagent(parentEl as any, subagent); + const headerEl = (wrapperEl as any).children[0]; + const contentEl = (wrapperEl as any).children[1]; + + // Initially collapsed + expect((wrapperEl as any).hasClass('expanded')).toBe(false); + expect(contentEl.style.display).toBe('none'); + + // Click to expand + headerEl.click(); + expect((wrapperEl as any).hasClass('expanded')).toBe(true); + expect(contentEl.hasClass('claudian-hidden')).toBe(false); + expect(headerEl.getAttribute('aria-expanded')).toBe('true'); + + // Click to collapse + headerEl.click(); + expect((wrapperEl as any).hasClass('expanded')).toBe(false); + expect(contentEl.style.display).toBe('none'); + expect(headerEl.getAttribute('aria-expanded')).toBe('false'); + }); + }); +}); + +describe('keyboard navigation', () => { + let parentEl: MockElement; + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl('div'); + }); + + it('should support keyboard navigation (Enter/Space) on createSubagentBlock', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + // Simulate keydown event + const keydownHandlers: Array<(e: any) => void> = []; + const originalAddEventListener = state.headerEl.addEventListener; + state.headerEl.addEventListener = (event: string, handler: (e: any) => void) => { + if (event === 'keydown') { + keydownHandlers.push(handler); + } + originalAddEventListener.call(state.headerEl, event, handler); + }; + + // Re-check - the handler should already be registered + // We need to dispatch a keydown event + const enterEvent = { key: 'Enter', preventDefault: jest.fn() }; + (state.headerEl as any).dispatchEvent({ type: 'keydown', ...enterEvent }); + + // The handler should have been called and expanded + expect(state.info.isExpanded).toBe(true); + expect((state.wrapperEl as any).hasClass('expanded')).toBe(true); + + // Space to collapse + const spaceEvent = { key: ' ', preventDefault: jest.fn() }; + (state.headerEl as any).dispatchEvent({ type: 'keydown', ...spaceEvent }); + + expect(state.info.isExpanded).toBe(false); + expect((state.wrapperEl as any).hasClass('expanded')).toBe(false); + }); + + it('should support keyboard navigation (Enter/Space) on renderStoredSubagent', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Test task', + status: 'completed', + toolCalls: [], + isExpanded: false, + }; + + const wrapperEl = renderStoredSubagent(parentEl as any, subagent); + const headerEl = (wrapperEl as any).children[0]; + + // Simulate Enter key + const enterEvent = { key: 'Enter', preventDefault: jest.fn() }; + headerEl.dispatchEvent({ type: 'keydown', ...enterEvent }); + + expect((wrapperEl as any).hasClass('expanded')).toBe(true); + + // Simulate Space key to collapse + const spaceEvent = { key: ' ', preventDefault: jest.fn() }; + headerEl.dispatchEvent({ type: 'keydown', ...spaceEvent }); + + expect((wrapperEl as any).hasClass('expanded')).toBe(false); + }); +}); + +describe('Async Subagent Renderer', () => { + let parentEl: MockElement; + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl('div'); + }); + + describe('inline display behavior', () => { + it('should start collapsed', () => { + const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + expect(state.info.isExpanded).toBe(false); + expect((state.wrapperEl as any).hasClass('expanded')).toBe(false); + }); + + it('should have aria-label indicating expand action', () => { + const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + expect(state.headerEl.getAttribute('aria-label')).toContain('click to expand'); + }); + + it('should expand content when header is clicked', () => { + const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + // Initially collapsed + expect(state.info.isExpanded).toBe(false); + + // Trigger click to expand + (state.headerEl as any).click(); + + expect(state.info.isExpanded).toBe(true); + expect((state.wrapperEl as any).hasClass('expanded')).toBe(true); + }); + + it('should toggle expansion on repeated clicks', () => { + const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + // Click to expand + (state.headerEl as any).click(); + expect(state.info.isExpanded).toBe(true); + + // Click to collapse + (state.headerEl as any).click(); + expect(state.info.isExpanded).toBe(false); + }); + + it('should expand when Enter key is pressed', () => { + const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Test' }); + + const enterEvent = { key: 'Enter', preventDefault: jest.fn() }; + (state.headerEl as any).dispatchEvent({ type: 'keydown', ...enterEvent }); + + expect(state.info.isExpanded).toBe(true); + }); + + it('should expand when Space key is pressed', () => { + const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Test' }); + + const spaceEvent = { key: ' ', preventDefault: jest.fn() }; + (state.headerEl as any).dispatchEvent({ type: 'keydown', ...spaceEvent }); + + expect(state.info.isExpanded).toBe(true); + }); + }); + + it('shows label immediately and initializing status text', () => { + const state = createAsyncSubagentBlock(parentEl as any, 'task-1', { description: 'Background job' }); + + expect(state.labelEl.textContent).toBe('Background job'); + expect(state.statusTextEl.textContent).toBe('Initializing'); + expect((state.wrapperEl as any).getClasses()).toEqual(expect.arrayContaining(['async', 'pending'])); + }); + + it('shows prompt in content and keeps label visible while running', () => { + const state = createAsyncSubagentBlock(parentEl as any, 'task-2', { description: 'Background job', prompt: 'Do the work' }); + + updateAsyncSubagentRunning(state, 'agent-xyz'); + + expect(state.labelEl.textContent).toBe('Background job'); + expect(state.statusTextEl.textContent).toBe('Running in background'); + const contentText = getTextByClass(state.contentEl as any, 'claudian-subagent-prompt-text')[0]; + expect(contentText).toContain('Do the work'); + expect((state.wrapperEl as any).getClasses()).toEqual(expect.arrayContaining(['running', 'async'])); + }); + + it('finalizes to completed and reveals description', () => { + const state = createAsyncSubagentBlock(parentEl as any, 'task-3', { description: 'Background job' }); + state.info.toolCalls.push( + { + id: 'tool-1', + name: 'Read', + input: { file_path: 'a.md' }, + status: 'completed', + result: 'A', + isExpanded: false, + }, + { + id: 'tool-2', + name: 'Grep', + input: { pattern: 'x' }, + status: 'completed', + result: 'B', + isExpanded: false, + } + ); + updateAsyncSubagentRunning(state, 'agent-complete'); + + (setIcon as jest.Mock).mockClear(); + finalizeAsyncSubagent(state, 'all done', false); + + expect(state.labelEl.textContent).toBe('Background job'); + expect(state.statusTextEl.textContent).toBe(''); + expect((state.wrapperEl as any).hasClass('done')).toBe(true); + const contentText = getTextByClass(state.contentEl as any, 'claudian-subagent-result-output')[0]; + expect(contentText).toBe('all done'); + const lastIcon = (setIcon as jest.Mock).mock.calls.pop(); + expect(lastIcon?.[1]).toBe('check'); + }); + + it('finalizes to error and truncates error message', () => { + const state = createAsyncSubagentBlock(parentEl as any, 'task-4', { description: 'Background job' }); + updateAsyncSubagentRunning(state, 'agent-error'); + + (setIcon as jest.Mock).mockClear(); + finalizeAsyncSubagent(state, 'failure happened', true); + + expect(state.statusTextEl.textContent).toBe('Error'); + expect((state.wrapperEl as any).hasClass('error')).toBe(true); + const contentText = getTextByClass(state.contentEl as any, 'claudian-subagent-result-output')[0]; + expect(contentText).toBe('failure happened'); + const lastIcon = (setIcon as jest.Mock).mock.calls.pop(); + expect(lastIcon?.[1]).toBe('x'); + }); + + it('marks async subagent as orphaned', () => { + const state = createAsyncSubagentBlock(parentEl as any, 'task-5', { description: 'Background job' }); + + markAsyncSubagentOrphaned(state); + + expect(state.statusTextEl.textContent).toBe('Orphaned'); + expect((state.wrapperEl as any).hasClass('orphaned')).toBe(true); + const contentText = getTextByClass(state.contentEl as any, 'claudian-subagent-result-output')[0]; + expect(contentText).toContain('Conversation ended before task completed'); + }); + + describe('renderStoredAsyncSubagent', () => { + it('should return wrapper element', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Test task', + status: 'completed', + toolCalls: [], + isExpanded: false, + mode: 'async', + asyncStatus: 'completed', + }; + + const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); + + expect(wrapperEl).toBeDefined(); + expect((wrapperEl as any).hasClass('claudian-subagent-list')).toBe(true); + }); + + it('should expand content when header is clicked', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Test task', + status: 'completed', + toolCalls: [], + isExpanded: false, + mode: 'async', + asyncStatus: 'completed', + }; + + const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); + const headerEl = (wrapperEl as any).children[0]; + + // Click to expand + headerEl.click(); + + expect((wrapperEl as any).hasClass('expanded')).toBe(true); + }); + + it('should expand on Enter key', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Test task', + status: 'completed', + toolCalls: [], + isExpanded: false, + mode: 'async', + asyncStatus: 'completed', + }; + + const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); + const headerEl = (wrapperEl as any).children[0]; + + const enterEvent = { key: 'Enter', preventDefault: jest.fn() }; + headerEl.dispatchEvent({ type: 'keydown', ...enterEvent }); + + expect((wrapperEl as any).hasClass('expanded')).toBe(true); + }); + + it('should have aria-label indicating expand action', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Test task', + status: 'completed', + toolCalls: [], + isExpanded: false, + mode: 'async', + asyncStatus: 'completed', + }; + + const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); + const headerEl = (wrapperEl as any).children[0]; + + expect(headerEl.getAttribute('aria-label')).toContain('click to expand'); + }); + + it('should toggle expansion on repeated clicks', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Test task', + status: 'completed', + toolCalls: [], + isExpanded: false, + mode: 'async', + asyncStatus: 'completed', + }; + + const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); + const headerEl = (wrapperEl as any).children[0]; + + // Click to expand + headerEl.click(); + expect((wrapperEl as any).hasClass('expanded')).toBe(true); + + // Click to collapse + headerEl.click(); + expect((wrapperEl as any).hasClass('expanded')).toBe(false); + }); + + it('renders error status correctly', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Failed task', + status: 'error', + toolCalls: [], + isExpanded: false, + mode: 'async', + asyncStatus: 'error', + }; + + const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); + + expect((wrapperEl as any).hasClass('error')).toBe(true); + const contentText = getTextByClass(wrapperEl as any, 'claudian-subagent-result-output')[0]; + expect(contentText).toBe('ERROR'); + }); + + it('renders orphaned status correctly', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Lost task', + status: 'error', + toolCalls: [], + isExpanded: false, + mode: 'async', + asyncStatus: 'orphaned', + }; + + (setIcon as jest.Mock).mockClear(); + const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); + + expect((wrapperEl as any).hasClass('error')).toBe(true); + expect((wrapperEl as any).hasClass('orphaned')).toBe(true); + const contentText = getTextByClass(wrapperEl as any, 'claudian-subagent-result-output')[0]; + expect(contentText).toContain('Conversation ended before task completed'); + // Should use alert-circle icon + expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'alert-circle'); + }); + + it('renders running status with prompt', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Running task', + status: 'running', + toolCalls: [], + isExpanded: false, + mode: 'async', + asyncStatus: 'running', + prompt: 'Do some work', + }; + + const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); + + expect((wrapperEl as any).hasClass('running')).toBe(true); + const contentText = getTextByClass(wrapperEl as any, 'claudian-subagent-prompt-text')[0]; + expect(contentText).toContain('Do some work'); + }); + + it('renders pending status as running', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Pending task', + status: 'running', + toolCalls: [], + isExpanded: false, + mode: 'async', + asyncStatus: 'pending', + }; + + const wrapperEl = renderStoredAsyncSubagent(parentEl as any, subagent); + + // pending maps to running display status + expect((wrapperEl as any).hasClass('running')).toBe(true); + }); + }); +}); + +describe('addSubagentToolCall', () => { + let parentEl: MockElement; + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl('div'); + }); + + it('adds tool call to state without rendering a header count', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Read', + input: { file_path: 'test.md' }, + status: 'running', + isExpanded: false, + }; + + addSubagentToolCall(state, toolCall); + + expect(state.info.toolCalls).toHaveLength(1); + expect(getTextByClass(state.wrapperEl as any, 'claudian-subagent-count')).toEqual([]); + }); + + it('clears previous content and renders new tool item', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + const toolCall1: ToolCallInfo = { + id: 'tool-1', + name: 'Read', + input: { file_path: 'test.md' }, + status: 'running', + isExpanded: false, + }; + addSubagentToolCall(state, toolCall1); + + const toolCall2: ToolCallInfo = { + id: 'tool-2', + name: 'Grep', + input: { pattern: 'test' }, + status: 'running', + isExpanded: false, + }; + addSubagentToolCall(state, toolCall2); + + expect(state.info.toolCalls).toHaveLength(2); + expect(getTextByClass(state.wrapperEl as any, 'claudian-subagent-count')).toEqual([]); + }); + + it('merges repeated tool IDs instead of duplicating tool rows', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + addSubagentToolCall(state, { + id: 'tool-1', + name: 'Write', + input: {}, + status: 'running', + isExpanded: false, + }); + + addSubagentToolCall(state, { + id: 'tool-1', + name: 'Write', + input: { file_path: 'notes.md' }, + status: 'running', + isExpanded: false, + }); + + expect(state.info.toolCalls).toHaveLength(1); + expect(state.info.toolCalls[0]).toEqual( + expect.objectContaining({ + id: 'tool-1', + input: { file_path: 'notes.md' }, + }) + ); + expect(getTextByClass(state.wrapperEl as any, 'claudian-subagent-count')).toEqual([]); + expect(getTextByClass(state.toolsContainerEl as any, 'claudian-subagent-tool-name')).toEqual(['Write']); + expect(getTextByClass(state.toolsContainerEl as any, 'claudian-subagent-tool-summary')).toEqual(['notes.md']); + }); +}); + +describe('updateSubagentToolResult', () => { + let parentEl: MockElement; + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl('div'); + }); + + it('updates tool call status in state', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Read', + input: { file_path: 'test.md' }, + status: 'running', + isExpanded: false, + }; + addSubagentToolCall(state, toolCall); + + const updatedToolCall: ToolCallInfo = { + ...toolCall, + status: 'completed', + result: 'File contents here', + }; + updateSubagentToolResult(state, 'tool-1', updatedToolCall); + + expect(state.info.toolCalls[0].status).toBe('completed'); + }); + + it('does not update tool call for non-matching tool ID', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Read', + input: { file_path: 'test.md' }, + status: 'running', + isExpanded: false, + }; + addSubagentToolCall(state, toolCall); + + updateSubagentToolResult(state, 'tool-999', { ...toolCall, id: 'tool-999', status: 'completed' }); + + expect(state.info.toolCalls[0].status).toBe('running'); + }); +}); + +describe('finalizeSubagentBlock', () => { + let parentEl: MockElement; + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl('div'); + }); + + it('sets status to completed and adds done class', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + (setIcon as jest.Mock).mockClear(); + finalizeSubagentBlock(state, 'All done', false); + + expect(state.info.status).toBe('completed'); + expect(state.info.result).toBe('All done'); + expect((state.wrapperEl as any).hasClass('done')).toBe(true); + expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'check'); + }); + + it('sets status to error and adds error class', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + (setIcon as jest.Mock).mockClear(); + finalizeSubagentBlock(state, 'Something failed', true); + + expect(state.info.status).toBe('error'); + expect(state.info.result).toBe('Something failed'); + expect((state.wrapperEl as any).hasClass('error')).toBe(true); + expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'x'); + }); + + it('keeps tool history and shows result section text', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + // Add a tool call first to populate content + addSubagentToolCall(state, { + id: 'tool-1', + name: 'Read', + input: { file_path: 'test.md' }, + status: 'running', + isExpanded: false, + }); + + finalizeSubagentBlock(state, 'Done', false); + + const doneText = getTextByClass(state.contentEl as any, 'claudian-subagent-result-output')[0]; + expect(doneText).toBe('Done'); + }); + + it('shows ERROR text when isError is true', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + finalizeSubagentBlock(state, 'Error occurred', true); + + const errorText = getTextByClass(state.contentEl as any, 'claudian-subagent-result-output')[0]; + expect(errorText).toBe('Error occurred'); + }); + + it('does not restore a tool count badge after finalization', () => { + const state = createSubagentBlock(parentEl as any, 'task-1', { description: 'Test task' }); + + addSubagentToolCall(state, { + id: 'tool-1', + name: 'Read', + input: {}, + status: 'running', + isExpanded: false, + }); + addSubagentToolCall(state, { + id: 'tool-2', + name: 'Grep', + input: {}, + status: 'running', + isExpanded: false, + }); + + finalizeSubagentBlock(state, 'Done', false); + + expect(getTextByClass(state.wrapperEl as any, 'claudian-subagent-count')).toEqual([]); + }); +}); + +describe('renderStoredSubagent status variants', () => { + let parentEl: MockElement; + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl('div'); + }); + + it('renders completed subagent with done class and check icon', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Completed task', + status: 'completed', + toolCalls: [], + isExpanded: false, + }; + + (setIcon as jest.Mock).mockClear(); + const wrapperEl = renderStoredSubagent(parentEl as any, subagent); + + expect((wrapperEl as any).hasClass('done')).toBe(true); + expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'check'); + const doneText = getTextByClass(wrapperEl as any, 'claudian-subagent-result-output')[0]; + expect(doneText).toBe('DONE'); + }); + + it('renders error subagent with error class and x icon', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Failed task', + status: 'error', + toolCalls: [], + isExpanded: false, + }; + + (setIcon as jest.Mock).mockClear(); + const wrapperEl = renderStoredSubagent(parentEl as any, subagent); + + expect((wrapperEl as any).hasClass('error')).toBe(true); + expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'x'); + const errorText = getTextByClass(wrapperEl as any, 'claudian-subagent-result-output')[0]; + expect(errorText).toBe('ERROR'); + }); + + it('renders running subagent with tool list', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Running task', + status: 'running', + toolCalls: [ + { id: 'tool-1', name: 'Read', input: { file_path: 'test.md' }, status: 'completed', isExpanded: false }, + { id: 'tool-2', name: 'Grep', input: { pattern: 'test' }, status: 'running', isExpanded: false }, + ], + isExpanded: false, + }; + + const wrapperEl = renderStoredSubagent(parentEl as any, subagent); + + // Should not have done or error class + expect((wrapperEl as any).hasClass('done')).toBe(false); + expect((wrapperEl as any).hasClass('error')).toBe(false); + }); + + it('renders running subagent tool call with expanded-style result', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Running task', + status: 'running', + toolCalls: [ + { + id: 'tool-1', + name: 'Read', + input: { file_path: 'test.md' }, + status: 'completed', + result: 'File contents here', + isExpanded: false, + }, + ], + isExpanded: false, + }; + + const wrapperEl = renderStoredSubagent(parentEl as any, subagent); + const contentEl = (wrapperEl as any).children[1]; // content area + + // Should show result text + const resultTexts = getTextByClass(contentEl, 'claudian-tool-line'); + expect(resultTexts.length).toBe(1); + expect(resultTexts[0]).toContain('File contents here'); + }); + + it('does not render a tool count badge for stored subagents', () => { + const subagent: SubagentInfo = { + id: 'task-1', + description: 'Task with tools', + status: 'completed', + toolCalls: [ + { id: 'tool-1', name: 'Read', input: {}, status: 'completed', isExpanded: false }, + { id: 'tool-2', name: 'Grep', input: {}, status: 'completed', isExpanded: false }, + { id: 'tool-3', name: 'Edit', input: {}, status: 'completed', isExpanded: false }, + ], + isExpanded: false, + }; + + const wrapperEl = renderStoredSubagent(parentEl as any, subagent); + + expect(getTextByClass(wrapperEl as any, 'claudian-subagent-count')).toEqual([]); + }); + + it('truncates long descriptions', () => { + const longDesc = 'A'.repeat(50); + const subagent: SubagentInfo = { + id: 'task-1', + description: longDesc, + status: 'completed', + toolCalls: [], + isExpanded: false, + }; + + const wrapperEl = renderStoredSubagent(parentEl as any, subagent); + + const labelTexts = getTextByClass(wrapperEl as any, 'claudian-subagent-label'); + expect(labelTexts[0]).toBe('A'.repeat(40) + '...'); + }); +}); diff --git a/tests/unit/features/chat/rendering/ThinkingBlockRenderer.test.ts b/tests/unit/features/chat/rendering/ThinkingBlockRenderer.test.ts new file mode 100644 index 0000000..1dd696d --- /dev/null +++ b/tests/unit/features/chat/rendering/ThinkingBlockRenderer.test.ts @@ -0,0 +1,124 @@ +import { createMockEl } from '@test/helpers/mockElement'; + +import { + createThinkingBlock, + finalizeThinkingBlock, + renderStoredThinkingBlock, +} from '@/features/chat/rendering/ThinkingBlockRenderer'; + +// Mock renderContent function +const mockRenderContent = jest.fn().mockResolvedValue(undefined); + +describe('ThinkingBlockRenderer', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('createThinkingBlock', () => { + it('should show timer label', () => { + const parentEl = createMockEl(); + + const state = createThinkingBlock(parentEl, mockRenderContent); + + expect(state.labelEl.textContent).toContain('Thinking'); + }); + + it('should clean up timer on finalize', () => { + const parentEl = createMockEl(); + + const state = createThinkingBlock(parentEl, mockRenderContent); + + expect(state.timerInterval).not.toBeNull(); + + finalizeThinkingBlock(state); + + expect(state.timerInterval).toBeNull(); + }); + }); + + describe('finalizeThinkingBlock', () => { + it('should collapse the block when finalized', () => { + const parentEl = createMockEl(); + + const state = createThinkingBlock(parentEl, mockRenderContent); + + // Manually expand first + state.wrapperEl.addClass('expanded'); + state.contentEl.style.display = 'block'; + + finalizeThinkingBlock(state); + + expect(state.wrapperEl.hasClass('expanded')).toBe(false); + expect(state.contentEl.style.display).toBe('none'); + }); + + it('should update label with final duration', () => { + const parentEl = createMockEl(); + + const state = createThinkingBlock(parentEl, mockRenderContent); + + // Advance time by 5 seconds + jest.advanceTimersByTime(5000); + + const duration = finalizeThinkingBlock(state); + + expect(duration).toBeGreaterThanOrEqual(5); + expect(state.labelEl.textContent).toContain('Thought for'); + }); + + it('should sync isExpanded state so toggle works correctly after finalize', () => { + const parentEl = createMockEl(); + + const state = createThinkingBlock(parentEl, mockRenderContent); + const header = (state.wrapperEl as any)._children[0]; + + // Expand the block + const clickHandlers = header._eventListeners.get('click') || []; + clickHandlers[0](); + expect(state.isExpanded).toBe(true); + expect((state.wrapperEl as any).hasClass('expanded')).toBe(true); + + // Finalize (which collapses) + finalizeThinkingBlock(state); + expect(state.isExpanded).toBe(false); + expect((state.wrapperEl as any).hasClass('expanded')).toBe(false); + + // Now click once - should expand (not require two clicks) + clickHandlers[0](); + expect(state.isExpanded).toBe(true); + expect((state.wrapperEl as any).hasClass('expanded')).toBe(true); + expect((state.contentEl as any).hasClass('claudian-hidden')).toBe(false); + }); + + it('should update aria-expanded on finalize', () => { + const parentEl = createMockEl(); + + const state = createThinkingBlock(parentEl, mockRenderContent); + const header = (state.wrapperEl as any)._children[0]; + + // Expand first + const clickHandlers = header._eventListeners.get('click') || []; + clickHandlers[0](); + expect(header.getAttribute('aria-expanded')).toBe('true'); + + // Finalize + finalizeThinkingBlock(state); + expect(header.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + describe('renderStoredThinkingBlock', () => { + it('should render stored block with duration label', () => { + const parentEl = createMockEl(); + + const wrapperEl = renderStoredThinkingBlock(parentEl, 'thinking content', 10, mockRenderContent); + + expect(wrapperEl).toBeDefined(); + }); + }); +}); diff --git a/tests/unit/features/chat/rendering/TodoListRenderer.test.ts b/tests/unit/features/chat/rendering/TodoListRenderer.test.ts new file mode 100644 index 0000000..b90da0d --- /dev/null +++ b/tests/unit/features/chat/rendering/TodoListRenderer.test.ts @@ -0,0 +1,173 @@ +import { extractLastTodosFromMessages, parseTodoInput } from '@/features/chat/rendering/TodoListRenderer'; + +describe('TodoListRenderer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('parseTodoInput', () => { + it('should parse valid todo input', () => { + const input = { + todos: [ + { content: 'Task 1', status: 'pending', activeForm: 'Doing Task 1' }, + { content: 'Task 2', status: 'completed', activeForm: 'Doing Task 2' }, + ], + }; + + const result = parseTodoInput(input); + + expect(result).toHaveLength(2); + expect(result![0].content).toBe('Task 1'); + expect(result![1].status).toBe('completed'); + }); + + it('should return null for invalid input', () => { + expect(parseTodoInput({})).toBeNull(); + expect(parseTodoInput({ todos: 'not an array' })).toBeNull(); + }); + + it('should filter out invalid todo items', () => { + const input = { + todos: [ + { content: 'Valid', status: 'pending', activeForm: 'Doing' }, + { content: 'Invalid status', status: 'unknown' }, + { status: 'pending' }, + ], + }; + + const result = parseTodoInput(input); + + expect(result).toHaveLength(1); + expect(result![0].content).toBe('Valid'); + }); + + it('should filter out items with empty strings', () => { + const input = { + todos: [ + { content: '', status: 'pending', activeForm: 'Doing' }, + { content: 'Valid', status: 'pending', activeForm: '' }, + { content: 'Also valid', status: 'completed', activeForm: 'Done' }, + ], + }; + + const result = parseTodoInput(input); + + expect(result).toHaveLength(1); + expect(result![0].content).toBe('Also valid'); + }); + }); + + describe('extractLastTodosFromMessages', () => { + it('should return the most recent TodoWrite from conversation', () => { + const messages = [ + { + role: 'assistant', + toolCalls: [{ + name: 'TodoWrite', + input: { todos: [{ content: 'Old task', status: 'completed', activeForm: 'Old' }] }, + }], + }, + { role: 'user' }, + { + role: 'assistant', + toolCalls: [{ + name: 'TodoWrite', + input: { todos: [{ content: 'New task', status: 'pending', activeForm: 'New' }] }, + }], + }, + ]; + + const result = extractLastTodosFromMessages(messages); + + expect(result).not.toBeNull(); + expect(result![0].content).toBe('New task'); + expect(result![0].status).toBe('pending'); + }); + + it('should return null when no TodoWrite exists', () => { + const messages = [ + { role: 'assistant', toolCalls: [{ name: 'Read', input: {} }] }, + { role: 'user' }, + ]; + + expect(extractLastTodosFromMessages(messages)).toBeNull(); + }); + + it('should return null for empty messages array', () => { + expect(extractLastTodosFromMessages([])).toBeNull(); + }); + + it('should handle messages without toolCalls', () => { + const messages = [ + { role: 'assistant' }, + { role: 'user' }, + ]; + + expect(extractLastTodosFromMessages(messages)).toBeNull(); + }); + + it('should ignore user messages with toolCalls', () => { + const messages = [ + { + role: 'user', + toolCalls: [{ + name: 'TodoWrite', + input: { todos: [{ content: 'User task', status: 'pending', activeForm: 'Task' }] }, + }], + }, + ]; + + expect(extractLastTodosFromMessages(messages)).toBeNull(); + }); + + it('should find TodoWrite among other tool calls', () => { + const messages = [ + { + role: 'assistant', + toolCalls: [ + { name: 'Read', input: {} }, + { name: 'TodoWrite', input: { todos: [{ content: 'Task', status: 'in_progress', activeForm: 'Doing' }] } }, + { name: 'Write', input: {} }, + ], + }, + ]; + + const result = extractLastTodosFromMessages(messages); + + expect(result).not.toBeNull(); + expect(result![0].content).toBe('Task'); + }); + + it('should return the last TodoWrite in a message with multiple TodoWrites', () => { + const messages = [ + { + role: 'assistant', + toolCalls: [ + { name: 'TodoWrite', input: { todos: [{ content: 'First', status: 'pending', activeForm: 'First' }] } }, + { name: 'TodoWrite', input: { todos: [{ content: 'Last', status: 'pending', activeForm: 'Last' }] } }, + ], + }, + ]; + + const result = extractLastTodosFromMessages(messages); + + expect(result).not.toBeNull(); + expect(result![0].content).toBe('Last'); + }); + + it('should return null when TodoWrite parsing fails', () => { + const messages = [ + { + role: 'assistant', + toolCalls: [ + { name: 'TodoWrite', input: { todos: 'invalid' } }, // Invalid: todos should be array + ], + }, + ]; + + const result = extractLastTodosFromMessages(messages); + + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/unit/features/chat/rendering/ToolCallRenderer.test.ts b/tests/unit/features/chat/rendering/ToolCallRenderer.test.ts new file mode 100644 index 0000000..6c8a410 --- /dev/null +++ b/tests/unit/features/chat/rendering/ToolCallRenderer.test.ts @@ -0,0 +1,909 @@ +import { createMockEl } from '@test/helpers/mockElement'; +import { setIcon } from 'obsidian'; + +import type { ToolCallInfo } from '@/core/types'; +import { + getToolLabel, + getToolName, + getToolSummary, + isBlockedToolResult, + renderStoredToolCall, + renderTodoWriteResult, + renderToolCall, + setToolIcon, + updateToolCallResult, +} from '@/features/chat/rendering/ToolCallRenderer'; + +// Mock obsidian +jest.mock('obsidian', () => ({ + setIcon: jest.fn(), +})); + +// Helper to create a basic tool call +function createToolCall(overrides: Partial = {}): ToolCallInfo { + return { + id: 'tool-123', + name: 'Read', + input: { file_path: '/test/file.md' }, + status: 'running', + ...overrides, + }; +} + +describe('ToolCallRenderer', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('renderToolCall', () => { + it('should store element in toolCallElements map', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ id: 'test-id' }); + const toolCallElements = new Map(); + + const toolEl = renderToolCall(parentEl, toolCall, toolCallElements); + + expect(toolCallElements.get('test-id')).toBe(toolEl); + }); + + it('should set data-tool-id on element', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ id: 'my-tool-id' }); + const toolCallElements = new Map(); + + const toolEl = renderToolCall(parentEl, toolCall, toolCallElements); + + expect(toolEl.dataset.toolId).toBe('my-tool-id'); + }); + + it('should set correct ARIA attributes for accessibility', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + const toolCallElements = new Map(); + + const toolEl = renderToolCall(parentEl, toolCall, toolCallElements); + + const header = (toolEl as any)._children[0]; + expect(header.getAttribute('role')).toBe('button'); + expect(header.getAttribute('tabindex')).toBe('0'); + }); + + it('should track isExpanded on toolCall object', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + const toolCallElements = new Map(); + + renderToolCall(parentEl, toolCall, toolCallElements); + + expect(toolCall.isExpanded).toBe(false); + }); + + it('should start expanded when requested', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + const toolCallElements = new Map(); + + const toolEl = renderToolCall(parentEl, toolCall, toolCallElements, { initiallyExpanded: true }); + const header = toolEl.querySelector('.claudian-tool-header'); + const content = toolEl.querySelector('.claudian-tool-content'); + + expect(toolCall.isExpanded).toBe(true); + expect(toolEl.hasClass('expanded')).toBe(true); + expect(content?.hasClass('claudian-hidden')).toBe(false); + expect(header?.getAttribute('aria-expanded')).toBe('true'); + }); + }); + + describe('renderStoredToolCall', () => { + it('should show completed status icon', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ status: 'completed' }); + + renderStoredToolCall(parentEl, toolCall); + + expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'check'); + }); + + it('should show error status icon', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ status: 'error' }); + + renderStoredToolCall(parentEl, toolCall); + + expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'x'); + }); + + it('renders AskUserQuestion answers from result text when resolvedAnswers is missing', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'AskUserQuestion', + status: 'completed', + input: { questions: [{ question: 'Color?' }] }, + result: '"Color?"="Blue"', + }); + + const toolEl = renderStoredToolCall(parentEl, toolCall); + const answerEls = toolEl.querySelectorAll('.claudian-ask-review-a-text'); + + expect(answerEls).toHaveLength(1); + expect(answerEls[0].textContent).toBe('Blue'); + }); + + it('renders AskUserQuestion answers by stable question id', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'AskUserQuestion', + status: 'completed', + input: { questions: [{ id: 'q1', question: 'Color?' }] }, + result: '{"answers":{"q1":{"answers":["Blue"]}}}', + }); + + const toolEl = renderStoredToolCall(parentEl, toolCall); + const answerEls = toolEl.querySelectorAll('.claudian-ask-review-a-text'); + + expect(answerEls).toHaveLength(1); + expect(answerEls[0].textContent).toBe('Blue'); + }); + + it('renders AskUserQuestion options during fallback state', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'AskUserQuestion', + status: 'running', + input: { + questions: [{ + question: 'Title timing?', + options: [ + { label: 'Non-blocking', description: 'Generate title later.' }, + { label: 'Blocking', description: 'Wait for title first.' }, + ], + multiSelect: true, + }], + }, + }); + + const toolEl = renderStoredToolCall(parentEl, toolCall); + const labelEls = toolEl.querySelectorAll('.claudian-ask-item-label'); + const descEls = toolEl.querySelectorAll('.claudian-ask-item-desc'); + const checkEls = toolEl.querySelectorAll('.claudian-ask-check'); + + expect(Array.from(labelEls, el => el.textContent)).toEqual(['Non-blocking', 'Blocking']); + expect(Array.from(descEls, el => el.textContent)).toEqual(['Generate title later.', 'Wait for title first.']); + expect(checkEls).toHaveLength(2); + }); + }); + + describe('updateToolCallResult', () => { + it('should update status indicator', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ id: 'tool-1' }); + const toolCallElements = new Map(); + + const toolEl = renderToolCall(parentEl, toolCall, toolCallElements); + + // Update with completed result + toolCall.status = 'completed'; + toolCall.result = 'Success'; + updateToolCallResult('tool-1', toolCall, toolCallElements); + + const statusEl = toolEl.querySelector('.claudian-tool-status'); + expect(statusEl?.hasClass('status-completed')).toBe(true); + }); + + it('shows raw AskUserQuestion result when answers cannot be parsed', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + id: 'ask-1', + name: 'AskUserQuestion', + input: { questions: [{ question: 'Color?' }] }, + }); + const toolCallElements = new Map(); + + const toolEl = renderToolCall(parentEl, toolCall, toolCallElements); + toolCall.status = 'completed'; + toolCall.result = 'Answer submitted successfully.'; + + updateToolCallResult('ask-1', toolCall, toolCallElements); + + const promptEl = toolEl.querySelector('.claudian-ask-review-prompt'); + expect(promptEl?.textContent).toBe('Answer submitted successfully.'); + }); + }); + + describe('setToolIcon', () => { + it('should call setIcon with the resolved icon name', () => { + const el = createMockEl() as unknown as HTMLElement; + setToolIcon(el, 'Read'); + expect(setIcon).toHaveBeenCalledWith(el, expect.any(String)); + }); + + it('should set MCP SVG for MCP tools', () => { + const el = createMockEl(); + setToolIcon(el as unknown as HTMLElement, 'mcp__server__tool'); + expect(el.children[0]?.tagName).toBe('SVG'); + }); + }); + + describe('getToolLabel', () => { + it('should label Read tool with shortened path', () => { + expect(getToolLabel('Read', { file_path: '/a/b/c/d/e.ts' })).toBe('Read: .../d/e.ts'); + }); + + it('should label Read with fallback for missing path', () => { + expect(getToolLabel('Read', {})).toBe('Read: file'); + }); + + it('should label Write tool with path', () => { + expect(getToolLabel('Write', { file_path: 'short.ts' })).toBe('Write: short.ts'); + }); + + it('should label Edit tool with path', () => { + expect(getToolLabel('Edit', { file_path: 'file.ts' })).toBe('Edit: file.ts'); + }); + + it('should label Bash tool and truncate long commands', () => { + const shortCmd = 'npm test'; + expect(getToolLabel('Bash', { command: shortCmd })).toBe('Bash: npm test'); + + const longCmd = 'a'.repeat(50); + expect(getToolLabel('Bash', { command: longCmd })).toBe(`Bash: ${'a'.repeat(40)}...`); + }); + + it('should label Bash with fallback for missing command', () => { + expect(getToolLabel('Bash', {})).toBe('Bash: command'); + }); + + it('should label Glob tool', () => { + expect(getToolLabel('Glob', { pattern: '**/*.ts' })).toBe('Glob: **/*.ts'); + }); + + it('should label Glob with fallback', () => { + expect(getToolLabel('Glob', {})).toBe('Glob: files'); + }); + + it('should label Grep tool', () => { + expect(getToolLabel('Grep', { pattern: 'TODO' })).toBe('Grep: TODO'); + }); + + it('should label WebSearch and truncate long queries', () => { + expect(getToolLabel('WebSearch', { query: 'short' })).toBe('WebSearch: short'); + + const longQuery = 'q'.repeat(50); + expect(getToolLabel('WebSearch', { query: longQuery })).toBe(`WebSearch: ${'q'.repeat(40)}...`); + }); + + it('should label WebSearch open_page actions with the URL', () => { + expect(getToolLabel('WebSearch', { + actionType: 'open_page', + url: 'https://example.com/docs', + })).toBe('WebSearch: Open https://example.com/docs'); + }); + + it('should label WebFetch and truncate long URLs', () => { + expect(getToolLabel('WebFetch', { url: 'https://x.com' })).toBe('WebFetch: https://x.com'); + + const longUrl = 'https://' + 'x'.repeat(50); + expect(getToolLabel('WebFetch', { url: longUrl })).toBe(`WebFetch: ${longUrl.substring(0, 40)}...`); + }); + + it('should label LS tool with path', () => { + expect(getToolLabel('LS', { path: '/src' })).toBe('LS: /src'); + }); + + it('should label LS with fallback', () => { + expect(getToolLabel('LS', {})).toBe('LS: .'); + }); + + it('should label TodoWrite with completion count', () => { + const todos = [ + { status: 'completed' }, + { status: 'completed' }, + { status: 'pending' }, + ]; + expect(getToolLabel('TodoWrite', { todos })).toBe('Tasks (2/3)'); + }); + + it('should label TodoWrite without array', () => { + expect(getToolLabel('TodoWrite', {})).toBe('Tasks'); + }); + + it('should label Skill tool', () => { + expect(getToolLabel('Skill', { skill: 'commit' })).toBe('Skill: commit'); + }); + + it('should label Skill with fallback', () => { + expect(getToolLabel('Skill', {})).toBe('Skill: skill'); + }); + + it('should label ToolSearch with tool names', () => { + expect(getToolLabel('ToolSearch', { query: 'select:Read,Glob' })).toBe('ToolSearch: Read, Glob'); + }); + + it('should label ToolSearch with fallback for missing query', () => { + expect(getToolLabel('ToolSearch', {})).toBe('ToolSearch: tools'); + }); + + it('should return raw name for unknown tools', () => { + expect(getToolLabel('CustomTool', {})).toBe('CustomTool'); + }); + }); + + describe('getToolName', () => { + it('should return tool name for standard tools', () => { + expect(getToolName('Read', {})).toBe('Read'); + expect(getToolName('Write', {})).toBe('Write'); + expect(getToolName('Bash', {})).toBe('Bash'); + expect(getToolName('Glob', {})).toBe('Glob'); + }); + + it('should return Tasks with count for TodoWrite', () => { + const todos = [ + { status: 'completed' }, + { status: 'completed' }, + { status: 'pending' }, + ]; + expect(getToolName('TodoWrite', { todos })).toBe('Tasks 2/3'); + expect(getToolName('TodoWrite', {})).toBe('Tasks'); + }); + + it('should return plan mode labels', () => { + expect(getToolName('EnterPlanMode', {})).toBe('Entering plan mode'); + expect(getToolName('ExitPlanMode', {})).toBe('Plan complete'); + }); + + }); + + describe('getToolSummary', () => { + it('should return filename-only for file tools', () => { + expect(getToolSummary('Read', { file_path: '/a/b/c/file.ts' })).toBe('file.ts'); + expect(getToolSummary('Write', { file_path: '/src/index.ts' })).toBe('index.ts'); + expect(getToolSummary('Edit', { file_path: 'simple.md' })).toBe('simple.md'); + }); + + it('should return empty for file tools with no path', () => { + expect(getToolSummary('Read', {})).toBe(''); + }); + + it('should return command for Bash', () => { + expect(getToolSummary('Bash', { command: 'npm test' })).toBe('npm test'); + }); + + it('should truncate long Bash commands', () => { + const longCmd = 'a'.repeat(70); + expect(getToolSummary('Bash', { command: longCmd })).toBe('a'.repeat(60) + '...'); + }); + + it('should return pattern for Glob/Grep', () => { + expect(getToolSummary('Glob', { pattern: '**/*.ts' })).toBe('**/*.ts'); + expect(getToolSummary('Grep', { pattern: 'TODO' })).toBe('TODO'); + }); + + it('should return query for WebSearch', () => { + expect(getToolSummary('WebSearch', { query: 'test query' })).toBe('test query'); + }); + + it('should summarize WebSearch find_in_page actions', () => { + expect(getToolSummary('WebSearch', { + actionType: 'find_in_page', + url: 'https://example.com/docs', + pattern: 'tools', + })).toBe('Find "tools" in https://example.com/docs'); + }); + + it('should return url for WebFetch', () => { + expect(getToolSummary('WebFetch', { url: 'https://x.com' })).toBe('https://x.com'); + }); + + it('should return filename for LS', () => { + expect(getToolSummary('LS', { path: '/src/components' })).toBe('components'); + }); + + it('should return skill name for Skill', () => { + expect(getToolSummary('Skill', { skill: 'commit' })).toBe('commit'); + }); + + it('should return empty for TodoWrite', () => { + const todos = [ + { status: 'completed', activeForm: 'Done' }, + { status: 'in_progress', activeForm: 'Working on it' }, + ]; + expect(getToolSummary('TodoWrite', { todos })).toBe(''); + expect(getToolSummary('TodoWrite', {})).toBe(''); + }); + + it('should return empty for AskUserQuestion', () => { + expect(getToolSummary('AskUserQuestion', { questions: [{ question: 'Q1' }] })).toBe(''); + expect(getToolSummary('AskUserQuestion', { questions: [{ question: 'Q1' }, { question: 'Q2' }] })).toBe(''); + }); + + it('should return parsed tool names for ToolSearch', () => { + expect(getToolSummary('ToolSearch', { query: 'select:Read,Glob' })).toBe('Read, Glob'); + expect(getToolSummary('ToolSearch', { query: 'select:Bash' })).toBe('Bash'); + }); + + it('should return empty for ToolSearch with missing query', () => { + expect(getToolSummary('ToolSearch', {})).toBe(''); + }); + + it('should return empty for unknown tools', () => { + expect(getToolSummary('CustomTool', {})).toBe(''); + }); + }); + + describe('getToolSummary - Codex native tools', () => { + it('returns file count for apply_patch with changes array', () => { + expect(getToolSummary('apply_patch', { + changes: [{ path: 'src/a.ts', kind: 'update' }, { path: 'src/b.ts', kind: 'add' }], + })).toBe('2 files'); + }); + + it('returns single filename for apply_patch with one change', () => { + expect(getToolSummary('apply_patch', { + changes: [{ path: 'src/main.ts', kind: 'update' }], + })).toBe('main.ts'); + }); + + it('extracts files from patch text markers', () => { + expect(getToolSummary('apply_patch', { + patch: '*** Update File: src/main.ts\n--- src/main.ts\n+++ src/main.ts\n@@ ...', + })).toBe('main.ts'); + }); + + it('returns "patch" for apply_patch with unrecognized patch text', () => { + expect(getToolSummary('apply_patch', { patch: 'diff output here' })).toBe('patch'); + }); + + it('returns empty for apply_patch with no input', () => { + expect(getToolSummary('apply_patch', {})).toBe(''); + }); + + it('returns session id for write_stdin', () => { + expect(getToolSummary('write_stdin', { session_id: 'sess_1', chars: 'y\n' })).toBe('#sess_1 y\\n'); + }); + + it('returns chars preview for write_stdin without session', () => { + expect(getToolSummary('write_stdin', { chars: 'y\n' })).toBe('y\\n'); + }); + + it('returns empty for write_stdin with no input', () => { + expect(getToolSummary('write_stdin', {})).toBe(''); + }); + + it('returns message preview for spawn_agent', () => { + expect(getToolSummary('spawn_agent', { message: 'Update imports' })).toBe('Update imports'); + }); + + it('truncates long spawn_agent messages', () => { + const longMsg = 'a'.repeat(60); + expect(getToolSummary('spawn_agent', { message: longMsg })).toBe('a'.repeat(50) + '...'); + }); + + it('returns agent count and timeout for wait', () => { + expect(getToolSummary('wait', { ids: ['a1'], timeout_ms: 30000 })).toBe('1 agent, 30s'); + }); + + it('returns message preview for send_input', () => { + expect(getToolSummary('send_input', { message: 'Also update exports.' })).toBe('Also update exports.'); + }); + + it('returns empty for resume_agent and close_agent', () => { + expect(getToolSummary('resume_agent', {})).toBe(''); + expect(getToolSummary('close_agent', {})).toBe(''); + }); + }); + + describe('getToolLabel - Codex native tools', () => { + it('labels apply_patch with summary', () => { + expect(getToolLabel('apply_patch', { + changes: [{ path: 'src/foo.ts', kind: 'update' }], + })).toBe('apply_patch: foo.ts'); + }); + + it('labels apply_patch without changes', () => { + expect(getToolLabel('apply_patch', {})).toBe('apply_patch'); + }); + + it('labels write_stdin with summary', () => { + expect(getToolLabel('write_stdin', { session_id: 's1' })).toBe('write_stdin: #s1'); + }); + + it('labels write_stdin without input', () => { + expect(getToolLabel('write_stdin', {})).toBe('write_stdin'); + }); + + it('labels spawn_agent with message', () => { + expect(getToolLabel('spawn_agent', { message: 'Fix bug' })).toBe('spawn_agent: Fix bug'); + }); + + it('labels wait with count', () => { + expect(getToolLabel('wait', { ids: ['a1', 'a2'], timeout_ms: 5000 })).toBe('wait: 2 agents, 5s'); + }); + + it('returns raw name for lifecycle tools with no summary', () => { + expect(getToolLabel('close_agent', {})).toBe('close_agent'); + }); + }); + + describe('WebSearch expanded rendering', () => { + it('renders Codex search actions instead of the placeholder result text', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'WebSearch', + status: 'completed', + input: { + actionType: 'search', + query: 'obsidian plugin API', + queries: ['obsidian plugin API', 'obsidian docs'], + }, + result: 'Search complete', + }); + + const toolEl = renderStoredToolCall(parentEl, toolCall); + const lines = Array.from(toolEl.querySelectorAll('.claudian-tool-line')).map(line => line.textContent); + + expect(lines).toContain('Query: obsidian plugin API'); + expect(lines).toContain('Alt query: obsidian docs'); + expect(lines).not.toContain('Search complete'); + }); + + it('renders Codex open_page actions from tool input even without a rich result body', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'WebSearch', + status: 'completed', + input: { + actionType: 'open_page', + url: 'https://example.com/docs', + }, + result: 'Search complete', + }); + + const toolEl = renderStoredToolCall(parentEl, toolCall); + const links = toolEl.querySelectorAll('.claudian-tool-link'); + const lines = Array.from(toolEl.querySelectorAll('.claudian-tool-line')).map(line => line.textContent); + + expect(lines).toContain('Open page'); + expect(links).toHaveLength(1); + expect(links[0].getAttribute('href')).toBe('https://example.com/docs'); + expect(links[0].querySelector('.claudian-tool-link-title')?.textContent).toBe('https://example.com/docs'); + }); + }); + + describe('apply_patch expanded rendering', () => { + it('renders parsed patch diffs from tool input', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'apply_patch', + status: 'completed', + input: { + patch: [ + '*** Begin Patch', + '*** Update File: src/main.ts', + '@@', + "-import { Plugin } from 'obsidian';", + "+import { Plugin, Notice } from 'obsidian';", + '*** End Patch', + ].join('\n'), + }, + result: 'Applied patch', + }); + + const toolEl = renderStoredToolCall(parentEl, toolCall); + const headers = Array.from(toolEl.querySelectorAll('.claudian-tool-patch-header')).map(el => el.textContent); + const statusEl = toolEl.querySelector('.claudian-tool-status'); + const diffTexts = Array.from(toolEl.querySelectorAll('.claudian-diff-text')).map(el => el.textContent); + + expect(headers).toHaveLength(0); + expect(statusEl?.hasClass('claudian-write-edit-stats')).toBe(true); + expect(statusEl?.querySelector('.added')?.textContent).toBe('+1'); + expect(statusEl?.querySelector('.removed')?.textContent).toBe('-1'); + expect(statusEl?.getAttribute('aria-label')).toBe('Changes: +1 -1'); + expect(setIcon).not.toHaveBeenCalledWith(expect.anything(), 'check'); + expect(diffTexts).toContain("import { Plugin } from 'obsidian';"); + expect(diffTexts).toContain("import { Plugin, Notice } from 'obsidian';"); + }); + + it('renders stored apply_patch collapsed by default', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'apply_patch', + status: 'completed', + input: { + patch: [ + '*** Begin Patch', + '*** Update File: src/main.ts', + '@@', + '-old', + '+new', + '*** End Patch', + ].join('\n'), + }, + result: 'Applied patch', + }); + + const toolEl = renderStoredToolCall(parentEl, toolCall); + const header = toolEl.querySelector('.claudian-tool-header'); + const content = toolEl.querySelector('.claudian-tool-content'); + + expect(toolEl.hasClass('expanded')).toBe(false); + expect(content?.hasClass('claudian-hidden')).toBe(true); + expect(header?.getAttribute('aria-expanded')).toBe('false'); + }); + + it('renders stored apply_patch expanded when requested', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'apply_patch', + status: 'completed', + input: { + patch: [ + '*** Begin Patch', + '*** Update File: src/main.ts', + '@@', + '-old', + '+new', + '*** End Patch', + ].join('\n'), + }, + result: 'Applied patch', + }); + + const toolEl = renderStoredToolCall(parentEl, toolCall, { initiallyExpanded: true }); + const header = toolEl.querySelector('.claudian-tool-header'); + const content = toolEl.querySelector('.claudian-tool-content'); + + expect(toolEl.hasClass('expanded')).toBe(true); + expect(content?.hasClass('claudian-hidden')).toBe(false); + expect(header?.getAttribute('aria-expanded')).toBe('true'); + }); + + it('renders fileChange patchUpdated diffs from changes input', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'apply_patch', + status: 'completed', + input: { + changes: [ + { + path: 'src/main.ts', + kind: 'update', + diff: '@@ -1 +1 @@\n-old value\n+new value', + }, + ], + }, + result: 'Applied patch', + }); + + const toolEl = renderStoredToolCall(parentEl, toolCall); + const headers = Array.from(toolEl.querySelectorAll('.claudian-tool-patch-header')).map(el => el.textContent); + const statusEl = toolEl.querySelector('.claudian-tool-status'); + const diffTexts = Array.from(toolEl.querySelectorAll('.claudian-diff-text')).map(el => el.textContent); + + expect(headers).toHaveLength(0); + expect(statusEl?.hasClass('claudian-write-edit-stats')).toBe(true); + expect(statusEl?.querySelector('.added')?.textContent).toBe('+1'); + expect(statusEl?.querySelector('.removed')?.textContent).toBe('-1'); + expect(setIcon).not.toHaveBeenCalledWith(expect.anything(), 'check'); + expect(diffTexts).toContain('old value'); + expect(diffTexts).toContain('new value'); + }); + + it('aggregates apply_patch header stats across changed files', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'apply_patch', + status: 'completed', + input: { + changes: [ + { + path: 'src/main.ts', + kind: 'update', + diff: '@@ -1 +1 @@\n-old value\n+new value', + }, + { + path: 'src/extra.ts', + kind: 'update', + diff: '@@ -1 +1,2 @@\n-old extra\n+new extra\n+another extra', + }, + ], + }, + result: 'Applied patch', + }); + + const toolEl = renderStoredToolCall(parentEl, toolCall); + const statusEl = toolEl.querySelector('.claudian-tool-status'); + + expect(statusEl?.querySelector('.added')?.textContent).toBe('+3'); + expect(statusEl?.querySelector('.removed')?.textContent).toBe('-2'); + expect(statusEl?.getAttribute('aria-label')).toBe('Changes: +3 -2'); + }); + + it('keeps the error status icon for failed apply_patch calls', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'apply_patch', + status: 'error', + input: { + changes: [ + { + path: 'src/main.ts', + kind: 'update', + diff: '@@ -1 +1 @@\n-old value\n+new value', + }, + ], + }, + result: 'Error: patch failed', + }); + + const toolEl = renderStoredToolCall(parentEl, toolCall); + const statusEl = toolEl.querySelector('.claudian-tool-status'); + + expect(statusEl?.hasClass('status-error')).toBe(true); + expect(statusEl?.hasClass('claudian-write-edit-stats')).toBe(false); + expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'x'); + }); + + it('updates the header stats when apply_patch diffs arrive during streaming', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + id: 'patch-1', + name: 'apply_patch', + status: 'running', + input: {}, + }); + const toolCallElements = new Map(); + + const toolEl = renderToolCall(parentEl, toolCall, toolCallElements); + jest.clearAllMocks(); + + toolCall.status = 'completed'; + toolCall.result = 'Applied patch'; + toolCall.input = { + changes: [ + { + path: 'src/main.ts', + kind: 'update', + diff: '@@ -1 +1 @@\n-old value\n+new value', + }, + ], + }; + + updateToolCallResult('patch-1', toolCall, toolCallElements); + + const statusEl = toolEl.querySelector('.claudian-tool-status'); + const diffTexts = Array.from(toolEl.querySelectorAll('.claudian-diff-text')).map(el => el.textContent); + + expect(statusEl?.hasClass('claudian-write-edit-stats')).toBe(true); + expect(statusEl?.querySelector('.added')?.textContent).toBe('+1'); + expect(statusEl?.querySelector('.removed')?.textContent).toBe('-1'); + expect(setIcon).not.toHaveBeenCalledWith(expect.anything(), 'check'); + expect(diffTexts).toContain('old value'); + expect(diffTexts).toContain('new value'); + }); + + it('falls back to file changes when patch text is unavailable', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'apply_patch', + status: 'completed', + input: { + changes: [{ path: 'src/main.ts', kind: 'update' }], + }, + result: 'Applied patch', + }); + + const toolEl = renderStoredToolCall(parentEl, toolCall); + const lines = Array.from(toolEl.querySelectorAll('.claudian-tool-line')).map(el => el.textContent); + + expect(lines).toContain('src/main.ts'); + expect(lines).not.toContain('update: src/main.ts'); + }); + }); + + describe('isBlockedToolResult', () => { + it.each([ + 'Path is outside the vault', + 'Access Denied for this file', + 'User denied the action', + 'Requires approval from user', + ])('should detect blocked result: %s', (result) => { + expect(isBlockedToolResult(result)).toBe(true); + }); + + it('should detect "deny" only when isError is true', () => { + expect(isBlockedToolResult('deny permission', true)).toBe(true); + expect(isBlockedToolResult('deny permission', false)).toBe(false); + expect(isBlockedToolResult('deny permission')).toBe(false); + }); + + it('should return false for normal results', () => { + expect(isBlockedToolResult('File content here')).toBe(false); + }); + + it('extracts text from structured content blocks before blocked detection', () => { + expect(isBlockedToolResult([ + { type: 'text', text: 'Requires approval from user' }, + ])).toBe(true); + }); + }); + + describe('renderTodoWriteResult', () => { + it('should render todo items', () => { + const container = createMockEl(); + const input = { + todos: [ + { status: 'completed', content: 'Task 1', activeForm: 'Task 1' }, + { status: 'pending', content: 'Task 2', activeForm: 'Task 2' }, + ], + }; + renderTodoWriteResult(container as unknown as HTMLElement, input); + expect(container.hasClass('claudian-todo-panel-content')).toBe(true); + expect(container.hasClass('claudian-todo-list-container')).toBe(true); + }); + + it('should show fallback text when no todos array', () => { + const container = createMockEl(); + renderTodoWriteResult(container as unknown as HTMLElement, {}); + expect(container._children[0].textContent).toBe('Tasks updated'); + }); + + it('should show fallback text for non-array todos', () => { + const container = createMockEl(); + renderTodoWriteResult(container as unknown as HTMLElement, { todos: 'invalid' }); + expect(container._children[0].textContent).toBe('Tasks updated'); + }); + }); + + describe('updateToolCallResult for TodoWrite', () => { + it('should update todo status and content', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + id: 'todo-1', + name: 'TodoWrite', + input: { + todos: [ + { status: 'in_progress', content: 'Task 1', activeForm: 'Working' }, + ], + }, + }); + const toolCallElements = new Map(); + + renderToolCall(parentEl, toolCall, toolCallElements); + + // Update with all completed + toolCall.input = { + todos: [ + { status: 'completed', content: 'Task 1', activeForm: 'Done' }, + ], + }; + updateToolCallResult('todo-1', toolCall, toolCallElements); + + const statusEl = parentEl.querySelector('.claudian-tool-status'); + expect(statusEl?.hasClass('status-completed')).toBe(true); + }); + + it('should do nothing for non-existent tool id', () => { + const toolCallElements = new Map(); + updateToolCallResult('nonexistent', createToolCall(), toolCallElements); + expect(toolCallElements.size).toBe(0); + }); + }); + + describe('renderStoredToolCall for TodoWrite', () => { + it('should render stored TodoWrite with status', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'TodoWrite', + status: 'completed', + input: { + todos: [ + { status: 'completed', content: 'Task 1', activeForm: 'Done' }, + ], + }, + }); + + const toolEl = renderStoredToolCall(parentEl, toolCall); + expect(toolEl).toBeDefined(); + }); + }); +}); diff --git a/tests/unit/features/chat/rendering/WriteEditRenderer.test.ts b/tests/unit/features/chat/rendering/WriteEditRenderer.test.ts new file mode 100644 index 0000000..97d2d9b --- /dev/null +++ b/tests/unit/features/chat/rendering/WriteEditRenderer.test.ts @@ -0,0 +1,474 @@ +import { createMockEl } from '@test/helpers/mockElement'; + +import type { ToolCallInfo, ToolDiffData } from '@/core/types'; +import { + createWriteEditBlock, + finalizeWriteEditBlock, + renderStoredWriteEdit, + updateWriteEditWithDiff, +} from '@/features/chat/rendering/WriteEditRenderer'; + +// Helper to create a basic tool call +function createToolCall(overrides: Partial = {}): ToolCallInfo { + return { + id: 'tool-123', + name: 'Write', + input: { file_path: '/test/vault/notes/test.md', content: 'new content' }, + status: 'running', + ...overrides, + }; +} + +// Helper to create pre-computed diff data +function createDiffData(overrides: Partial = {}): ToolDiffData { + return { + filePath: 'test.md', + diffLines: [ + { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, + { type: 'delete', text: 'old', oldLineNum: 2 }, + { type: 'insert', text: 'new', newLineNum: 2 }, + ], + stats: { added: 1, removed: 1 }, + ...overrides, + }; +} + +describe('WriteEditRenderer', () => { + describe('createWriteEditBlock', () => { + it('should create a block with correct structure', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + + const state = createWriteEditBlock(parentEl, toolCall); + + expect(state.wrapperEl).toBeDefined(); + expect(state.headerEl).toBeDefined(); + expect(state.nameEl).toBeDefined(); + expect(state.summaryEl).toBeDefined(); + expect(state.statsEl).toBeDefined(); + expect(state.statusEl).toBeDefined(); + expect(state.contentEl).toBeDefined(); + expect(state.toolCall).toBe(toolCall); + }); + + it('should set data-tool-id on wrapper', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ id: 'my-tool-id' }); + + const state = createWriteEditBlock(parentEl, toolCall); + + expect(state.wrapperEl.dataset.toolId).toBe('my-tool-id'); + }); + + it('should display tool name and filename in two-part header', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + name: 'Edit', + input: { file_path: 'notes/test.md' }, + }); + + const state = createWriteEditBlock(parentEl, toolCall); + + expect(state.nameEl.textContent).toBe('Edit'); + expect(state.summaryEl.textContent).toBe('test.md'); + }); + + it('should show spinner status while running', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + + const state = createWriteEditBlock(parentEl, toolCall); + + expect(state.statusEl.hasClass('status-running')).toBe(true); + }); + + it('should show filename only in summary', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + input: { file_path: '/very/long/path/to/some/deeply/nested/file.md' }, + }); + + const state = createWriteEditBlock(parentEl, toolCall); + + // Summary should show just the filename + expect(state.summaryEl.textContent).toBe('file.md'); + }); + + it('should handle missing file_path gracefully', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ input: {} }); + + const state = createWriteEditBlock(parentEl, toolCall); + + expect(state.summaryEl.textContent).toBe('file'); + }); + + it('should start collapsed by default', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + + const state = createWriteEditBlock(parentEl, toolCall); + + expect(state.isExpanded).toBe(false); + expect(state.wrapperEl.hasClass('expanded')).toBe(false); + expect(state.contentEl.hasClass('claudian-hidden')).toBe(true); + expect(state.headerEl.getAttribute('aria-expanded')).toBe('false'); + expect(state.headerEl.getAttribute('aria-label')).toContain('click to expand'); + }); + + it('should start expanded when requested', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + + const state = createWriteEditBlock(parentEl, toolCall, { initiallyExpanded: true }); + + expect(state.isExpanded).toBe(true); + expect(state.wrapperEl.hasClass('expanded')).toBe(true); + expect(state.contentEl.hasClass('claudian-hidden')).toBe(false); + expect(state.headerEl.getAttribute('aria-expanded')).toBe('true'); + expect(state.headerEl.getAttribute('aria-label')).toContain('click to collapse'); + }); + }); + + describe('updateWriteEditWithDiff', () => { + it('should render diff stats when diff data is provided', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + const state = createWriteEditBlock(parentEl, toolCall); + + const diffData = createDiffData({ + stats: { added: 1, removed: 0 }, + }); + + updateWriteEditWithDiff(state, diffData); + + // Should show +1 for added line + expect((state.statsEl as any)._children.length).toBeGreaterThan(0); + }); + + it('should store diffLines in state', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + const state = createWriteEditBlock(parentEl, toolCall); + + const diffData = createDiffData(); + + updateWriteEditWithDiff(state, diffData); + + expect(state.diffLines).toBeDefined(); + expect(state.diffLines!.length).toBeGreaterThan(0); + }); + + it('should show both added and removed counts', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + const state = createWriteEditBlock(parentEl, toolCall); + + const diffData = createDiffData({ + stats: { added: 3, removed: 2 }, + }); + + updateWriteEditWithDiff(state, diffData); + + // Should have stats children + expect((state.statsEl as any)._children.length).toBeGreaterThan(0); + }); + + it('should handle empty diffLines with zero stats', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + const state = createWriteEditBlock(parentEl, toolCall); + + const diffData = createDiffData({ + diffLines: [], + stats: { added: 0, removed: 0 }, + }); + + updateWriteEditWithDiff(state, diffData); + + // Should not have stats children when no changes + expect((state.statsEl as any)._children.length).toBe(0); + }); + }); + + describe('finalizeWriteEditBlock', () => { + it('should update status to done on success', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + const state = createWriteEditBlock(parentEl, toolCall); + + // Add diff data first + updateWriteEditWithDiff(state, createDiffData()); + + finalizeWriteEditBlock(state, false); + + expect(state.wrapperEl.hasClass('done')).toBe(true); + expect(state.statusEl.hasClass('status-running')).toBe(false); + }); + + it('should update status to error on failure', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ result: 'Error: file not found' }); + const state = createWriteEditBlock(parentEl, toolCall); + + finalizeWriteEditBlock(state, true); + + expect(state.wrapperEl.hasClass('error')).toBe(true); + expect(state.statusEl.hasClass('status-error')).toBe(true); + }); + + it('should show error message in content when no diff', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ result: 'Permission denied' }); + const state = createWriteEditBlock(parentEl, toolCall); + + finalizeWriteEditBlock(state, true); + + const contentText = getTextContent(state.contentEl); + expect(contentText).toContain('Permission denied'); + }); + + it('should clear spinner status on finalize', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + const state = createWriteEditBlock(parentEl, toolCall); + + finalizeWriteEditBlock(state, false); + + expect(state.statusEl.hasClass('status-running')).toBe(false); + expect((state.statusEl as any)._children.length).toBe(0); + }); + }); + + describe('renderStoredWriteEdit', () => { + it('should show done state for completed status', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ status: 'completed' }); + + const block = renderStoredWriteEdit(parentEl, toolCall); + + expect(block.hasClass('done')).toBe(true); + }); + + it('should show error state for error status', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ status: 'error' }); + + const block = renderStoredWriteEdit(parentEl, toolCall); + + expect(block.hasClass('error')).toBe(true); + }); + + it('should show error state for blocked status', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ status: 'blocked' }); + + const block = renderStoredWriteEdit(parentEl, toolCall); + + expect(block.hasClass('error')).toBe(true); + }); + + it('should render diff stats from stored diffData', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + status: 'completed', + diffData: createDiffData({ + stats: { added: 2, removed: 1 }, + }), + }); + + const block = renderStoredWriteEdit(parentEl, toolCall); + + // Block should be created successfully with stats + expect(block.dataset.toolId).toBe('tool-123'); + }); + + it('should handle stored block with empty diffLines', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + status: 'completed', + diffData: createDiffData({ + diffLines: [], + stats: { added: 0, removed: 0 }, + }), + }); + + const block = renderStoredWriteEdit(parentEl, toolCall); + + expect(block).toBeDefined(); + }); + + it('should show error message when no diffData and error', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + status: 'error', + result: 'File not found', + }); + + const block = renderStoredWriteEdit(parentEl, toolCall); + + expect(block.hasClass('error')).toBe(true); + }); + + it('should use correct icon for Edit tool', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ name: 'Edit' }); + + const block = renderStoredWriteEdit(parentEl, toolCall); + + // Block should render for Edit tool + expect(block).toBeDefined(); + }); + + it('should start collapsed by default', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ status: 'completed' }); + + const block = renderStoredWriteEdit(parentEl, toolCall); + const headerEl = block.querySelector('.claudian-write-edit-header'); + const contentEl = block.querySelector('.claudian-write-edit-content'); + + expect(block.hasClass('expanded')).toBe(false); + expect(contentEl?.hasClass('claudian-hidden')).toBe(true); + expect(headerEl?.getAttribute('aria-expanded')).toBe('false'); + expect(headerEl?.getAttribute('aria-label')).toContain('click to expand'); + }); + + it('should start expanded when requested', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ status: 'completed' }); + + const block = renderStoredWriteEdit(parentEl, toolCall, { initiallyExpanded: true }); + const headerEl = block.querySelector('.claudian-write-edit-header'); + const contentEl = block.querySelector('.claudian-write-edit-content'); + + expect(block.hasClass('expanded')).toBe(true); + expect(contentEl?.hasClass('claudian-hidden')).toBe(false); + expect(headerEl?.getAttribute('aria-expanded')).toBe('true'); + expect(headerEl?.getAttribute('aria-label')).toContain('click to collapse'); + }); + + }); + + describe('filename extraction', () => { + it('should extract filename from path', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + input: { file_path: 'notes/test.md' }, + }); + + const state = createWriteEditBlock(parentEl, toolCall); + + expect(state.summaryEl.textContent).toBe('test.md'); + }); + + it('should extract filename from long path', () => { + const parentEl = createMockEl(); + const longPath = 'src/components/features/auth/modals/confirmation/ConfirmationDialog.tsx'; + const toolCall = createToolCall({ + input: { file_path: longPath }, + }); + + const state = createWriteEditBlock(parentEl, toolCall); + + expect(state.summaryEl.textContent).toBe('ConfirmationDialog.tsx'); + }); + + it('should handle paths with only filename', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall({ + input: { file_path: 'README.md' }, + }); + + const state = createWriteEditBlock(parentEl, toolCall); + + expect(state.summaryEl.textContent).toBe('README.md'); + }); + }); + + describe('diff rendering', () => { + it('should render new file correctly (all inserts)', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + const state = createWriteEditBlock(parentEl, toolCall); + + const diffData: ToolDiffData = { + filePath: 'test.md', + diffLines: [ + { type: 'insert', text: 'new content', newLineNum: 1 }, + { type: 'insert', text: 'line 2', newLineNum: 2 }, + ], + stats: { added: 2, removed: 0 }, + }; + + updateWriteEditWithDiff(state, diffData); + + // Should show +2 for two new lines + expect(state.diffLines).toBeDefined(); + expect(state.diffLines!.filter(l => l.type === 'insert').length).toBe(2); + }); + + it('should handle file deletion (all deletes)', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + const state = createWriteEditBlock(parentEl, toolCall); + + const diffData: ToolDiffData = { + filePath: 'test.md', + diffLines: [ + { type: 'delete', text: 'content', oldLineNum: 1 }, + ], + stats: { added: 0, removed: 1 }, + }; + + updateWriteEditWithDiff(state, diffData); + + expect(state.diffLines).toBeDefined(); + expect(state.diffLines!.filter(l => l.type === 'delete').length).toBe(1); + }); + + it('should handle mixed changes', () => { + const parentEl = createMockEl(); + const toolCall = createToolCall(); + const state = createWriteEditBlock(parentEl, toolCall); + + const diffData: ToolDiffData = { + filePath: 'test.md', + diffLines: [ + { type: 'equal', text: 'line1', oldLineNum: 1, newLineNum: 1 }, + { type: 'delete', text: 'old', oldLineNum: 2 }, + { type: 'insert', text: 'new1', newLineNum: 2 }, + { type: 'insert', text: 'new2', newLineNum: 3 }, + { type: 'equal', text: 'line3', oldLineNum: 3, newLineNum: 4 }, + ], + stats: { added: 2, removed: 1 }, + }; + + updateWriteEditWithDiff(state, diffData); + + expect(state.diffLines).toBeDefined(); + const types = state.diffLines!.reduce( + (acc, l) => { + acc[l.type] = (acc[l.type] || 0) + 1; + return acc; + }, + {} as Record + ); + + expect(types.delete).toBe(1); + expect(types.insert).toBe(2); + expect(types.equal).toBe(2); + }); + }); +}); + +// Helper to get text content recursively +function getTextContent(element: any): string { + let text = element.textContent || ''; + if (element._children) { + for (const child of element._children) { + text += getTextContent(child); + } + } + return text; +} diff --git a/tests/unit/features/chat/rendering/collapsible.test.ts b/tests/unit/features/chat/rendering/collapsible.test.ts new file mode 100644 index 0000000..032d843 --- /dev/null +++ b/tests/unit/features/chat/rendering/collapsible.test.ts @@ -0,0 +1,158 @@ +import { createMockEl } from '@test/helpers/mockElement'; + +import { + collapseElement, + type CollapsibleState, + setupCollapsible, +} from '@/features/chat/rendering/collapsible'; + +describe('collapsible', () => { + let wrapper: ReturnType; + let header: ReturnType; + let content: ReturnType; + let state: CollapsibleState; + + beforeEach(() => { + wrapper = createMockEl(); + header = createMockEl(); + content = createMockEl(); + state = { isExpanded: false }; + }); + + describe('setupCollapsible', () => { + it('should start collapsed by default', () => { + setupCollapsible(wrapper, header, content, state); + + expect(state.isExpanded).toBe(false); + expect(content.hasClass('claudian-hidden')).toBe(true); + expect(header.getAttribute('aria-expanded')).toBe('false'); + expect(wrapper.hasClass('expanded')).toBe(false); + }); + + it('should start expanded when initiallyExpanded is true', () => { + setupCollapsible(wrapper, header, content, state, { initiallyExpanded: true }); + + expect(state.isExpanded).toBe(true); + expect(content.hasClass('claudian-hidden')).toBe(false); + expect(header.getAttribute('aria-expanded')).toBe('true'); + expect(wrapper.hasClass('expanded')).toBe(true); + }); + + it('should toggle on click', () => { + setupCollapsible(wrapper, header, content, state); + + const clickHandlers = header._eventListeners.get('click') || []; + expect(clickHandlers.length).toBe(1); + + // Expand + clickHandlers[0](); + expect(state.isExpanded).toBe(true); + expect(wrapper.hasClass('expanded')).toBe(true); + expect(content.hasClass('claudian-hidden')).toBe(false); + expect(header.getAttribute('aria-expanded')).toBe('true'); + + // Collapse + clickHandlers[0](); + expect(state.isExpanded).toBe(false); + expect(wrapper.hasClass('expanded')).toBe(false); + expect(content.hasClass('claudian-hidden')).toBe(true); + expect(header.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should toggle on Enter key', () => { + setupCollapsible(wrapper, header, content, state); + + const keydownHandlers = header._eventListeners.get('keydown') || []; + const event = { key: 'Enter', preventDefault: jest.fn() }; + keydownHandlers[0](event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(state.isExpanded).toBe(true); + }); + + it('should toggle on Space key', () => { + setupCollapsible(wrapper, header, content, state); + + const keydownHandlers = header._eventListeners.get('keydown') || []; + const event = { key: ' ', preventDefault: jest.fn() }; + keydownHandlers[0](event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(state.isExpanded).toBe(true); + }); + + it('should not toggle on other keys', () => { + setupCollapsible(wrapper, header, content, state); + + const keydownHandlers = header._eventListeners.get('keydown') || []; + const event = { key: 'Tab', preventDefault: jest.fn() }; + keydownHandlers[0](event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(state.isExpanded).toBe(false); + }); + + it('should call onToggle callback with new state', () => { + const onToggle = jest.fn(); + + setupCollapsible(wrapper, header, content, state, { onToggle }); + + const clickHandlers = header._eventListeners.get('click') || []; + + clickHandlers[0](); + expect(onToggle).toHaveBeenCalledWith(true); + + clickHandlers[0](); + expect(onToggle).toHaveBeenCalledWith(false); + }); + + it('should set aria-label with baseAriaLabel', () => { + setupCollapsible(wrapper, header, content, state, { baseAriaLabel: 'Read: file.ts' }); + + expect(header.getAttribute('aria-label')).toBe('Read: file.ts - click to expand'); + + // Expand and check label changes + const clickHandlers = header._eventListeners.get('click') || []; + clickHandlers[0](); + expect(header.getAttribute('aria-label')).toBe('Read: file.ts - click to collapse'); + }); + + it('should set aria-label for initially expanded with baseAriaLabel', () => { + setupCollapsible(wrapper, header, content, state, { + initiallyExpanded: true, + baseAriaLabel: 'Tool', + }); + + expect(header.getAttribute('aria-label')).toBe('Tool - click to collapse'); + }); + + it('should not set aria-label without baseAriaLabel', () => { + setupCollapsible(wrapper, header, content, state); + + expect(header.getAttribute('aria-label')).toBeNull(); + }); + }); + + describe('collapseElement', () => { + it('should collapse an expanded element', () => { + state.isExpanded = true; + wrapper.addClass('expanded'); + content.style.display = 'block'; + header.setAttribute('aria-expanded', 'true'); + + collapseElement(wrapper, header, content, state); + + expect(state.isExpanded).toBe(false); + expect(wrapper.hasClass('expanded')).toBe(false); + expect(content.hasClass('claudian-hidden')).toBe(true); + expect(header.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should be safe to call on already collapsed element', () => { + collapseElement(wrapper, header, content, state); + + expect(state.isExpanded).toBe(false); + expect(content.hasClass('claudian-hidden')).toBe(true); + }); + }); +}); diff --git a/tests/unit/features/chat/rendering/todoUtils.test.ts b/tests/unit/features/chat/rendering/todoUtils.test.ts new file mode 100644 index 0000000..565e50a --- /dev/null +++ b/tests/unit/features/chat/rendering/todoUtils.test.ts @@ -0,0 +1,105 @@ +import { createMockEl } from '@test/helpers/mockElement'; +import { setIcon } from 'obsidian'; + +import type { TodoItem } from '@/core/tools/todo'; +import { + getTodoDisplayText, + getTodoStatusIcon, + renderTodoItems, +} from '@/features/chat/rendering/todoUtils'; + +jest.mock('obsidian', () => ({ + setIcon: jest.fn(), +})); + +describe('todoUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getTodoStatusIcon', () => { + it('should return "check" for completed', () => { + expect(getTodoStatusIcon('completed')).toBe('check'); + }); + + it('should return "dot" for pending', () => { + expect(getTodoStatusIcon('pending')).toBe('dot'); + }); + + it('should return "dot" for in_progress', () => { + expect(getTodoStatusIcon('in_progress')).toBe('dot'); + }); + }); + + describe('getTodoDisplayText', () => { + it('should return activeForm for in_progress', () => { + const todo: TodoItem = { status: 'in_progress', content: 'Fix bug', activeForm: 'Fixing bug' }; + expect(getTodoDisplayText(todo)).toBe('Fixing bug'); + }); + + it('should return content for completed', () => { + const todo: TodoItem = { status: 'completed', content: 'Fix bug', activeForm: 'Fixing bug' }; + expect(getTodoDisplayText(todo)).toBe('Fix bug'); + }); + + it('should return content for pending', () => { + const todo: TodoItem = { status: 'pending', content: 'Fix bug', activeForm: 'Fixing bug' }; + expect(getTodoDisplayText(todo)).toBe('Fix bug'); + }); + }); + + describe('renderTodoItems', () => { + it('should render todo items with status icons and text', () => { + const container = createMockEl(); + const todos: TodoItem[] = [ + { status: 'completed', content: 'Task 1', activeForm: 'Doing Task 1' }, + { status: 'in_progress', content: 'Task 2', activeForm: 'Doing Task 2' }, + { status: 'pending', content: 'Task 3', activeForm: 'Doing Task 3' }, + ]; + + renderTodoItems(container as unknown as HTMLElement, todos); + + expect(container._children.length).toBe(3); + expect(setIcon).toHaveBeenCalledTimes(3); + + // First item: completed + expect(container._children[0].hasClass('claudian-todo-completed')).toBe(true); + expect(setIcon).toHaveBeenCalledWith(expect.anything(), 'check'); + + // Second item: in_progress shows activeForm + expect(container._children[1].hasClass('claudian-todo-in_progress')).toBe(true); + + // Third item: pending + expect(container._children[2].hasClass('claudian-todo-pending')).toBe(true); + }); + + it('should clear container before rendering', () => { + const container = createMockEl(); + container.createDiv({ text: 'old content' }); + + renderTodoItems(container as unknown as HTMLElement, [ + { status: 'completed', content: 'New', activeForm: 'New' }, + ]); + + // Should have exactly 1 child (old cleared, new added) + expect(container._children.length).toBe(1); + }); + + it('should handle empty todos array', () => { + const container = createMockEl(); + renderTodoItems(container as unknown as HTMLElement, []); + expect(container._children.length).toBe(0); + }); + + it('should set aria-hidden on status icon', () => { + const container = createMockEl(); + renderTodoItems(container as unknown as HTMLElement, [ + { status: 'completed', content: 'Task', activeForm: 'Task' }, + ]); + + const item = container._children[0]; + const icon = item._children[0]; + expect(icon.getAttribute('aria-hidden')).toBe('true'); + }); + }); +}); diff --git a/tests/unit/features/chat/rewind.test.ts b/tests/unit/features/chat/rewind.test.ts new file mode 100644 index 0000000..502f278 --- /dev/null +++ b/tests/unit/features/chat/rewind.test.ts @@ -0,0 +1,56 @@ +import type { ChatMessage } from '@/core/types'; +import { findRewindContext } from '@/features/chat/rewind'; + +describe('findRewindContext', () => { + it('finds the nearest previous assistant UUID and detects a following response UUID', () => { + const messages: ChatMessage[] = [ + { id: 'a0', role: 'assistant', content: 'no uuid', timestamp: 1 }, + { id: 'a1', role: 'assistant', content: 'prev', timestamp: 2, assistantMessageId: 'prev-a' }, + { id: 'u1', role: 'user', content: 'user', timestamp: 3, userMessageId: 'user-u' }, + { id: 'a2', role: 'assistant', content: 'no uuid', timestamp: 4 }, + { id: 'a3', role: 'assistant', content: 'resp', timestamp: 5, assistantMessageId: 'resp-a' }, + ]; + + const ctx = findRewindContext(messages, 2); + expect(ctx.prevAssistantUuid).toBe('prev-a'); + expect(ctx.hasResponse).toBe(true); + }); + + it('does not treat assistants after the next user message as a response', () => { + const messages: ChatMessage[] = [ + { id: 'a1', role: 'assistant', content: 'prev', timestamp: 1, assistantMessageId: 'prev-a' }, + { id: 'u1', role: 'user', content: 'user', timestamp: 2, userMessageId: 'user-u' }, + { id: 'a2', role: 'assistant', content: 'no uuid', timestamp: 3 }, + { id: 'u2', role: 'user', content: 'next user', timestamp: 4, userMessageId: 'user-u2' }, + { id: 'a3', role: 'assistant', content: 'later resp', timestamp: 5, assistantMessageId: 'resp-a' }, + ]; + + const ctx = findRewindContext(messages, 1); + expect(ctx.prevAssistantUuid).toBe('prev-a'); + expect(ctx.hasResponse).toBe(false); + }); + + it('returns prevAssistantUuid as undefined when no prior assistant UUID exists', () => { + const messages: ChatMessage[] = [ + { id: 'u1', role: 'user', content: 'user', timestamp: 1, userMessageId: 'user-u' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 2, assistantMessageId: 'resp-a' }, + ]; + + const ctx = findRewindContext(messages, 0); + expect(ctx.prevAssistantUuid).toBeUndefined(); + expect(ctx.hasResponse).toBe(true); + }); + + it('returns hasResponse as false when no following assistant UUID exists', () => { + const messages: ChatMessage[] = [ + { id: 'a1', role: 'assistant', content: 'prev', timestamp: 1, assistantMessageId: 'prev-a' }, + { id: 'u1', role: 'user', content: 'user', timestamp: 2, userMessageId: 'user-u' }, + { id: 'a2', role: 'assistant', content: 'no uuid', timestamp: 3 }, + ]; + + const ctx = findRewindContext(messages, 1); + expect(ctx.prevAssistantUuid).toBe('prev-a'); + expect(ctx.hasResponse).toBe(false); + }); +}); + diff --git a/tests/unit/features/chat/services/BangBashService.test.ts b/tests/unit/features/chat/services/BangBashService.test.ts new file mode 100644 index 0000000..347150a --- /dev/null +++ b/tests/unit/features/chat/services/BangBashService.test.ts @@ -0,0 +1,142 @@ +import { exec } from 'child_process'; + +import { BangBashService } from '@/features/chat/services/BangBashService'; + +jest.mock('child_process', () => ({ + exec: jest.fn(), +})); + +const execMock = exec as jest.MockedFunction; + +describe('BangBashService', () => { + let service: BangBashService; + + beforeEach(() => { + service = new BangBashService('/test/dir', '/usr/bin'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('should pass correct exec options', async () => { + execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { + cb(null, '', ''); + return undefined as any; + }); + + await service.execute('echo hello'); + + expect(execMock).toHaveBeenCalledWith( + 'echo hello', + expect.objectContaining({ + cwd: '/test/dir', + env: expect.objectContaining({ PATH: '/usr/bin' }), + timeout: 30_000, + maxBuffer: 1024 * 1024, + shell: process.platform === 'win32' ? 'cmd.exe' : '/bin/bash', + }), + expect.any(Function) + ); + }); + + it('should return stdout for a successful command', async () => { + execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { + cb(null, 'hello\n', ''); + return undefined as any; + }); + + const result = await service.execute('echo hello'); + expect(result.command).toBe('echo hello'); + expect(result.stdout.trim()).toBe('hello'); + expect(result.exitCode).toBe(0); + expect(result.error).toBeUndefined(); + }); + + it('should return non-zero exit code for a failing command', async () => { + const error = Object.assign(new Error('Command failed'), { code: 2 }); + execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { + cb(error, '', 'No such file'); + return undefined as any; + }); + + const result = await service.execute('ls /nonexistent'); + expect(result.exitCode).toBe(2); + expect(result.stderr).toBe('No such file'); + }); + + it('should return exit code 1 when error has non-numeric code', async () => { + const error = Object.assign(new Error('Command failed'), { code: 'ENOENT' }); + execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { + cb(error, '', 'command not found'); + return undefined as any; + }); + + const result = await service.execute('nonexistent_cmd'); + expect(result.exitCode).toBe(1); + expect(typeof result.exitCode).toBe('number'); + }); + + it('should capture both stdout and stderr', async () => { + execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { + cb(null, 'out\n', 'err\n'); + return undefined as any; + }); + + const result = await service.execute('echo out && echo err >&2'); + expect(result.stdout.trim()).toBe('out'); + expect(result.stderr.trim()).toBe('err'); + }); + + it('should handle timeout (killed process)', async () => { + const error = Object.assign(new Error('Timed out'), { killed: true }); + execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { + cb(error, '', ''); + return undefined as any; + }); + + const result = await service.execute('sleep 999'); + expect(result.exitCode).toBe(124); + expect(result.error).toContain('timed out'); + }); + + it('should handle maxBuffer exceeded (killed process with ERR_CHILD_PROCESS_STDIO_MAXBUFFER)', async () => { + const error = Object.assign(new Error('maxBuffer'), { + killed: true, + code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', + }); + execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { + cb(error, 'partial output', ''); + return undefined as any; + }); + + const result = await service.execute('cat /dev/urandom'); + expect(result.exitCode).toBe(124); + expect(result.error).toContain('maximum buffer size'); + expect(result.stdout).toBe('partial output'); + }); + + it('should not surface redundant error.message for non-zero exit', async () => { + const error = Object.assign(new Error('Command failed: exit 1'), { code: 1 }); + execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { + cb(error, '', ''); + return undefined as any; + }); + + const result = await service.execute('exit 1'); + expect(result.exitCode).toBe(1); + expect(result.error).toBeUndefined(); + }); + + it('should handle null stdout/stderr gracefully', async () => { + execMock.mockImplementation((_cmd: any, _opts: any, cb: any) => { + cb(null, null, null); + return undefined as any; + }); + + const result = await service.execute('test'); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + expect(result.exitCode).toBe(0); + }); +}); diff --git a/tests/unit/features/chat/services/InstructionRefineService.test.ts b/tests/unit/features/chat/services/InstructionRefineService.test.ts new file mode 100644 index 0000000..c2f7947 --- /dev/null +++ b/tests/unit/features/chat/services/InstructionRefineService.test.ts @@ -0,0 +1,371 @@ +import '@/providers'; + +// eslint-disable-next-line jest/no-mocks-import +import { + getLastOptions, + resetMockMessages, + setMockMessages, +} from '@test/__mocks__/claude-agent-sdk'; + +// Import after mocks are set up +import { InstructionRefineService } from '@/providers/claude/auxiliary/ClaudeInstructionRefineService'; + +function createMockPlugin(settings = {}) { + return { + settings: { + model: 'sonnet', + thinkingBudget: 'off', + systemPrompt: '', + ...settings, + }, + app: { + vault: { + adapter: { + basePath: '/test/vault/path', + }, + }, + }, + getActiveEnvironmentVariables: jest.fn().mockReturnValue(''), + getResolvedProviderCliPath: jest.fn().mockReturnValue('/fake/claude'), + } as any; +} + +describe('InstructionRefineService', () => { + let service: InstructionRefineService; + let mockPlugin: any; + + beforeEach(() => { + jest.clearAllMocks(); + resetMockMessages(); + mockPlugin = createMockPlugin(); + service = new InstructionRefineService(mockPlugin); + }); + + describe('refineInstruction', () => { + it('should use no tools (text-only refinement)', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: '- Be concise.' }], + }, + }, + { type: 'result' }, + ]); + + const result = await service.refineInstruction('be concise', ''); + expect(result.success).toBe(true); + + const options = getLastOptions(); + expect(options?.tools).toEqual([]); + expect(options?.permissionMode).toBe('bypassPermissions'); + expect(options?.allowDangerouslySkipPermissions).toBe(true); + }); + + it('should set settingSources to project only when loadUserClaudeSettings is false', async () => { + mockPlugin.settings.loadUserClaudeSettings = false; + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: '- Be concise.' }], + }, + }, + { type: 'result' }, + ]); + + await service.refineInstruction('be concise', ''); + + const options = getLastOptions(); + expect(options?.settingSources).toEqual(['project', 'local']); + }); + + it('should set settingSources to include user when loadUserClaudeSettings is true', async () => { + mockPlugin.settings.loadUserClaudeSettings = true; + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: '- Be concise.' }], + }, + }, + { type: 'result' }, + ]); + + await service.refineInstruction('be concise', ''); + + const options = getLastOptions(); + expect(options?.settingSources).toEqual(['user', 'project', 'local']); + }); + + it('should include existing instructions and allow markdown blocks', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [ + { + type: 'text', + text: '\n## Coding Style\n\n- Use TypeScript.\n- Prefer small diffs.\n', + }, + ], + }, + }, + { type: 'result' }, + ]); + + const existing = '## Existing\n\n- Keep it short.'; + const result = await service.refineInstruction('coding style', existing); + + expect(result.success).toBe(true); + expect(result.refinedInstruction).toBe('## Coding Style\n\n- Use TypeScript.\n- Prefer small diffs.'); + + const options = getLastOptions(); + expect(options?.systemPrompt).toContain('EXISTING INSTRUCTIONS'); + expect(options?.systemPrompt).toContain(existing); + expect(options?.systemPrompt).toContain('Consider how it fits with existing instructions'); + expect(options?.systemPrompt).toContain('Match the format of existing instructions'); + }); + + it('should return clarification when no instruction tag in response', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Could you clarify what you mean by concise?' }], + }, + }, + { type: 'result' }, + ]); + + const result = await service.refineInstruction('be concise', ''); + expect(result.success).toBe(true); + expect(result.clarification).toBe('Could you clarify what you mean by concise?'); + expect(result.refinedInstruction).toBeUndefined(); + }); + + it('should return error for empty response', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: '' }], + }, + }, + { type: 'result' }, + ]); + + const result = await service.refineInstruction('be concise', ''); + expect(result.success).toBe(false); + expect(result.error).toBe('Empty response'); + }); + + it('should call onProgress during streaming', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: '- Be brief.' }], + }, + }, + { type: 'result' }, + ]); + + const onProgress = jest.fn(); + await service.refineInstruction('be concise', '', onProgress); + expect(onProgress).toHaveBeenCalled(); + }); + + it('should set adaptive thinking for Claude models', async () => { + mockPlugin.settings.model = 'sonnet'; + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'ok' }], + }, + }, + { type: 'result' }, + ]); + + await service.refineInstruction('test', ''); + const options = getLastOptions(); + expect(options?.thinking).toEqual({ type: 'adaptive' }); + expect(options?.maxThinkingTokens).toBeUndefined(); + }); + + it('should set adaptive thinking with effort for custom models', async () => { + mockPlugin.settings.model = 'custom-model'; + mockPlugin.settings.thinkingBudget = 'medium'; + mockPlugin.settings.effortLevel = 'medium'; + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'ok' }], + }, + }, + { type: 'result' }, + ]); + + await service.refineInstruction('test', ''); + const options = getLastOptions(); + expect(options?.thinking).toEqual({ type: 'adaptive' }); + expect(options?.effort).toBe('medium'); + expect(options?.maxThinkingTokens).toBeUndefined(); + }); + + it('should ignore non-text content blocks', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [ + { type: 'tool_use', name: 'test' }, + { type: 'text', text: 'result' }, + ], + }, + }, + { type: 'result' }, + ]); + + const result = await service.refineInstruction('test', ''); + expect(result.success).toBe(true); + expect(result.refinedInstruction).toBe('result'); + }); + + it('should skip messages without content', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { type: 'assistant' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'ok' }], + }, + }, + { type: 'result' }, + ]); + + const result = await service.refineInstruction('test', ''); + expect(result.success).toBe(true); + }); + }); + + describe('continueConversation', () => { + it('should return error when no active session', async () => { + const result = await service.continueConversation('follow up'); + expect(result.success).toBe(false); + expect(result.error).toBe('No active conversation to continue'); + }); + + it('should continue with session id after initial refinement', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'session-abc' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'What do you mean?' }], + }, + }, + { type: 'result' }, + ]); + + // First call establishes a session + await service.refineInstruction('test', ''); + + // Set up messages for the continuation + resetMockMessages(); + setMockMessages([ + { + type: 'assistant', + message: { + content: [{ type: 'text', text: '- Be concise and clear.' }], + }, + }, + { type: 'result' }, + ]); + + const result = await service.continueConversation('I mean short answers'); + expect(result.success).toBe(true); + expect(result.refinedInstruction).toBe('- Be concise and clear.'); + + const options = getLastOptions(); + expect(options?.resume).toBe('session-abc'); + }); + }); + + describe('resetConversation', () => { + it('should clear session so continueConversation fails', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'session-abc' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'clarification' }], + }, + }, + { type: 'result' }, + ]); + + await service.refineInstruction('test', ''); + service.resetConversation(); + + const result = await service.continueConversation('follow up'); + expect(result.success).toBe(false); + expect(result.error).toBe('No active conversation to continue'); + }); + }); + + describe('cancel', () => { + it('should abort the current request', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'ok' }], + }, + }, + { type: 'result' }, + ]); + + const promise = service.refineInstruction('test', ''); + service.cancel(); + const result = await promise; + expect(result).toBeDefined(); + }); + + it('should be safe to cancel when nothing is running', () => { + service.cancel(); + // Verify service is still usable after cancelling with no active request + expect(service).toBeDefined(); + }); + }); + + describe('error handling', () => { + it('should return error when vault path cannot be determined', async () => { + mockPlugin.app.vault.adapter.basePath = undefined; + const result = await service.refineInstruction('test', ''); + expect(result.success).toBe(false); + expect(result.error).toBe('Could not determine vault path'); + }); + + it('should return error when Claude CLI is not found', async () => { + mockPlugin.getResolvedProviderCliPath.mockReturnValue(null); + const result = await service.refineInstruction('test', ''); + expect(result.success).toBe(false); + expect(result.error).toBe('Claude CLI not found'); + }); + }); +}); diff --git a/tests/unit/features/chat/services/MentionCacheCoordinator.test.ts b/tests/unit/features/chat/services/MentionCacheCoordinator.test.ts new file mode 100644 index 0000000..4cb3ba8 --- /dev/null +++ b/tests/unit/features/chat/services/MentionCacheCoordinator.test.ts @@ -0,0 +1,35 @@ +import { MentionCacheCoordinator } from '@/features/chat/services/MentionCacheCoordinator'; + +function createRegistration() { + return { + fileContextManager: { + markFileCacheDirty: jest.fn(), + markFolderCacheDirty: jest.fn(), + }, + }; +} + +describe('MentionCacheCoordinator', () => { + it('marks every registered tab cache dirty for structural vault changes', () => { + const first = createRegistration(); + const second = createRegistration(); + const coordinator = new MentionCacheCoordinator(() => [first, second]); + + coordinator.markStructureDirty(); + + expect(first.fileContextManager.markFileCacheDirty).toHaveBeenCalledTimes(1); + expect(first.fileContextManager.markFolderCacheDirty).toHaveBeenCalledTimes(1); + expect(second.fileContextManager.markFileCacheDirty).toHaveBeenCalledTimes(1); + expect(second.fileContextManager.markFolderCacheDirty).toHaveBeenCalledTimes(1); + }); + + it('keeps folder caches intact for file content changes', () => { + const registration = createRegistration(); + const coordinator = new MentionCacheCoordinator(() => [registration]); + + coordinator.markFilesDirty(); + + expect(registration.fileContextManager.markFileCacheDirty).toHaveBeenCalledTimes(1); + expect(registration.fileContextManager.markFolderCacheDirty).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/features/chat/services/SubagentManager.test.ts b/tests/unit/features/chat/services/SubagentManager.test.ts new file mode 100644 index 0000000..64c6571 --- /dev/null +++ b/tests/unit/features/chat/services/SubagentManager.test.ts @@ -0,0 +1,1759 @@ +import '@/providers'; + +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import type { SubagentInfo, ToolCallInfo } from '@/core/types'; +import { SubagentManager } from '@/features/chat/services/SubagentManager'; +import { createStopSubagentHook } from '@/providers/claude/hooks/SubagentHooks'; + +jest.mock('@/features/chat/rendering/SubagentRenderer', () => ({ + createSubagentBlock: jest.fn().mockImplementation((_parentEl: any, toolId: string, input: any) => ({ + wrapperEl: { querySelector: jest.fn().mockReturnValue(null) }, + contentEl: {}, + info: { + id: toolId, + description: input?.description || 'Task', + prompt: input?.prompt || '', + mode: 'sync', + isExpanded: false, + status: 'running', + toolCalls: [], + }, + toolCallStates: new Map(), + })), + createAsyncSubagentBlock: jest.fn().mockImplementation((_parentEl: any, toolId: string, input: any) => ({ + wrapperEl: { querySelector: jest.fn().mockReturnValue(null) }, + info: { + id: toolId, + description: input?.description || 'Background task', + prompt: input?.prompt || '', + mode: 'async', + isExpanded: false, + status: 'running', + toolCalls: [], + asyncStatus: 'pending', + }, + statusEl: {}, + })), + addSubagentToolCall: jest.fn(), + updateSubagentToolResult: jest.fn(), + finalizeSubagentBlock: jest.fn(), + updateAsyncSubagentRunning: jest.fn(), + finalizeAsyncSubagent: jest.fn(), + markAsyncSubagentOrphaned: jest.fn(), +})); + +const createManager = () => { + const updates: SubagentInfo[] = []; + const manager = new SubagentManager((subagent) => { + updates.push({ ...subagent }); + }); + return { manager, updates }; +}; + +const createMockEl = () => ({ createDiv: jest.fn(), appendChild: jest.fn() } as any); + +describe('SubagentManager', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + // ============================================ + // Async Lifecycle Tests (migrated from AsyncSubagentManager) + // ============================================ + + describe('async lifecycle', () => { + it('transitions from pending to running when agent_id is parsed', () => { + const { manager, updates } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { description: 'Background', run_in_background: true }, parentEl); + expect(manager.getByTaskId('task-1')?.asyncStatus).toBe('pending'); + + manager.handleTaskToolResult('task-1', JSON.stringify({ agent_id: 'agent-123' })); + + const running = manager.getByTaskId('task-1'); + expect(running?.asyncStatus).toBe('running'); + expect(running?.agentId).toBe('agent-123'); + expect(updates[updates.length - 1].agentId).toBe('agent-123'); + expect(manager.isPendingAsyncTask('task-1')).toBe(false); + }); + + it('transitions from pending to running when agent_id exists only in toolUseResult', () => { + const { manager, updates } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-structured', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult( + 'task-structured', + 'Task launched', + false, + { data: { agent_id: 'agent-structured-1' } } + ); + + const running = manager.getByTaskId('task-structured'); + expect(running?.asyncStatus).toBe('running'); + expect(running?.agentId).toBe('agent-structured-1'); + expect(updates[updates.length - 1].agentId).toBe('agent-structured-1'); + expect(manager.isPendingAsyncTask('task-structured')).toBe(false); + }); + + it('transitions from pending to running when structured content carries agent_id', () => { + const { manager, updates } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-array', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult( + 'task-array', + [{ type: 'text', text: '{"agent_id":"agent-array-1"}' }] as any, + ); + + const running = manager.getByTaskId('task-array'); + expect(running?.asyncStatus).toBe('running'); + expect(running?.agentId).toBe('agent-array-1'); + expect(updates[updates.length - 1].agentId).toBe('agent-array-1'); + expect(manager.isPendingAsyncTask('task-array')).toBe(false); + }); + + it('moves to error when Task tool_result parsing fails', () => { + const { manager, updates } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-parse-fail', { description: 'No id', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-parse-fail', 'no agent id present'); + + expect(manager.getByTaskId('task-parse-fail')).toBeUndefined(); + const last = updates[updates.length - 1]; + expect(last.asyncStatus).toBe('error'); + expect(last.result).toContain('Failed to parse agent_id'); + }); + + it('moves to error when Task tool_result itself is an error', () => { + const { manager, updates } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-error', { description: 'Will fail', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-error', 'launch failed', true); + + expect(manager.getByTaskId('task-error')).toBeUndefined(); + const last = updates[updates.length - 1]; + expect(last.asyncStatus).toBe('error'); + expect(last.result).toBe('launch failed'); + }); + + it('stays running when AgentOutputTool reports not_ready', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-running', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-running', JSON.stringify({ agent_id: 'agent-abc' })); + + const toolCall: ToolCallInfo = { + id: 'output-not-ready', + name: 'AgentOutputTool', + input: { agent_id: 'agent-abc' }, + status: 'running', + isExpanded: false, + }; + manager.handleAgentOutputToolUse(toolCall); + + const stillRunning = manager.handleAgentOutputToolResult( + 'output-not-ready', + JSON.stringify({ retrieval_status: 'not_ready', agents: {} }), + false + ); + + expect(stillRunning?.asyncStatus).toBe('running'); + expect(manager.getByTaskId('task-running')?.asyncStatus).toBe('running'); + }); + + it('ignores unrelated tool_result when async subagent is active', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-standalone', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-standalone', JSON.stringify({ agent_id: 'agent-standalone' })); + + const unrelated = manager.handleAgentOutputToolResult( + 'non-agent-output', + 'regular tool output', + false + ); + + expect(unrelated).toBeUndefined(); + expect(manager.getByTaskId('task-standalone')?.asyncStatus).toBe('running'); + }); + + it('finalizes to completed when AgentOutputTool succeeds and extracts result', () => { + const { manager, updates } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-complete', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-complete', JSON.stringify({ agent_id: 'agent-complete' })); + + const toolCall: ToolCallInfo = { + id: 'output-success', + name: 'AgentOutputTool', + input: { agent_id: 'agent-complete' }, + status: 'running', + isExpanded: false, + }; + manager.handleAgentOutputToolUse(toolCall); + + const completed = manager.handleAgentOutputToolResult( + 'output-success', + JSON.stringify({ + retrieval_status: 'success', + agents: { 'agent-complete': { status: 'completed', result: 'done!' } }, + }), + false + ); + + expect(completed?.asyncStatus).toBe('completed'); + expect(completed?.result).toBe('done!'); + expect(updates[updates.length - 1].asyncStatus).toBe('completed'); + expect(manager.getByTaskId('task-complete')).toBeUndefined(); + }); + + it('finalizes to error when AgentOutputTool result has isError=true', () => { + const { manager, updates } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-err', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-err', JSON.stringify({ agent_id: 'agent-err' })); + + const toolCall: ToolCallInfo = { + id: 'output-err', + name: 'AgentOutputTool', + input: { agent_id: 'agent-err' }, + status: 'running', + isExpanded: false, + }; + manager.handleAgentOutputToolUse(toolCall); + + const errored = manager.handleAgentOutputToolResult( + 'output-err', + 'agent crashed', + true + ); + + expect(errored?.asyncStatus).toBe('error'); + expect(errored?.status).toBe('error'); + expect(errored?.result).toBe('agent crashed'); + expect(updates[updates.length - 1].asyncStatus).toBe('error'); + expect(manager.getByTaskId('task-err')).toBeUndefined(); + }); + + it('marks pending and running async subagents as orphaned', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('pending-task', { description: 'Pending task', run_in_background: true }, parentEl); + manager.handleTaskToolUse('running-task', { description: 'Running task', run_in_background: true }, parentEl); + manager.handleTaskToolResult('running-task', JSON.stringify({ agent_id: 'agent-running' })); + + const orphaned = manager.orphanAllActive(); + + expect(orphaned).toHaveLength(2); + orphaned.forEach((subagent) => { + expect(subagent.asyncStatus).toBe('orphaned'); + expect(subagent.result).toContain('Conversation ended'); + }); + expect(manager.getByTaskId('pending-task')).toBeUndefined(); + expect(manager.getByTaskId('running-task')).toBeUndefined(); + }); + + it('ignores Task results for unknown tasks', () => { + const { manager } = createManager(); + + manager.handleTaskToolResult('missing-task', 'agent_id: x'); + + expect(manager.getByTaskId('missing-task')).toBeUndefined(); + }); + + it('ignores AgentOutputTool when missing agentId', () => { + const { manager } = createManager(); + + manager.handleAgentOutputToolUse({ + id: 'output-1', + name: 'AgentOutputTool', + input: {}, + status: 'running', + isExpanded: false, + }); + + expect(manager.isLinkedAgentOutputTool('output-1')).toBe(false); + }); + + it('ignores AgentOutputTool when referencing unknown agent', () => { + const { manager } = createManager(); + + manager.handleAgentOutputToolUse({ + id: 'output-unknown', + name: 'AgentOutputTool', + input: { agent_id: 'agent-x' }, + status: 'running', + isExpanded: false, + }); + + expect(manager.isLinkedAgentOutputTool('output-unknown')).toBe(false); + }); + + it('handles TaskOutput with task_id parameter (SDK format)', () => { + const { manager, updates } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-sdk', { description: 'SDK test', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-sdk', JSON.stringify({ agent_id: 'agent-sdk-123' })); + + const toolCall: ToolCallInfo = { + id: 'taskoutput-1', + name: 'TaskOutput', + input: { task_id: 'agent-sdk-123' }, + status: 'running', + isExpanded: false, + }; + manager.handleAgentOutputToolUse(toolCall); + + expect(manager.isLinkedAgentOutputTool('taskoutput-1')).toBe(true); + + const completed = manager.handleAgentOutputToolResult( + 'taskoutput-1', + JSON.stringify({ + retrieval_status: 'success', + agents: { 'agent-sdk-123': { status: 'completed', result: 'task_id works!' } }, + }), + false + ); + + expect(completed?.asyncStatus).toBe('completed'); + expect(completed?.result).toBe('task_id works!'); + expect(updates[updates.length - 1].asyncStatus).toBe('completed'); + }); + + it('returns undefined on invalid AgentOutputTool state transition', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-done', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-done', JSON.stringify({ agent_id: 'agent-done' })); + + manager.handleAgentOutputToolUse({ + id: 'output-any', + name: 'AgentOutputTool', + input: { agent_id: 'agent-done' }, + status: 'running', + isExpanded: false, + }); + + // Manually mark completed to force invalid transition + const sub = manager.getByTaskId('task-done')!; + sub.asyncStatus = 'completed'; + + const res = manager.handleAgentOutputToolResult('output-any', '{"retrieval_status":"success"}', false); + expect(res).toBeUndefined(); + }); + + it('treats plain text not_ready as still running', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-plain', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-plain', JSON.stringify({ agent_id: 'agent-plain' })); + + const toolCall: ToolCallInfo = { + id: 'output-plain', + name: 'AgentOutputTool', + input: { agent_id: 'agent-plain' }, + status: 'running', + isExpanded: false, + }; + manager.handleAgentOutputToolUse(toolCall); + + const running = manager.handleAgentOutputToolResult('output-plain', 'not ready', false); + expect(running?.asyncStatus).toBe('running'); + }); + + it('treats XML-style status running as still running', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-xml', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-xml', JSON.stringify({ agent_id: 'agent-xml' })); + + const toolCall: ToolCallInfo = { + id: 'output-xml', + name: 'AgentOutputTool', + input: { agent_id: 'agent-xml' }, + status: 'running', + isExpanded: false, + }; + manager.handleAgentOutputToolUse(toolCall); + + const xmlResult = `not_ready +agent-xml +local_agent +running`; + + const running = manager.handleAgentOutputToolResult('output-xml', xmlResult, false); + expect(running?.asyncStatus).toBe('running'); + }); + + it('extracts first agent result when agentId is missing', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-first', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-first', JSON.stringify({ agent_id: 'agent-first' })); + + const toolCall: ToolCallInfo = { + id: 'output-first', + name: 'AgentOutputTool', + input: { agent_id: 'agent-first' }, + status: 'running', + isExpanded: false, + }; + manager.handleAgentOutputToolUse(toolCall); + + const completed = manager.handleAgentOutputToolResult( + 'output-first', + JSON.stringify({ retrieval_status: 'success', agents: { other: { status: 'completed', result: 'ok' } } }), + false + ); + + expect(completed?.result).toBe('ok'); + }); + + it('infers agentId from AgentOutputTool result when not linked', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-infer', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-infer', JSON.stringify({ agent_id: 'agent-infer' })); + + const result = JSON.stringify({ + retrieval_status: 'success', + agents: { 'agent-infer': { status: 'completed', result: 'ok' } }, + }); + + const completed = manager.handleAgentOutputToolResult('unlinked', result, false); + expect(completed?.asyncStatus).toBe('completed'); + expect(completed?.result).toBe('ok'); + }); + + it('gets running subagent by task id after transition', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-map', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-map', JSON.stringify({ agent_id: 'agent-map' })); + + expect(manager.getByTaskId('task-map')?.agentId).toBe('agent-map'); + }); + }); + + // ============================================ + // Async Parsing Edge Cases (via public API) + // ============================================ + + describe('async parsing edge cases', () => { + const setupLinkedAgentOutput = ( + manager: ReturnType['manager'], + taskId: string, + agentId: string, + outputToolId: string + ) => { + const parentEl = createMockEl(); + manager.handleTaskToolUse(taskId, { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult(taskId, JSON.stringify({ agent_id: agentId })); + manager.handleAgentOutputToolUse({ + id: outputToolId, + name: 'AgentOutputTool', + input: { agent_id: agentId }, + status: 'running', + isExpanded: false, + }); + }; + + // ---- still-running detection with envelope forms ---- + + it('stays running with array envelope containing not_ready', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); + + const arrayEnvelope = JSON.stringify([ + { text: JSON.stringify({ retrieval_status: 'not_ready', agents: {} }) }, + ]); + const result = manager.handleAgentOutputToolResult('out-1', arrayEnvelope, false); + expect(result?.asyncStatus).toBe('running'); + }); + + it('stays running with object envelope containing running status', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); + + const objectEnvelope = JSON.stringify({ + text: JSON.stringify({ retrieval_status: 'running', agents: {} }), + }); + const result = manager.handleAgentOutputToolResult('out-1', objectEnvelope, false); + expect(result?.asyncStatus).toBe('running'); + }); + + it('finalizes when result is whitespace-only', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); + + const result = manager.handleAgentOutputToolResult('out-1', ' ', false); + expect(result?.asyncStatus).toBe('completed'); + }); + + it('finalizes to error when isError is true regardless of content', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); + + const result = manager.handleAgentOutputToolResult('out-1', 'whatever', true); + expect(result?.asyncStatus).toBe('error'); + }); + + it('finalizes when retrieval_status is success without agents', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); + + const result = manager.handleAgentOutputToolResult( + 'out-1', + JSON.stringify({ retrieval_status: 'success' }), + false + ); + expect(result?.asyncStatus).toBe('completed'); + }); + + it('finalizes when retrieval_status is unknown', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); + + const result = manager.handleAgentOutputToolResult( + 'out-1', + JSON.stringify({ retrieval_status: 'unknown' }), + false + ); + expect(result?.asyncStatus).toBe('completed'); + }); + + it('marks error when toolUseResult has retrieval_status error', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); + + const toolUseResult = { + retrieval_status: 'error', + error: 'Agent process crashed', + }; + + const result = manager.handleAgentOutputToolResult( + 'out-1', + '{}', + false, + toolUseResult + ); + expect(result?.asyncStatus).toBe('error'); + expect(result?.result).toBe('Error: Agent process crashed'); + }); + + it('marks error when retrieval_status is error without error field', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); + + const toolUseResult = { + retrieval_status: 'error', + }; + + const result = manager.handleAgentOutputToolResult( + 'out-1', + '{}', + false, + toolUseResult + ); + expect(result?.asyncStatus).toBe('error'); + expect(result?.result).toBe('Error: Task retrieval failed'); + }); + + it('finalizes with plain text as result when no running indicators', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-1', 'out-1'); + + const result = manager.handleAgentOutputToolResult('out-1', 'plain output', false); + expect(result?.asyncStatus).toBe('completed'); + expect(result?.result).toBe('plain output'); + }); + + // ---- result extraction with envelope forms ---- + + it('extracts result from array envelope', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'a', 'out-1'); + + const payloadArray = JSON.stringify([ + { text: JSON.stringify({ retrieval_status: 'success', agents: { a: { result: 'R' } } }) }, + ]); + const result = manager.handleAgentOutputToolResult('out-1', payloadArray, false); + expect(result?.result).toBe('R'); + }); + + it('extracts result from object envelope', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'a', 'out-1'); + + const payloadObject = JSON.stringify({ + text: JSON.stringify({ retrieval_status: 'success', agents: { a: { status: 'completed' } } }), + }); + const result = manager.handleAgentOutputToolResult('out-1', payloadObject, false); + expect(result?.result).toContain('completed'); + }); + + it('extracts only final assistant result from XML output payload', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'a6ac482', 'out-1'); + + const outputLines = [ + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'I will search first.' }], + }, + }), + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'Grep', input: { pattern: 'martini' } }], + }, + }), + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Final answer: 24 matches across 6 files.' }], + }, + }), + ].join('\n'); + + const xmlPayload = `success +a6ac482 +completed + +${outputLines} +`; + + const result = manager.handleAgentOutputToolResult('out-1', xmlPayload, false); + expect(result?.result).toBe('Final answer: 24 matches across 6 files.'); + }); + + it('extracts final assistant result from structured toolUseResult.task.result', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-structured', 'out-1'); + + const outputLines = [ + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Intermediate step' }], + }, + }), + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Final summary from structured result.' }], + }, + }), + ].join('\n'); + + const structuredToolUseResult = { + retrieval_status: 'success', + task: { + task_id: 'agent-structured', + status: 'completed', + result: outputLines, + output: outputLines, + }, + }; + + const result = manager.handleAgentOutputToolResult( + 'out-1', + '{"retrieval_status":"success"}', + false, + structuredToolUseResult + ); + expect(result?.result).toBe('Final summary from structured result.'); + }); + + it('extracts full result from SDK toolUseResult.content array', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-sdk-content', 'out-1'); + + const fullResult = 'This is the full multi-line result.\n\nLine 2 of the result.\nLine 3 with details.'; + const sdkToolUseResult = { + status: 'completed', + content: [ + { type: 'text', text: fullResult }, + ], + agentId: 'agent-sdk-content', + prompt: 'Do something', + totalDurationMs: 5000, + totalTokens: 1000, + totalToolUseCount: 5, + }; + + const result = manager.handleAgentOutputToolResult( + 'out-1', + '{}', + false, + sdkToolUseResult + ); + expect(result?.result).toBe(fullResult); + }); + + it('extracts result from SDK content array with multiple text blocks', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-multi', 'out-1'); + + const sdkToolUseResult = { + status: 'completed', + content: [ + { type: 'text', text: 'Main result text here.' }, + { type: 'text', text: 'agentId: agent-multi\ntotal_tokens: 100' }, + ], + agentId: 'agent-multi', + }; + + const result = manager.handleAgentOutputToolResult( + 'out-1', + '{}', + false, + sdkToolUseResult + ); + // Should return the first text block (actual result), not the metadata block + expect(result?.result).toBe('Main result text here.'); + }); + + it('reads full output file when inline output is truncated', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-full-output', 'out-1'); + + const tempDir = mkdtempSync(join(tmpdir(), 'subagent-output-')); + const fullOutputFile = join(tempDir, 'agent.output'); + const fullOutput = [ + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Recovered final answer from full output file.' }], + }, + }), + ].join('\n'); + writeFileSync(fullOutputFile, fullOutput, 'utf-8'); + + const inlineOutput = `[Truncated. Full output: ${fullOutputFile}]`; + const xmlPayload = `success +agent-full-output +completed + +${inlineOutput} +`; + + try { + const result = manager.handleAgentOutputToolResult('out-1', xmlPayload, false); + expect(result?.result).toBe('Recovered final answer from full output file.'); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('ignores truncated full output files outside trusted temp roots', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-untrusted-output', 'out-1'); + + const homeDir = process.env.HOME ?? process.cwd(); + const untrustedDir = mkdtempSync(join(homeDir, '.claudian-untrusted-')); + const fullOutputFile = join(untrustedDir, 'agent-untrusted.output'); + const fullOutput = [ + JSON.stringify({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Should not be loaded from untrusted path.' }], + }, + }), + ].join('\n'); + writeFileSync(fullOutputFile, fullOutput, 'utf-8'); + + const inlineOutput = `[Truncated. Full output: ${fullOutputFile}]`; + const xmlPayload = `success +agent-untrusted-output +completed + +${inlineOutput} +`; + + try { + const result = manager.handleAgentOutputToolResult('out-1', xmlPayload, false); + expect(result?.result).toBe(inlineOutput); + } finally { + rmSync(untrustedDir, { recursive: true, force: true }); + } + }); + + it('extracts direct result tag when present', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-x', 'out-1'); + + const taggedPayload = `completed + +Only this is the final result. +`; + + const result = manager.handleAgentOutputToolResult('out-1', taggedPayload, false); + expect(result?.result).toBe('Only this is the final result.'); + }); + + it('falls back to first agent when agentId is missing from agents map', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-x', 'out-1'); + + const fallback = JSON.stringify({ + retrieval_status: 'success', + agents: { first: { status: 'completed' } }, + }); + const result = manager.handleAgentOutputToolResult('out-1', fallback, false); + expect(result?.result).toContain('completed'); + }); + + it('returns raw payload when no agents key is present', () => { + const { manager } = createManager(); + setupLinkedAgentOutput(manager, 'task-1', 'agent-x', 'out-1'); + + const noAgents = JSON.stringify({ foo: 'bar' }); + const result = manager.handleAgentOutputToolResult('out-1', noAgents, false); + expect(result?.result).toBe(noAgents); + }); + + // ---- agent ID parsing from multiple JSON shapes ---- + + it('parses camelCase agentId from task result', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + manager.handleTaskToolUse('task-1', { description: 'Bg', run_in_background: true }, parentEl); + + manager.handleTaskToolResult('task-1', JSON.stringify({ agentId: 'camel' })); + expect(manager.getByTaskId('task-1')).toBeDefined(); + expect(manager.getByTaskId('task-1')?.agentId).toBe('camel'); + }); + + it('parses nested data.agent_id from task result', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + manager.handleTaskToolUse('task-1', { description: 'Bg', run_in_background: true }, parentEl); + + manager.handleTaskToolResult('task-1', JSON.stringify({ data: { agent_id: 'nested' } })); + expect(manager.getByTaskId('task-1')?.agentId).toBe('nested'); + }); + + it('parses id field from task result', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + manager.handleTaskToolUse('task-1', { description: 'Bg', run_in_background: true }, parentEl); + + manager.handleTaskToolResult('task-1', JSON.stringify({ id: 'idfield' })); + expect(manager.getByTaskId('task-1')?.agentId).toBe('idfield'); + }); + + it('parses unicode-escaped agent_id from task result', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + manager.handleTaskToolUse('task-1', { description: 'Bg', run_in_background: true }, parentEl); + + manager.handleTaskToolResult('task-1', '{"agent\\u005fid":"escaped"}'); + expect(manager.getByTaskId('task-1')?.agentId).toBe('escaped'); + }); + + it('parses nested unicode-escaped agent_id from task result', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + manager.handleTaskToolUse('task-1', { description: 'Bg', run_in_background: true }, parentEl); + + manager.handleTaskToolResult('task-1', '{"data": {"agent\\u005fid": "nested2"}}'); + expect(manager.getByTaskId('task-1')?.agentId).toBe('nested2'); + }); + + it('transitions to error when no recognizable agent ID in task result', () => { + const { manager, updates } = createManager(); + const parentEl = createMockEl(); + manager.handleTaskToolUse('task-1', { description: 'Bg', run_in_background: true }, parentEl); + + manager.handleTaskToolResult('task-1', '{"foo": "bar"}'); + const last = updates[updates.length - 1]; + expect(last.asyncStatus).toBe('error'); + expect(last.result).toContain('Failed to parse agent_id'); + }); + }); + + // ============================================ + // Unified Task Entry Point + // ============================================ + + describe('handleTaskToolUse', () => { + it('buffers task in pendingTasks when currentContentEl is null', () => { + const { manager } = createManager(); + + const result = manager.handleTaskToolUse('task-1', { prompt: 'test' }, null); + expect(result.action).toBe('buffered'); + expect(manager.hasPendingTask('task-1')).toBe(true); + }); + + it('renders task buffered with null parentEl once contentEl becomes available', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + // First chunk: no content element + manager.handleTaskToolUse('task-1', { prompt: 'test' }, null); + expect(manager.hasPendingTask('task-1')).toBe(true); + + // Second chunk: content element available, run_in_background known + const result = manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); + expect(result.action).toBe('created_sync'); + expect(manager.hasPendingTask('task-1')).toBe(false); + }); + + it('returns created_sync for run_in_background=false', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + const result = manager.handleTaskToolUse( + 'task-sync', + { prompt: 'test', run_in_background: false }, + parentEl + ); + + expect(result.action).toBe('created_sync'); + expect((result as any).subagentState.info.id).toBe('task-sync'); + }); + + it('returns created_async for run_in_background=true', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + const result = manager.handleTaskToolUse( + 'task-async', + { description: 'Background', run_in_background: true }, + parentEl + ); + + expect(result.action).toBe('created_async'); + expect((result as any).info.id).toBe('task-async'); + expect((result as any).info.asyncStatus).toBe('pending'); + }); + + it('buffers task when run_in_background is missing', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + const result = manager.handleTaskToolUse( + 'task-unknown', + { prompt: 'test' }, + parentEl + ); + + expect(result.action).toBe('buffered'); + expect(manager.hasPendingTask('task-unknown')).toBe(true); + }); + + it('upgrades buffered task to async when run_in_background=true arrives later', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + const first = manager.handleTaskToolUse( + 'task-upgrade', + { prompt: 'test' }, + parentEl + ); + expect(first.action).toBe('buffered'); + expect(manager.hasPendingTask('task-upgrade')).toBe(true); + + const second = manager.handleTaskToolUse( + 'task-upgrade', + { run_in_background: true, description: 'Background' }, + parentEl + ); + + expect(second.action).toBe('created_async'); + expect((second as any).info.id).toBe('task-upgrade'); + expect(manager.hasPendingTask('task-upgrade')).toBe(false); + }); + + it('returns label_updated for already rendered sync subagent', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + // Create sync + manager.handleTaskToolUse('task-1', { run_in_background: false, description: 'Initial' }, parentEl); + + // Update input + const result = manager.handleTaskToolUse('task-1', { description: 'Updated' }, parentEl); + expect(result.action).toBe('label_updated'); + }); + + it('returns label_updated for already rendered async subagent', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + // Create async + manager.handleTaskToolUse('task-1', { run_in_background: true, description: 'Initial' }, parentEl); + + // Update input + const result = manager.handleTaskToolUse('task-1', { description: 'Updated' }, parentEl); + expect(result.action).toBe('label_updated'); + }); + + it('syncs async label update to canonical SubagentInfo', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { run_in_background: true, description: 'Initial' }, parentEl); + + // Canonical info should have initial description + expect(manager.getByTaskId('task-1')?.description).toBe('Initial'); + + // Update label via streaming input + manager.handleTaskToolUse('task-1', { description: 'Updated description' }, parentEl); + + // Canonical info should now reflect the update + expect(manager.getByTaskId('task-1')?.description).toBe('Updated description'); + }); + + it('propagates prompt updates in label update', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { run_in_background: true, description: 'Bg', prompt: 'initial' }, parentEl); + + // Update prompt via streaming input + manager.handleTaskToolUse('task-1', { prompt: 'full prompt text' }, parentEl); + + // Canonical info should have updated prompt + expect(manager.getByTaskId('task-1')?.prompt).toBe('full prompt text'); + }); + + it('merges buffered input and renders once content element becomes available', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + // First chunk without content target must be buffered. + manager.handleTaskToolUse('task-1', { description: 'Initial description' }, null); + expect(manager.hasPendingTask('task-1')).toBe(true); + + // Second chunk arrives with a content target, additional input, and confirmed mode. + const result = manager.handleTaskToolUse( + 'task-1', + { prompt: 'latest prompt', run_in_background: false }, + parentEl + ); + + expect(result.action).toBe('created_sync'); + expect((result as any).subagentState.info.description).toBe('Initial description'); + expect((result as any).subagentState.info.prompt).toBe('latest prompt'); + expect(manager.hasPendingTask('task-1')).toBe(false); + }); + + it('increments spawned count when creating sync task', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + expect(manager.subagentsSpawnedThisStream).toBe(0); + manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); + expect(manager.subagentsSpawnedThisStream).toBe(1); + }); + + it('increments spawned count when creating async task', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + expect(manager.subagentsSpawnedThisStream).toBe(0); + manager.handleTaskToolUse('task-1', { run_in_background: true }, parentEl); + expect(manager.subagentsSpawnedThisStream).toBe(1); + }); + }); + + // ============================================ + // Pending Task Resolution + // ============================================ + + describe('renderPendingTask', () => { + it('returns null for unknown tool id', () => { + const { manager } = createManager(); + + const result = manager.renderPendingTask('unknown'); + expect(result).toBeNull(); + }); + + it('renders buffered sync task and increments counter', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { prompt: 'test' }, null); + + const result = manager.renderPendingTask('task-1', parentEl); + expect(result).not.toBeNull(); + expect(result?.mode).toBe('sync'); + expect(manager.hasPendingTask('task-1')).toBe(false); + expect(manager.subagentsSpawnedThisStream).toBe(1); + }); + + it('returns null and keeps task pending when targetEl is null', () => { + const { manager } = createManager(); + + // Buffer with null parentEl (no content element) + manager.handleTaskToolUse('task-1', { prompt: 'test' }, null); + expect(manager.hasPendingTask('task-1')).toBe(true); + + // Try to render without override — both parentEl and override are null + const result = manager.renderPendingTask('task-1'); + expect(result).toBeNull(); + expect(manager.hasPendingTask('task-1')).toBe(true); + expect(manager.subagentsSpawnedThisStream).toBe(0); + }); + + it('renders buffered async task with parentEl override', () => { + const { manager } = createManager(); + const overrideEl = createMockEl(); + + // Buffer with null parentEl so the task stays pending despite run_in_background being known + manager.handleTaskToolUse('task-1', { prompt: 'test', run_in_background: true }, null); + expect(manager.hasPendingTask('task-1')).toBe(true); + + const result = manager.renderPendingTask('task-1', overrideEl); + expect(result).not.toBeNull(); + expect(result?.mode).toBe('async'); + }); + + it('does not increment spawned counter when rendering throws', () => { + const { createSubagentBlock } = jest.requireMock('@/features/chat/rendering/SubagentRenderer'); + createSubagentBlock.mockImplementationOnce(() => { throw new Error('DOM error'); }); + + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { prompt: 'test' }, null); + expect(manager.subagentsSpawnedThisStream).toBe(0); + + const result = manager.renderPendingTask('task-1', parentEl); + expect(result).toBeNull(); + expect(manager.subagentsSpawnedThisStream).toBe(0); + }); + }); + + describe('renderPendingTaskFromTaskResult', () => { + it('returns null for unknown tool id', () => { + const { manager } = createManager(); + const result = manager.renderPendingTaskFromTaskResult('unknown', 'ok', false); + expect(result).toBeNull(); + }); + + it('infers async from agent_id markers when mode is still unknown', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { prompt: 'test' }, parentEl); + const result = manager.renderPendingTaskFromTaskResult( + 'task-1', + '{"agent_id":"agent-123"}', + false + ); + + expect(result).not.toBeNull(); + expect(result?.mode).toBe('async'); + expect(manager.hasPendingTask('task-1')).toBe(false); + }); + + it('infers async from structured task-result content when mode is still unknown', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { prompt: 'test' }, parentEl); + const result = manager.renderPendingTaskFromTaskResult( + 'task-1', + [{ type: 'text', text: '{"agent_id":"agent-structured"}' }] as any, + false + ); + + expect(result).not.toBeNull(); + expect(result?.mode).toBe('async'); + expect(manager.hasPendingTask('task-1')).toBe(false); + }); + + it('falls back to sync when no async evidence is present', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { prompt: 'test' }, parentEl); + const result = manager.renderPendingTaskFromTaskResult( + 'task-1', + '{"foo":"bar"}', + false + ); + + expect(result).not.toBeNull(); + expect(result?.mode).toBe('sync'); + expect(manager.getSyncSubagent('task-1')).toBeDefined(); + }); + + it('honors explicit async mode from input even without task-result markers', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse( + 'task-1', + { prompt: 'test', run_in_background: true }, + null + ); + const result = manager.renderPendingTaskFromTaskResult( + 'task-1', + '{"foo":"bar"}', + false, + parentEl + ); + + expect(result).not.toBeNull(); + expect(result?.mode).toBe('async'); + }); + + it('infers async from toolUseResult markers when task-result text has no agent id', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { prompt: 'test' }, parentEl); + const result = manager.renderPendingTaskFromTaskResult( + 'task-1', + 'Launching task...', + false, + parentEl, + { + isAsync: true, + status: 'async_launched', + agentId: 'agent-xyz', + } + ); + + expect(result).not.toBeNull(); + expect(result?.mode).toBe('async'); + }); + + it('treats completed toolUseResult metadata with agentId as sync when mode is unknown', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { prompt: 'test' }, parentEl); + const result = manager.renderPendingTaskFromTaskResult( + 'task-1', + '{}', + false, + parentEl, + { + status: 'completed', + agentId: 'agent-sync', + content: [ + { type: 'text', text: 'Full sync result.' }, + { type: 'text', text: 'agentId: agent-sync' }, + ], + } + ); + + expect(result).not.toBeNull(); + expect(result?.mode).toBe('sync'); + expect(manager.getSyncSubagent('task-1')).toBeDefined(); + expect(manager.isPendingAsyncTask('task-1')).toBe(false); + expect(manager.hasRunningSubagents()).toBe(false); + }); + + it('treats stringified completed task metadata with agentId as sync when mode is unknown', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + const completedToolUseResult = { + status: 'completed', + agentId: 'agent-sync', + }; + + manager.handleTaskToolUse('task-1', { prompt: 'test' }, parentEl); + const result = manager.renderPendingTaskFromTaskResult( + 'task-1', + JSON.stringify(completedToolUseResult, null, 2), + false, + parentEl, + completedToolUseResult, + ); + + expect(result).not.toBeNull(); + expect(result?.mode).toBe('sync'); + expect(manager.getSyncSubagent('task-1')).toBeDefined(); + expect(manager.isPendingAsyncTask('task-1')).toBe(false); + expect(manager.hasRunningSubagents()).toBe(false); + }); + + it('treats sync result text with agentId metadata as sync when mode is unknown', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + const completedToolUseResult = { + status: 'completed', + agentId: 'agent-sync', + content: [ + { type: 'text', text: 'Full sync result.' }, + { type: 'text', text: 'agentId: agent-sync\ntotal_tokens: 500' }, + ], + }; + + manager.handleTaskToolUse('task-1', { prompt: 'test' }, parentEl); + const result = manager.renderPendingTaskFromTaskResult( + 'task-1', + 'Full sync result.\nagentId: agent-sync\ntotal_tokens: 500', + false, + parentEl, + completedToolUseResult, + ); + + expect(result).not.toBeNull(); + expect(result?.mode).toBe('sync'); + expect(manager.getSyncSubagent('task-1')).toBeDefined(); + expect(manager.isPendingAsyncTask('task-1')).toBe(false); + expect(manager.hasRunningSubagents()).toBe(false); + }); + + it('resolves to sync on errored task result when mode is unknown', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { prompt: 'test' }, parentEl); + const result = manager.renderPendingTaskFromTaskResult( + 'task-1', + '{"agent_id":"agent-123"}', + true + ); + + expect(result).not.toBeNull(); + expect(result?.mode).toBe('sync'); + }); + }); + + // ============================================ + // Sync Subagent Operations + // ============================================ + + describe('sync subagent operations', () => { + it('creates and retrieves sync subagent', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); + + const state = manager.getSyncSubagent('task-1'); + expect(state).toBeDefined(); + expect(state?.info.id).toBe('task-1'); + }); + + it('adds tool call to sync subagent', () => { + const { addSubagentToolCall } = jest.requireMock('@/features/chat/rendering/SubagentRenderer'); + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); + + const toolCall: ToolCallInfo = { + id: 'read-1', + name: 'Read', + input: { file_path: 'test.md' }, + status: 'running', + isExpanded: false, + }; + manager.addSyncToolCall('task-1', toolCall); + + expect(addSubagentToolCall).toHaveBeenCalled(); + }); + + it('updates tool result in sync subagent', () => { + const { updateSubagentToolResult } = jest.requireMock('@/features/chat/rendering/SubagentRenderer'); + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); + + const toolCall: ToolCallInfo = { + id: 'read-1', + name: 'Read', + input: {}, + status: 'completed', + isExpanded: false, + result: 'file content', + }; + manager.updateSyncToolResult('task-1', 'read-1', toolCall); + + expect(updateSubagentToolResult).toHaveBeenCalled(); + }); + + it('finalizes sync subagent and removes from map', () => { + const { finalizeSubagentBlock } = jest.requireMock('@/features/chat/rendering/SubagentRenderer'); + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); + + const info = manager.finalizeSyncSubagent('task-1', 'done', false); + + expect(info).not.toBeNull(); + expect(info?.id).toBe('task-1'); + expect(finalizeSubagentBlock).toHaveBeenCalled(); + expect(manager.getSyncSubagent('task-1')).toBeUndefined(); + }); + + it('extracts result from SDK toolUseResult.content for sync subagent', () => { + const { finalizeSubagentBlock } = jest.requireMock('@/features/chat/rendering/SubagentRenderer'); + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-sdk', { run_in_background: false }, parentEl); + + const sdkToolUseResult = { + status: 'completed', + content: [ + { type: 'text', text: 'Full sync subagent result with multiple lines.\n\nSecond paragraph.' }, + { type: 'text', text: 'agentId: agent-sync\ntotal_tokens: 500' }, + ], + agentId: 'agent-sync', + }; + + const info = manager.finalizeSyncSubagent('task-sdk', '{}', false, sdkToolUseResult); + + expect(info).not.toBeNull(); + // Verify the extracted result (first content block) was passed to the renderer + expect(finalizeSubagentBlock).toHaveBeenCalledWith( + expect.anything(), + 'Full sync subagent result with multiple lines.\n\nSecond paragraph.', + false + ); + }); + + it('returns null when finalizing nonexistent subagent', () => { + const { manager } = createManager(); + + const info = manager.finalizeSyncSubagent('nonexistent', 'done', false); + expect(info).toBeNull(); + }); + + it('ignores tool call for nonexistent subagent', () => { + const { addSubagentToolCall } = jest.requireMock('@/features/chat/rendering/SubagentRenderer'); + const { manager } = createManager(); + + manager.addSyncToolCall('nonexistent', { + id: 'tc-1', + name: 'Read', + input: {}, + status: 'running', + isExpanded: false, + }); + + expect(addSubagentToolCall).not.toHaveBeenCalled(); + }); + }); + + // ============================================ + // Async Error Resolution (resolveAsyncError) + // ============================================ + + describe('resolveAsyncError via handleAgentOutputToolResult', () => { + function setupRunningSubagent(manager: SubagentManager) { + const parentEl = createMockEl(); + manager.handleTaskToolUse('task-1', { description: 'BG', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-1', JSON.stringify({ agent_id: 'agent-1' })); + manager.handleAgentOutputToolUse({ + id: 'output-1', name: 'AgentOutput', + input: { task_id: 'agent-1' }, status: 'running', isExpanded: false, + }); + } + + it('marks completed when toolUseResult.status is "completed" even if chunk isError is true', () => { + const { manager, updates } = createManager(); + setupRunningSubagent(manager); + + manager.handleAgentOutputToolResult( + 'output-1', 'result text', true, + { status: 'completed', content: [{ type: 'text', text: 'Done' }] } + ); + + const final = updates[updates.length - 1]; + expect(final.asyncStatus).toBe('completed'); + expect(final.status).toBe('completed'); + }); + + it('marks completed when toolUseResult.retrieval_status is "success" even if chunk isError is true', () => { + const { manager, updates } = createManager(); + setupRunningSubagent(manager); + + manager.handleAgentOutputToolResult( + 'output-1', 'result text', true, + { retrieval_status: 'success', result: 'All good' } + ); + + const final = updates[updates.length - 1]; + expect(final.asyncStatus).toBe('completed'); + }); + + it('marks error when toolUseResult.retrieval_status is "error" even if chunk isError is false', () => { + const { manager, updates } = createManager(); + setupRunningSubagent(manager); + + manager.handleAgentOutputToolResult( + 'output-1', 'result text', false, + { retrieval_status: 'error', error: 'Agent crashed' } + ); + + const final = updates[updates.length - 1]; + expect(final.asyncStatus).toBe('error'); + }); + + it('falls back to chunk isError when toolUseResult has no status fields', () => { + const { manager, updates } = createManager(); + setupRunningSubagent(manager); + + manager.handleAgentOutputToolResult('output-1', 'result text', true, { foo: 'bar' }); + + const final = updates[updates.length - 1]; + expect(final.asyncStatus).toBe('error'); + }); + + it('falls back to chunk isError when no toolUseResult is provided', () => { + const { manager, updates } = createManager(); + setupRunningSubagent(manager); + + manager.handleAgentOutputToolResult('output-1', 'result text', false); + + const final = updates[updates.length - 1]; + expect(final.asyncStatus).toBe('completed'); + }); + }); + + // ============================================ + // Hook Delivery Methods + // ============================================ + + describe('hook delivery', () => { + describe('hasRunningSubagents', () => { + it('returns false when no subagents exist', () => { + const { manager } = createManager(); + expect(manager.hasRunningSubagents()).toBe(false); + }); + + it('returns true when pending async subagents exist', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + manager.handleTaskToolUse('task-1', { description: 'Background', run_in_background: true }, parentEl); + + expect(manager.hasRunningSubagents()).toBe(true); + }); + + it('returns true when active running subagents exist', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + manager.handleTaskToolUse('task-1', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-1', JSON.stringify({ agent_id: 'agent-123' })); + + expect(manager.hasRunningSubagents()).toBe(true); + }); + + it('returns false when all subagents have completed', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + manager.handleTaskToolUse('task-1', { description: 'Task agent-123', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-1', JSON.stringify({ agent_id: 'agent-123' })); + manager.handleAgentOutputToolUse({ + id: 'output-agent-123', + name: 'AgentOutput', + input: { agent_id: 'agent-123' }, + status: 'running', + isExpanded: false, + }); + manager.handleAgentOutputToolResult( + 'output-agent-123', + JSON.stringify({ result: 'Result from agent-123' }), + false + ); + + expect(manager.hasRunningSubagents()).toBe(false); + }); + + it('returns false after a live task notification completes an active async subagent', () => { + const { manager, updates } = createManager(); + const parentEl = createMockEl(); + manager.handleTaskToolUse('task-1', { description: 'Background', run_in_background: true }, parentEl); + manager.handleTaskToolResult('task-1', JSON.stringify({ agent_id: 'agent-123' })); + + expect(manager.hasRunningSubagents()).toBe(true); + + const completed = manager.handleAsyncSubagentResult( + 'agent-123', + 'completed', + 'Background agent finished.' + ); + + expect(completed?.asyncStatus).toBe('completed'); + expect(completed?.status).toBe('completed'); + expect(completed?.result).toBe('Background agent finished.'); + expect(manager.hasRunningSubagents()).toBe(false); + expect(updates[updates.length - 1]).toEqual( + expect.objectContaining({ + agentId: 'agent-123', + asyncStatus: 'completed', + result: 'Background agent finished.', + }) + ); + }); + + it('returns false after parallel foreground tasks resolve from completed task results', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + const syncToolUseResult1 = { + status: 'completed', + agentId: 'agent-sync-1', + content: [{ type: 'text', text: 'Foreground result 1.' }], + }; + const syncToolUseResult2 = { + status: 'completed', + agentId: 'agent-sync-2', + content: [{ type: 'text', text: 'Foreground result 2.' }], + }; + + manager.handleTaskToolUse('task-1', { prompt: 'Foreground 1' }, parentEl); + manager.handleTaskToolUse('task-2', { prompt: 'Foreground 2' }, parentEl); + + const first = manager.renderPendingTaskFromTaskResult( + 'task-1', + '{}', + false, + parentEl, + syncToolUseResult1 + ); + const second = manager.renderPendingTaskFromTaskResult( + 'task-2', + '{}', + false, + parentEl, + syncToolUseResult2 + ); + + expect(first?.mode).toBe('sync'); + expect(second?.mode).toBe('sync'); + + manager.finalizeSyncSubagent('task-1', '{}', false, syncToolUseResult1); + manager.finalizeSyncSubagent('task-2', '{}', false, syncToolUseResult2); + + expect(manager.hasRunningSubagents()).toBe(false); + }); + + it('allows the Stop hook after a foreground task resolves from completed task metadata', async () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + const completedToolUseResult = { + status: 'completed', + agentId: 'agent-sync', + }; + const hook = createStopSubagentHook(() => ({ + hasRunning: manager.hasRunningSubagents(), + })); + + manager.handleTaskToolUse('task-1', { prompt: 'Foreground task' }, parentEl); + const rendered = manager.renderPendingTaskFromTaskResult( + 'task-1', + JSON.stringify(completedToolUseResult, null, 2), + false, + parentEl, + completedToolUseResult, + ); + + expect(rendered?.mode).toBe('sync'); + + manager.finalizeSyncSubagent('task-1', '{}', false, completedToolUseResult); + + const stopResult = await hook.hooks[0]( + { + hook_event_name: 'Stop', + session_id: 'test-session', + transcript_path: '/tmp/transcript', + cwd: '/vault', + stop_hook_active: true, + }, + undefined, + { signal: new AbortController().signal } + ); + + expect(stopResult).toEqual({}); + }); + }); + }); + + // ============================================ + // Lifecycle + // ============================================ + + describe('lifecycle', () => { + it('resets spawned count', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-1', { run_in_background: false }, parentEl); + expect(manager.subagentsSpawnedThisStream).toBe(1); + + manager.resetSpawnedCount(); + expect(manager.subagentsSpawnedThisStream).toBe(0); + }); + + it('resets streaming state clears sync maps and pending tasks', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-sync', { run_in_background: false }, parentEl); + manager.handleTaskToolUse('task-pending', { prompt: 'test' }, null); + + expect(manager.getSyncSubagent('task-sync')).toBeDefined(); + expect(manager.hasPendingTask('task-pending')).toBe(true); + + manager.resetStreamingState(); + + expect(manager.getSyncSubagent('task-sync')).toBeUndefined(); + expect(manager.hasPendingTask('task-pending')).toBe(false); + }); + + it('clears all state', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + + manager.handleTaskToolUse('task-async', { description: 'Background', run_in_background: true }, parentEl); + + manager.clear(); + expect(manager.getByTaskId('task-async')).toBeUndefined(); + expect(manager.isPendingAsyncTask('task-async')).toBe(false); + }); + + it('updates callback via setCallback', () => { + const { manager } = createManager(); + const parentEl = createMockEl(); + const newUpdates: SubagentInfo[] = []; + + manager.handleTaskToolUse('task-1', { description: 'Background', run_in_background: true }, parentEl); + manager.setCallback((subagent) => { newUpdates.push({ ...subagent }); }); + + manager.handleTaskToolResult('task-1', JSON.stringify({ agent_id: 'agent-new' })); + + expect(newUpdates.length).toBeGreaterThan(0); + expect(newUpdates[newUpdates.length - 1].agentId).toBe('agent-new'); + }); + }); +}); diff --git a/tests/unit/features/chat/services/TitleGenerationService.test.ts b/tests/unit/features/chat/services/TitleGenerationService.test.ts new file mode 100644 index 0000000..0987103 --- /dev/null +++ b/tests/unit/features/chat/services/TitleGenerationService.test.ts @@ -0,0 +1,480 @@ +import '@/providers'; + +// eslint-disable-next-line jest/no-mocks-import +import { + getLastOptions, + resetMockMessages, + setMockMessages, +} from '@test/__mocks__/claude-agent-sdk'; + +import { type TitleGenerationResult, TitleGenerationService } from '@/providers/claude/auxiliary/ClaudeTitleGenerationService'; +function createMockPlugin(settings = {}) { + return { + settings: { + model: 'sonnet', + titleGenerationModel: '', + thinkingBudget: 'off', + ...settings, + }, + app: { + vault: { + adapter: { + basePath: '/test/vault/path', + }, + }, + }, + getActiveEnvironmentVariables: jest.fn().mockReturnValue(''), + getResolvedProviderCliPath: jest.fn().mockReturnValue('/fake/claude'), + } as any; +} + +describe('TitleGenerationService', () => { + let service: TitleGenerationService; + let mockPlugin: any; + + beforeEach(() => { + jest.clearAllMocks(); + resetMockMessages(); + mockPlugin = createMockPlugin(); + service = new TitleGenerationService(mockPlugin); + }); + + describe('generateTitle', () => { + it('should generate a title from user message', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Setting Up React Project' }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + await service.generateTitle( + 'conv-123', + 'How do I set up a React project?', + callback + ); + + expect(callback).toHaveBeenCalledWith('conv-123', { + success: true, + title: 'Setting Up React Project', + }); + }); + + it('should use no tools for title generation', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Test Title' }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + await service.generateTitle('conv-123', 'test', callback); + + const options = getLastOptions(); + expect(options?.tools).toEqual([]); + expect(options?.permissionMode).toBe('bypassPermissions'); + }); + + it('should use titleGenerationModel setting when set', async () => { + mockPlugin.settings.titleGenerationModel = 'opus'; + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Title' }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + await service.generateTitle('conv-123', 'test', callback); + + const options = getLastOptions(); + expect(options?.model).toBe('opus'); + }); + + it('should prioritize setting over env var', async () => { + mockPlugin.settings.titleGenerationModel = 'sonnet'; + mockPlugin.getActiveEnvironmentVariables.mockReturnValue( + 'ANTHROPIC_DEFAULT_HAIKU_MODEL=custom-haiku' + ); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Title' }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + await service.generateTitle('conv-123', 'test', callback); + + const options = getLastOptions(); + expect(options?.model).toBe('sonnet'); + }); + + it('should use ANTHROPIC_DEFAULT_HAIKU_MODEL when setting is empty', async () => { + mockPlugin.settings.titleGenerationModel = ''; + mockPlugin.getActiveEnvironmentVariables.mockReturnValue( + 'ANTHROPIC_DEFAULT_HAIKU_MODEL=custom-haiku' + ); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Title' }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + await service.generateTitle('conv-123', 'test', callback); + + const options = getLastOptions(); + expect(options?.model).toBe('custom-haiku'); + }); + + it('should fallback to claude-haiku-4-5 model', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Title' }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + await service.generateTitle('conv-123', 'test', callback); + + const options = getLastOptions(); + expect(options?.model).toBe('claude-haiku-4-5'); + }); + + it('should strip surrounding quotes from title', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: '"Quoted Title"' }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + await service.generateTitle('conv-123', 'test', callback); + + expect(callback).toHaveBeenCalledWith('conv-123', { + success: true, + title: 'Quoted Title', + }); + }); + + it('should strip trailing punctuation from title', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Title With Punctuation...' }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + await service.generateTitle('conv-123', 'test', callback); + + expect(callback).toHaveBeenCalledWith('conv-123', { + success: true, + title: 'Title With Punctuation', + }); + }); + + it('should truncate titles longer than 50 characters', async () => { + const longTitle = 'A'.repeat(60); + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: longTitle }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + await service.generateTitle('conv-123', 'test', callback); + + expect(callback).toHaveBeenCalledWith('conv-123', { + success: true, + title: 'A'.repeat(47) + '...', + }); + }); + + it('should fail gracefully when response is empty', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: '' }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + await service.generateTitle('conv-123', 'test', callback); + + expect(callback).toHaveBeenCalledWith('conv-123', { + success: false, + error: 'Failed to parse title from response', + }); + }); + + it('should fail when vault path cannot be determined', async () => { + mockPlugin.app.vault.adapter.basePath = undefined; + + const callback = jest.fn(); + await service.generateTitle('conv-123', 'test', callback); + + expect(callback).toHaveBeenCalledWith('conv-123', { + success: false, + error: 'Could not determine vault path', + }); + }); + + it('should fail when Claude CLI is not found', async () => { + mockPlugin.getResolvedProviderCliPath.mockReturnValue(null); + + const callback = jest.fn(); + await service.generateTitle('conv-123', 'test', callback); + + expect(callback).toHaveBeenCalledWith('conv-123', { + success: false, + error: 'Claude CLI not found', + }); + }); + + it('should set settingSources to project only when loadUserClaudeSettings is false', async () => { + mockPlugin.settings.loadUserClaudeSettings = false; + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Title' }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + await service.generateTitle('conv-123', 'test', callback); + + const options = getLastOptions(); + expect(options?.settingSources).toEqual(['project', 'local']); + }); + + it('should set settingSources to include user when loadUserClaudeSettings is true', async () => { + mockPlugin.settings.loadUserClaudeSettings = true; + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Title' }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + await service.generateTitle('conv-123', 'test', callback); + + const options = getLastOptions(); + expect(options?.settingSources).toEqual(['user', 'project', 'local']); + }); + + it('should truncate long user messages', async () => { + const longMessage = 'x'.repeat(1000); + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Title' }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + await service.generateTitle('conv-123', longMessage, callback); + + // Service should still complete successfully with truncated message + expect(callback).toHaveBeenCalledWith('conv-123', { + success: true, + title: 'Title', + }); + }); + }); + + describe('concurrent generation', () => { + it('should support multiple concurrent generations', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Title' }], + }, + }, + { type: 'result' }, + ]); + + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + // Start two generations concurrently + const promise1 = service.generateTitle('conv-1', 'msg1', callback1); + const promise2 = service.generateTitle('conv-2', 'msg2', callback2); + + await Promise.all([promise1, promise2]); + + expect(callback1).toHaveBeenCalledWith('conv-1', { success: true, title: 'Title' }); + expect(callback2).toHaveBeenCalledWith('conv-2', { success: true, title: 'Title' }); + }); + + it('should cancel previous generation for same conversation', async () => { + // First call will be aborted when second call starts + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + // Mock a slow first generation + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Title 1' }], + }, + }, + { type: 'result' }, + ]); + + // Start first generation (won't await it) + const promise1 = service.generateTitle('conv-1', 'msg1', callback1); + + // Immediately start second generation for same conversation + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session-2' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Title 2' }], + }, + }, + { type: 'result' }, + ]); + const promise2 = service.generateTitle('conv-1', 'msg2', callback2); + + await Promise.all([promise1, promise2]); + + // Second generation should complete with new title + expect(callback2).toHaveBeenCalledWith('conv-1', { success: true, title: 'Title 2' }); + }); + }); + + describe('cancel', () => { + it('should cancel all active generations', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Title' }], + }, + }, + { type: 'result' }, + ]); + + const callback = jest.fn(); + + // Start generation then cancel immediately + const promise = service.generateTitle('conv-1', 'msg', callback); + service.cancel(); + + await promise; + + // Should have been called with cancelled error or completed + expect(callback).toHaveBeenCalled(); + }); + }); + + describe('safeCallback', () => { + it('should catch errors thrown by callback', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Title' }], + }, + }, + { type: 'result' }, + ]); + + const throwingCallback = jest.fn().mockRejectedValue(new Error('Callback error')); + + // Should not throw + await expect( + service.generateTitle('conv-123', 'test', throwingCallback) + ).resolves.not.toThrow(); + }); + }); +}); + +describe('TitleGenerationResult type', () => { + it('should be a discriminated union for success', () => { + const success: TitleGenerationResult = { success: true, title: 'Test Title' }; + expect(success.success).toBe(true); + // TypeScript narrows the type based on success: true + expect(success).toEqual({ success: true, title: 'Test Title' }); + }); + + it('should be a discriminated union for failure', () => { + const failure: TitleGenerationResult = { success: false, error: 'Some error' }; + expect(failure.success).toBe(false); + // TypeScript narrows the type based on success: false + expect(failure).toEqual({ success: false, error: 'Some error' }); + }); +}); diff --git a/tests/unit/features/chat/state/ChatState.test.ts b/tests/unit/features/chat/state/ChatState.test.ts new file mode 100644 index 0000000..207a47f --- /dev/null +++ b/tests/unit/features/chat/state/ChatState.test.ts @@ -0,0 +1,581 @@ +import { ChatState, createInitialState } from '@/features/chat/state/ChatState'; +import type { ChatStateCallbacks } from '@/features/chat/state/types'; + +describe('ChatState', () => { + const originalWindow = (globalThis as { window?: Window }).window; + + beforeAll(() => { + const testWindow = { + setTimeout: (callback: () => void, timeout: number): number => + globalThis.setTimeout(callback, timeout) as unknown as number, + clearTimeout: (handle: number): void => { + globalThis.clearTimeout(handle as unknown as ReturnType); + }, + setInterval: (callback: () => void, timeout: number): number => + globalThis.setInterval(callback, timeout) as unknown as number, + clearInterval: (handle: number): void => { + globalThis.clearInterval(handle as unknown as ReturnType); + }, + } as Window; + + Object.defineProperty(globalThis, 'window', { + value: testWindow, + configurable: true, + }); + }); + + afterAll(() => { + if (originalWindow === undefined) { + delete (globalThis as { window?: Window }).window; + return; + } + + Object.defineProperty(globalThis, 'window', { + value: originalWindow, + configurable: true, + }); + }); + + describe('createInitialState', () => { + it('returns correct default values', () => { + const state = createInitialState(); + + expect(state.messages).toEqual([]); + expect(state.isStreaming).toBe(false); + expect(state.cancelRequested).toBe(false); + expect(state.streamGeneration).toBe(0); + expect(state.isCreatingConversation).toBe(false); + expect(state.isSwitchingConversation).toBe(false); + expect(state.currentConversationId).toBeNull(); + expect(state.queuedMessage).toBeNull(); + expect(state.currentContentEl).toBeNull(); + expect(state.currentTextEl).toBeNull(); + expect(state.currentTextContent).toBe(''); + expect(state.currentThinkingState).toBeNull(); + expect(state.thinkingEl).toBeNull(); + expect(state.queueIndicatorEl).toBeNull(); + expect(state.thinkingIndicatorTimeout).toBeNull(); + expect(state.toolCallElements).toBeInstanceOf(Map); + expect(state.writeEditStates).toBeInstanceOf(Map); + expect(state.pendingTools).toBeInstanceOf(Map); + expect(state.usage).toBeNull(); + expect(state.ignoreUsageUpdates).toBe(false); + expect(state.currentTodos).toBeNull(); + expect(state.needsAttention).toBe(false); + expect(state.autoScrollEnabled).toBe(true); + expect(state.responseStartTime).toBeNull(); + expect(state.flavorTimerInterval).toBeNull(); + }); + }); + + describe('messages', () => { + it('returns a copy of messages', () => { + const chatState = new ChatState(); + const msg = { id: '1', role: 'user' as const, content: 'hi', timestamp: 1 }; + chatState.addMessage(msg); + + const msgs = chatState.messages; + msgs.push({ id: '2', role: 'user' as const, content: 'bye', timestamp: 2 }); + + expect(chatState.messages).toHaveLength(1); + }); + + it('fires onMessagesChanged when setting messages', () => { + const onMessagesChanged = jest.fn(); + const chatState = new ChatState({ onMessagesChanged }); + + chatState.messages = [{ id: '1', role: 'user', content: 'hi', timestamp: 1 }]; + + expect(onMessagesChanged).toHaveBeenCalledTimes(1); + }); + + it('fires onMessagesChanged when adding a message', () => { + const onMessagesChanged = jest.fn(); + const chatState = new ChatState({ onMessagesChanged }); + + chatState.addMessage({ id: '1', role: 'user', content: 'hi', timestamp: 1 }); + + expect(onMessagesChanged).toHaveBeenCalledTimes(1); + }); + + it('fires onMessagesChanged when clearing messages', () => { + const onMessagesChanged = jest.fn(); + const chatState = new ChatState({ onMessagesChanged }); + chatState.addMessage({ id: '1', role: 'user', content: 'hi', timestamp: 1 }); + + chatState.clearMessages(); + + expect(chatState.messages).toHaveLength(0); + expect(onMessagesChanged).toHaveBeenCalledTimes(2); // once for add, once for clear + }); + }); + + describe('streaming control', () => { + it('fires onStreamingStateChanged when isStreaming changes', () => { + const onStreamingStateChanged = jest.fn(); + const chatState = new ChatState({ onStreamingStateChanged }); + + chatState.isStreaming = true; + + expect(onStreamingStateChanged).toHaveBeenCalledWith(true); + }); + + it('bumpStreamGeneration increments and returns the new value', () => { + const chatState = new ChatState(); + + expect(chatState.streamGeneration).toBe(0); + const gen1 = chatState.bumpStreamGeneration(); + expect(gen1).toBe(1); + expect(chatState.streamGeneration).toBe(1); + + const gen2 = chatState.bumpStreamGeneration(); + expect(gen2).toBe(2); + }); + + it('stores cancelRequested', () => { + const chatState = new ChatState(); + chatState.cancelRequested = true; + expect(chatState.cancelRequested).toBe(true); + }); + + it('stores isCreatingConversation', () => { + const chatState = new ChatState(); + chatState.isCreatingConversation = true; + expect(chatState.isCreatingConversation).toBe(true); + }); + + it('stores isSwitchingConversation', () => { + const chatState = new ChatState(); + chatState.isSwitchingConversation = true; + expect(chatState.isSwitchingConversation).toBe(true); + }); + }); + + describe('conversation', () => { + it('fires onConversationChanged when currentConversationId changes', () => { + const onConversationChanged = jest.fn(); + const chatState = new ChatState({ onConversationChanged }); + + chatState.currentConversationId = 'conv-1'; + + expect(onConversationChanged).toHaveBeenCalledWith('conv-1'); + }); + + it('fires onConversationChanged with null', () => { + const onConversationChanged = jest.fn(); + const chatState = new ChatState({ onConversationChanged }); + chatState.currentConversationId = 'conv-1'; + + chatState.currentConversationId = null; + + expect(onConversationChanged).toHaveBeenCalledWith(null); + }); + }); + + describe('queued message', () => { + it('stores and retrieves queued message', () => { + const chatState = new ChatState(); + const queued = { content: 'queued', editorContext: null, canvasContext: null }; + + chatState.queuedMessage = queued; + + expect(chatState.queuedMessage).toBe(queued); + }); + }); + + describe('streaming DOM state', () => { + it('stores currentContentEl', () => { + const chatState = new ChatState(); + const el = {} as HTMLElement; + chatState.currentContentEl = el; + expect(chatState.currentContentEl).toBe(el); + }); + + it('stores currentTextEl', () => { + const chatState = new ChatState(); + const el = {} as HTMLElement; + chatState.currentTextEl = el; + expect(chatState.currentTextEl).toBe(el); + }); + + it('stores currentTextContent', () => { + const chatState = new ChatState(); + chatState.currentTextContent = 'hello'; + expect(chatState.currentTextContent).toBe('hello'); + }); + + it('stores currentThinkingState', () => { + const chatState = new ChatState(); + const state = { content: 'thinking' } as any; + chatState.currentThinkingState = state; + expect(chatState.currentThinkingState).toBe(state); + }); + + it('stores thinkingEl', () => { + const chatState = new ChatState(); + const el = {} as HTMLElement; + chatState.thinkingEl = el; + expect(chatState.thinkingEl).toBe(el); + }); + + it('stores queueIndicatorEl', () => { + const chatState = new ChatState(); + const el = {} as HTMLElement; + chatState.queueIndicatorEl = el; + expect(chatState.queueIndicatorEl).toBe(el); + }); + + it('stores thinkingIndicatorTimeout', () => { + const chatState = new ChatState(); + const timeout = window.setTimeout(() => {}, 100); + chatState.thinkingIndicatorTimeout = timeout; + expect(chatState.thinkingIndicatorTimeout).toBe(timeout); + window.clearTimeout(timeout); + }); + }); + + describe('tool tracking maps', () => { + it('returns mutable toolCallElements map', () => { + const chatState = new ChatState(); + const el = {} as HTMLElement; + chatState.toolCallElements.set('tool-1', el); + expect(chatState.toolCallElements.get('tool-1')).toBe(el); + }); + + it('returns mutable writeEditStates map', () => { + const chatState = new ChatState(); + const state = {} as any; + chatState.writeEditStates.set('we-1', state); + expect(chatState.writeEditStates.get('we-1')).toBe(state); + }); + + it('returns mutable pendingTools map', () => { + const chatState = new ChatState(); + const pt = { toolCall: {} as any, parentEl: null }; + chatState.pendingTools.set('pt-1', pt); + expect(chatState.pendingTools.get('pt-1')).toBe(pt); + }); + }); + + describe('usage', () => { + it('fires onUsageChanged when usage changes', () => { + const onUsageChanged = jest.fn(); + const chatState = new ChatState({ onUsageChanged }); + const usage = { inputTokens: 100, outputTokens: 50 } as any; + + chatState.usage = usage; + + expect(onUsageChanged).toHaveBeenCalledWith(usage); + }); + + it('fires onUsageChanged with null', () => { + const onUsageChanged = jest.fn(); + const chatState = new ChatState({ onUsageChanged }); + chatState.usage = { inputTokens: 100, outputTokens: 50 } as any; + + chatState.usage = null; + + expect(onUsageChanged).toHaveBeenCalledWith(null); + }); + + it('stores ignoreUsageUpdates', () => { + const chatState = new ChatState(); + chatState.ignoreUsageUpdates = true; + expect(chatState.ignoreUsageUpdates).toBe(true); + }); + }); + + describe('currentTodos', () => { + it('fires onTodosChanged when todos change', () => { + const onTodosChanged = jest.fn(); + const chatState = new ChatState({ onTodosChanged }); + const todos = [{ content: 'Test', status: 'pending' as const, activeForm: 'Testing' }]; + + chatState.currentTodos = todos; + + expect(onTodosChanged).toHaveBeenCalledWith(todos); + }); + + it('normalizes empty array to null', () => { + const onTodosChanged = jest.fn(); + const chatState = new ChatState({ onTodosChanged }); + + chatState.currentTodos = []; + + expect(onTodosChanged).toHaveBeenCalledWith(null); + }); + + it('returns a copy of todos', () => { + const chatState = new ChatState(); + const todos = [{ content: 'Test', status: 'pending' as const, activeForm: 'Testing' }]; + chatState.currentTodos = todos; + + const retrieved = chatState.currentTodos!; + retrieved.push({ content: 'Other', status: 'pending' as const, activeForm: 'Othering' }); + + expect(chatState.currentTodos).toHaveLength(1); + }); + + it('returns null when not set', () => { + const chatState = new ChatState(); + expect(chatState.currentTodos).toBeNull(); + }); + }); + + describe('needsAttention', () => { + it('fires onAttentionChanged when value changes', () => { + const onAttentionChanged = jest.fn(); + const chatState = new ChatState({ onAttentionChanged }); + + chatState.needsAttention = true; + + expect(onAttentionChanged).toHaveBeenCalledWith(true); + }); + }); + + describe('autoScrollEnabled', () => { + it('fires onAutoScrollChanged when value changes', () => { + const onAutoScrollChanged = jest.fn(); + const chatState = new ChatState({ onAutoScrollChanged }); + // Default is true, so set to false to trigger change + chatState.autoScrollEnabled = false; + + expect(onAutoScrollChanged).toHaveBeenCalledWith(false); + }); + + it('does not fire onAutoScrollChanged when value is the same', () => { + const onAutoScrollChanged = jest.fn(); + const chatState = new ChatState({ onAutoScrollChanged }); + // Default is true, set to true again + chatState.autoScrollEnabled = true; + + expect(onAutoScrollChanged).not.toHaveBeenCalled(); + }); + }); + + describe('response timer', () => { + it('stores responseStartTime', () => { + const chatState = new ChatState(); + chatState.responseStartTime = 12345; + expect(chatState.responseStartTime).toBe(12345); + }); + + it('stores flavorTimerInterval', () => { + const chatState = new ChatState(); + const interval = window.setInterval(() => {}, 1000); + chatState.flavorTimerInterval = interval; + expect(chatState.flavorTimerInterval).toBe(interval); + window.clearInterval(interval); + }); + }); + + describe('clearFlavorTimerInterval', () => { + it('clears active interval', () => { + const chatState = new ChatState(); + const clearSpy = jest.spyOn(window, 'clearInterval'); + const interval = window.setInterval(() => {}, 1000); + chatState.flavorTimerInterval = interval; + + chatState.clearFlavorTimerInterval(); + + expect(clearSpy).toHaveBeenCalledWith(interval); + expect(chatState.flavorTimerInterval).toBeNull(); + clearSpy.mockRestore(); + }); + + it('is a no-op when no interval is active', () => { + const chatState = new ChatState(); + const clearSpy = jest.spyOn(window, 'clearInterval'); + + chatState.clearFlavorTimerInterval(); + + expect(clearSpy).not.toHaveBeenCalled(); + clearSpy.mockRestore(); + }); + }); + + describe('resetStreamingState', () => { + it('resets all streaming-related state', () => { + const chatState = new ChatState(); + chatState.currentContentEl = {} as HTMLElement; + chatState.currentTextEl = {} as HTMLElement; + chatState.currentTextContent = 'text'; + chatState.currentThinkingState = {} as any; + chatState.isStreaming = true; + chatState.cancelRequested = true; + const timeout = window.setTimeout(() => {}, 1000); + chatState.thinkingIndicatorTimeout = timeout; + const interval = window.setInterval(() => {}, 1000); + chatState.flavorTimerInterval = interval; + chatState.responseStartTime = 12345; + + chatState.resetStreamingState(); + + expect(chatState.currentContentEl).toBeNull(); + expect(chatState.currentTextEl).toBeNull(); + expect(chatState.currentTextContent).toBe(''); + expect(chatState.currentThinkingState).toBeNull(); + expect(chatState.isStreaming).toBe(false); + expect(chatState.cancelRequested).toBe(false); + expect(chatState.thinkingIndicatorTimeout).toBeNull(); + expect(chatState.flavorTimerInterval).toBeNull(); + expect(chatState.responseStartTime).toBeNull(); + }); + }); + + describe('clearMaps', () => { + it('clears all tracking maps', () => { + const chatState = new ChatState(); + chatState.toolCallElements.set('a', {} as HTMLElement); + chatState.writeEditStates.set('b', {} as any); + chatState.pendingTools.set('c', { toolCall: {} as any, parentEl: null }); + + chatState.clearMaps(); + + expect(chatState.toolCallElements.size).toBe(0); + expect(chatState.writeEditStates.size).toBe(0); + expect(chatState.pendingTools.size).toBe(0); + }); + }); + + describe('resetForNewConversation', () => { + it('resets all conversation state', () => { + const onMessagesChanged = jest.fn(); + const onUsageChanged = jest.fn(); + const onTodosChanged = jest.fn(); + const onAutoScrollChanged = jest.fn(); + const chatState = new ChatState({ + onMessagesChanged, + onUsageChanged, + onTodosChanged, + onAutoScrollChanged, + }); + + // Set up some state + chatState.addMessage({ id: '1', role: 'user', content: 'hi', timestamp: 1 }); + chatState.isStreaming = true; + chatState.cancelRequested = true; + chatState.currentContentEl = {} as HTMLElement; + chatState.toolCallElements.set('a', {} as HTMLElement); + chatState.queuedMessage = { content: 'queued', editorContext: null, canvasContext: null }; + chatState.usage = { inputTokens: 100, outputTokens: 50 } as any; + chatState.currentTodos = [{ content: 'Test', status: 'pending' as const, activeForm: 'Testing' }]; + // autoScrollEnabled defaults to true, set to false first so reset triggers change + chatState.autoScrollEnabled = false; + + // Reset all tracking + jest.clearAllMocks(); + + chatState.resetForNewConversation(); + + expect(chatState.messages).toHaveLength(0); + expect(chatState.isStreaming).toBe(false); + expect(chatState.cancelRequested).toBe(false); + expect(chatState.currentContentEl).toBeNull(); + expect(chatState.toolCallElements.size).toBe(0); + expect(chatState.writeEditStates.size).toBe(0); + expect(chatState.pendingTools.size).toBe(0); + expect(chatState.queuedMessage).toBeNull(); + expect(chatState.usage).toBeNull(); + expect(chatState.currentTodos).toBeNull(); + expect(chatState.autoScrollEnabled).toBe(true); + + // Verify callbacks were fired + expect(onMessagesChanged).toHaveBeenCalled(); + expect(onUsageChanged).toHaveBeenCalledWith(null); + expect(onTodosChanged).toHaveBeenCalledWith(null); + expect(onAutoScrollChanged).toHaveBeenCalledWith(true); + }); + }); + + describe('getPersistedMessages', () => { + it('returns messages as-is', () => { + const chatState = new ChatState(); + const msg = { id: '1', role: 'user' as const, content: 'test', timestamp: 1 }; + chatState.addMessage(msg); + + const persisted = chatState.getPersistedMessages(); + + expect(persisted).toHaveLength(1); + expect(persisted[0]).toEqual(msg); + }); + }); + + describe('callbacks property', () => { + it('allows getting callbacks', () => { + const callbacks: ChatStateCallbacks = { onMessagesChanged: jest.fn() }; + const chatState = new ChatState(callbacks); + + expect(chatState.callbacks).toBe(callbacks); + }); + + it('allows setting callbacks', () => { + const chatState = new ChatState(); + const newCallbacks: ChatStateCallbacks = { onMessagesChanged: jest.fn() }; + + chatState.callbacks = newCallbacks; + chatState.addMessage({ id: '1', role: 'user', content: 'hi', timestamp: 1 }); + + expect(newCallbacks.onMessagesChanged).toHaveBeenCalled(); + }); + }); + + describe('truncateAt', () => { + it('removes target message and all after, fires callback', () => { + const onMessagesChanged = jest.fn(); + const chatState = new ChatState({ onMessagesChanged }); + chatState.addMessage({ id: 'a', role: 'user', content: 'first', timestamp: 1 }); + chatState.addMessage({ id: 'b', role: 'assistant', content: 'reply1', timestamp: 2 }); + chatState.addMessage({ id: 'c', role: 'user', content: 'second', timestamp: 3 }); + chatState.addMessage({ id: 'd', role: 'assistant', content: 'reply2', timestamp: 4 }); + onMessagesChanged.mockClear(); + + const removed = chatState.truncateAt('c'); + + expect(removed).toBe(2); + expect(chatState.messages.map(m => m.id)).toEqual(['a', 'b']); + expect(onMessagesChanged).toHaveBeenCalledTimes(1); + }); + + it('returns 0 and does not fire callback for unknown id', () => { + const onMessagesChanged = jest.fn(); + const chatState = new ChatState({ onMessagesChanged }); + chatState.addMessage({ id: 'a', role: 'user', content: 'first', timestamp: 1 }); + onMessagesChanged.mockClear(); + + const removed = chatState.truncateAt('nonexistent'); + + expect(removed).toBe(0); + expect(chatState.messages.map(m => m.id)).toEqual(['a']); + expect(onMessagesChanged).not.toHaveBeenCalled(); + }); + + it('clears all messages when truncating at first message', () => { + const onMessagesChanged = jest.fn(); + const chatState = new ChatState({ onMessagesChanged }); + chatState.addMessage({ id: 'a', role: 'user', content: 'first', timestamp: 1 }); + chatState.addMessage({ id: 'b', role: 'assistant', content: 'reply', timestamp: 2 }); + onMessagesChanged.mockClear(); + + const removed = chatState.truncateAt('a'); + + expect(removed).toBe(2); + expect(chatState.messages).toEqual([]); + expect(onMessagesChanged).toHaveBeenCalledTimes(1); + }); + + it('removes only last message when truncating at last', () => { + const onMessagesChanged = jest.fn(); + const chatState = new ChatState({ onMessagesChanged }); + chatState.addMessage({ id: 'a', role: 'user', content: 'first', timestamp: 1 }); + chatState.addMessage({ id: 'b', role: 'assistant', content: 'reply', timestamp: 2 }); + onMessagesChanged.mockClear(); + + const removed = chatState.truncateAt('b'); + + expect(removed).toBe(1); + expect(chatState.messages.map(m => m.id)).toEqual(['a']); + expect(onMessagesChanged).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/tests/unit/features/chat/tabs/RuntimeSupervisor.test.ts b/tests/unit/features/chat/tabs/RuntimeSupervisor.test.ts new file mode 100644 index 0000000..7676a88 --- /dev/null +++ b/tests/unit/features/chat/tabs/RuntimeSupervisor.test.ts @@ -0,0 +1,46 @@ +import type { ChatRuntime } from '@/core/runtime/ChatRuntime'; +import { RuntimeSupervisor } from '@/features/chat/tabs/RuntimeSupervisor'; + +function createRuntime(id: string, trace: string[]): ChatRuntime { + return { + cleanup: () => { trace.push(`${id}:cleanup`); }, + } as unknown as ChatRuntime; +} + +describe('RuntimeSupervisor', () => { + it('owns replacement without introducing implicit cleanup semantics', () => { + const trace: string[] = []; + const first = createRuntime('first', trace); + const second = createRuntime('second', trace); + const supervisor = new RuntimeSupervisor(first); + + supervisor.setCurrent(second); + + expect(supervisor.current).toBe(second); + expect(trace).toEqual([]); + }); + + it('keeps cleanup authoritative and clears the owned reference', () => { + const trace: string[] = []; + const runtime = createRuntime('runtime', trace); + const supervisor = new RuntimeSupervisor(runtime); + + supervisor.cleanup(); + + expect(trace).toEqual(['runtime:cleanup']); + expect(supervisor.current).toBeNull(); + }); + + it('keeps the runtime visible during cleanup and preserves it when cleanup throws', () => { + const runtime = { + cleanup: jest.fn(() => { + expect(supervisor.current).toBe(runtime); + throw new Error('cleanup failed'); + }), + } as unknown as ChatRuntime; + const supervisor = new RuntimeSupervisor(runtime); + + expect(() => supervisor.cleanup()).toThrow('cleanup failed'); + expect(supervisor.current).toBe(runtime); + }); +}); diff --git a/tests/unit/features/chat/tabs/Tab.test.ts b/tests/unit/features/chat/tabs/Tab.test.ts new file mode 100644 index 0000000..a53409c --- /dev/null +++ b/tests/unit/features/chat/tabs/Tab.test.ts @@ -0,0 +1,4410 @@ +import '@/providers'; + +import { + TEST_CODEX_CATALOG, + TEST_CODEX_MODEL, + TEST_CODEX_MODEL_LABEL, +} from '@test/helpers/codexModels'; +import { createMockEl } from '@test/helpers/mockElement'; +import { Notice, Platform } from 'obsidian'; + +import { ProviderRegistry } from '@/core/providers/ProviderRegistry'; +import { ProviderWorkspaceRegistry } from '@/core/providers/ProviderWorkspaceRegistry'; +import { SelectionController } from '@/features/chat/controllers/SelectionController'; +import { ChatState } from '@/features/chat/state/ChatState'; +import { + activateTab, + createTab, + deactivateTab, + destroyTab, + getBlankTabModelOptions, + getTabTitle, + initializeTabControllers, + initializeTabService, + initializeTabUI, + onProviderAvailabilityChanged, + setupServiceCallbacks, + type TabCreateOptions, + updatePlanModeUI, + wireTabInputEvents, +} from '@/features/chat/tabs/Tab'; +import * as envUtils from '@/utils/env'; + +// Mock ResizeObserver (not available in jsdom) +const resizeObserverInstances: MockResizeObserver[] = []; +class MockResizeObserver { + callback: ResizeObserverCallback; + constructor(callback: ResizeObserverCallback) { + this.callback = callback; + resizeObserverInstances.push(this); + } + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); +} +global.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver; + +// Mock provider runtime used by ProviderRegistry +jest.mock('@/providers/claude/runtime/ClaudeChatRuntime', () => ({ + ClaudianService: jest.fn().mockImplementation(() => ({ + ensureReady: jest.fn().mockResolvedValue(true), + cleanup: jest.fn(), + isReady: jest.fn().mockReturnValue(false), + syncConversationState: jest.fn(), + onReadyStateChange: jest.fn((listener: (ready: boolean) => void) => { + listener(false); + return () => {}; + }), + })), +})); + +// Mock factories must be defined before jest.mock calls due to hoisting +// These will be initialized fresh in beforeEach +const createMockFileContextManager = () => ({ + setMcpManager: jest.fn(), + setAgentService: jest.fn(), + setOnMcpMentionChange: jest.fn(), + preScanExternalContexts: jest.fn(), + handleInputChange: jest.fn(), + handleMentionKeydown: jest.fn().mockReturnValue(false), + isMentionDropdownVisible: jest.fn().mockReturnValue(false), + hideMentionDropdown: jest.fn(), + destroy: jest.fn(), +}); + +const createMockImageContextManager = () => ({ + destroy: jest.fn(), + clearImages: jest.fn(), + setEnabled: jest.fn(), +}); + +const createMockSlashCommandDropdown = () => ({ + handleKeydown: jest.fn().mockReturnValue(false), + isVisible: jest.fn().mockReturnValue(false), + hide: jest.fn(), + resetSdkSkillsCache: jest.fn(), + setHiddenCommands: jest.fn(), + setEnabled: jest.fn(), + destroy: jest.fn(), +}); + +const createMockInstructionModeManager = () => ({ + handleTriggerKey: jest.fn().mockReturnValue(false), + handleKeydown: jest.fn().mockReturnValue(false), + handleInputChange: jest.fn(), + isActive: jest.fn().mockReturnValue(false), + destroy: jest.fn(), +}); + +const createMockBangBashModeManager = () => ({ + handleTriggerKey: jest.fn().mockReturnValue(false), + handleKeydown: jest.fn().mockReturnValue(false), + handleInputChange: jest.fn(), + isActive: jest.fn().mockReturnValue(false), + destroy: jest.fn(), +}); + +const createMockStatusPanel = () => ({ + mount: jest.fn(), + remount: jest.fn(), + updateTodos: jest.fn(), + destroy: jest.fn(), +}); + +const createMockModelSelector = () => ({ + updateDisplay: jest.fn(), + renderOptions: jest.fn(), + setReady: jest.fn(), +}); + +const createMockModeSelector = () => ({ + updateDisplay: jest.fn(), + renderOptions: jest.fn(), +}); + +const createMockClaudianService = (overrides?: { + ensureReady?: jest.Mock; + syncConversationState?: jest.Mock; + onReadyStateChange?: jest.Mock; + providerId?: 'claude' | 'codex'; +}) => ({ + providerId: overrides?.providerId ?? 'claude', + ensureReady: overrides?.ensureReady ?? jest.fn().mockResolvedValue(true), + cleanup: jest.fn(), + isReady: jest.fn().mockReturnValue(false), + getCapabilities: jest.fn().mockReturnValue({ + providerId: overrides?.providerId ?? 'claude', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: true, + supportsFork: true, + supportsProviderCommands: true, + supportsImageAttachments: true, + supportsInstructionMode: true, + supportsMcpTools: true, + reasoningControl: 'effort', + }), + syncConversationState: overrides?.syncConversationState ?? jest.fn(), + onReadyStateChange: overrides?.onReadyStateChange ?? jest.fn((listener: (ready: boolean) => void) => { + listener(false); + return () => {}; + }), +}); + +const createMockThinkingBudgetSelector = () => ({ + updateDisplay: jest.fn(), +}); + +const createMockContextUsageMeter = () => ({ + update: jest.fn(), + setVisible: jest.fn(), +}); + +const createMockExternalContextSelector = () => ({ + getExternalContexts: jest.fn().mockReturnValue([]), + setOnChange: jest.fn(), + setPersistentPaths: jest.fn(), + setOnPersistenceChange: jest.fn(), +}); + +const createMockMcpServerSelector = () => ({ + setMcpManager: jest.fn(), + addMentionedServers: jest.fn(), + clearEnabled: jest.fn(), + setVisible: jest.fn(), +}); + +const createMockPermissionToggle = () => ({ + setVisible: jest.fn(), + updateDisplay: jest.fn(), +}); + +const createMockServiceTierToggle = () => ({ + updateDisplay: jest.fn(), +}); + +// Shared mock instances (reset in beforeEach) +let mockFileContextManager: ReturnType; +let mockImageContextManager: ReturnType; +let mockSlashCommandDropdown: ReturnType; +let mockInstructionModeManager: ReturnType; +let mockBangBashModeManager: ReturnType; +let mockStatusPanel: ReturnType; +let mockModelSelector: ReturnType; +let mockModeSelector: ReturnType; +let mockThinkingBudgetSelector: ReturnType; +let mockContextUsageMeter: ReturnType; +let mockExternalContextSelector: ReturnType; +let mockMcpServerSelector: ReturnType; +let mockPermissionToggle: ReturnType; +let mockServiceTierToggle: ReturnType; +let mockMessageRenderer: { scrollToBottomIfNeeded: jest.Mock; setAsyncSubagentClickCallback: jest.Mock }; +let mockSelectionController: ReturnType; +let mockBrowserSelectionController: ReturnType; +let mockCanvasSelectionController: ReturnType; +let mockStreamController: { onAsyncSubagentStateChange: jest.Mock }; +let mockConversationController: { save: jest.Mock; rewind: jest.Mock }; +let mockInputController: ReturnType; +let mockNavigationController: { initialize: jest.Mock; dispose: jest.Mock }; + +const createMockSelectionController = () => ({ + start: jest.fn(), + stop: jest.fn(), + clear: jest.fn(), + showHighlight: jest.fn(), + updateContextRowVisibility: jest.fn(), +}); + +const createMockBrowserSelectionController = () => ({ + start: jest.fn(), + stop: jest.fn(), + clear: jest.fn(), + updateContextRowVisibility: jest.fn(), +}); + +const createMockCanvasSelectionController = () => ({ + start: jest.fn(), + stop: jest.fn(), + clear: jest.fn(), + updateContextRowVisibility: jest.fn(), +}); + +const createMockInputController = () => ({ + sendMessage: jest.fn(), + cancelStreaming: jest.fn(), + handleInstructionSubmit: jest.fn(), + updateQueueIndicator: jest.fn(), + handleResumeKeydown: jest.fn().mockReturnValue(false), + isResumeDropdownVisible: jest.fn().mockReturnValue(false), + destroyResumeDropdown: jest.fn(), + dismissPendingApproval: jest.fn(), +}); + +jest.mock('@/features/chat/ui/FileContext', () => ({ + FileContextManager: jest.fn().mockImplementation(() => { + mockFileContextManager = createMockFileContextManager(); + return mockFileContextManager; + }), +})); + +jest.mock('@/features/chat/ui/ImageContext', () => ({ + ImageContextManager: jest.fn().mockImplementation(() => { + mockImageContextManager = createMockImageContextManager(); + return mockImageContextManager; + }), +})); + +jest.mock('@/features/chat/ui/InstructionModeManager', () => ({ + InstructionModeManager: jest.fn().mockImplementation(() => { + mockInstructionModeManager = createMockInstructionModeManager(); + return mockInstructionModeManager; + }), +})); + +jest.mock('@/features/chat/ui/StatusPanel', () => ({ + StatusPanel: jest.fn().mockImplementation(() => { + mockStatusPanel = createMockStatusPanel(); + return mockStatusPanel; + }), +})); + +jest.mock('@/features/chat/ui/InputToolbar', () => ({ + createInputToolbar: jest.fn().mockImplementation(() => { + mockModelSelector = createMockModelSelector(); + mockModeSelector = createMockModeSelector(); + mockThinkingBudgetSelector = createMockThinkingBudgetSelector(); + mockContextUsageMeter = createMockContextUsageMeter(); + mockExternalContextSelector = createMockExternalContextSelector(); + mockMcpServerSelector = createMockMcpServerSelector(); + mockPermissionToggle = createMockPermissionToggle(); + mockServiceTierToggle = createMockServiceTierToggle(); + return { + modelSelector: mockModelSelector, + modeSelector: mockModeSelector, + thinkingBudgetSelector: mockThinkingBudgetSelector, + contextUsageMeter: mockContextUsageMeter, + externalContextSelector: mockExternalContextSelector, + mcpServerSelector: mockMcpServerSelector, + permissionToggle: mockPermissionToggle, + serviceTierToggle: mockServiceTierToggle, + }; + }), +})); + +jest.mock('@/shared/components/SlashCommandDropdown', () => ({ + SlashCommandDropdown: jest.fn().mockImplementation(() => { + mockSlashCommandDropdown = createMockSlashCommandDropdown(); + return mockSlashCommandDropdown; + }), +})); + +// Mock rendering +jest.mock('@/features/chat/rendering/MessageRenderer', () => ({ + MessageRenderer: jest.fn().mockImplementation(() => { + mockMessageRenderer = { + scrollToBottomIfNeeded: jest.fn(), + setAsyncSubagentClickCallback: jest.fn(), + }; + return mockMessageRenderer; + }), +})); + +jest.mock('@/features/chat/rendering/ThinkingBlockRenderer', () => ({ + cleanupThinkingBlock: jest.fn(), +})); + +// Mock controllers +jest.mock('@/features/chat/controllers/SelectionController', () => ({ + SelectionController: jest.fn().mockImplementation(() => { + mockSelectionController = createMockSelectionController(); + return mockSelectionController; + }), +})); + +jest.mock('@/features/chat/controllers/BrowserSelectionController', () => ({ + BrowserSelectionController: jest.fn().mockImplementation(() => { + mockBrowserSelectionController = createMockBrowserSelectionController(); + return mockBrowserSelectionController; + }), +})); + +jest.mock('@/features/chat/controllers/CanvasSelectionController', () => ({ + CanvasSelectionController: jest.fn().mockImplementation(() => { + mockCanvasSelectionController = createMockCanvasSelectionController(); + return mockCanvasSelectionController; + }), +})); + +jest.mock('@/features/chat/controllers/StreamController', () => ({ + StreamController: jest.fn().mockImplementation(() => { + mockStreamController = { onAsyncSubagentStateChange: jest.fn() }; + return mockStreamController; + }), +})); + +jest.mock('@/features/chat/controllers/ConversationController', () => ({ + ConversationController: jest.fn().mockImplementation(() => { + mockConversationController = { + save: jest.fn().mockResolvedValue(undefined), + rewind: jest.fn().mockResolvedValue(undefined), + }; + return mockConversationController; + }), +})); + +jest.mock('@/features/chat/controllers/InputController', () => ({ + InputController: jest.fn().mockImplementation(() => { + mockInputController = createMockInputController(); + return mockInputController; + }), +})); + +jest.mock('@/features/chat/controllers/NavigationController', () => ({ + NavigationController: jest.fn().mockImplementation(() => { + mockNavigationController = { initialize: jest.fn(), dispose: jest.fn() }; + return mockNavigationController; + }), +})); + +// Mock services +jest.mock('@/features/chat/services/SubagentManager', () => ({ + SubagentManager: jest.fn().mockImplementation(() => ({ + orphanAllActive: jest.fn(), + setCallback: jest.fn(), + clear: jest.fn(), + })), +})); + +jest.mock('@/providers/claude/auxiliary/ClaudeInstructionRefineService', () => ({ + InstructionRefineService: jest.fn().mockImplementation(() => ({ + cancel: jest.fn(), + resetConversation: jest.fn(), + })), +})); + +jest.mock('@/providers/claude/auxiliary/ClaudeTitleGenerationService', () => ({ + TitleGenerationService: jest.fn().mockImplementation(() => ({ + cancel: jest.fn(), + })), +})); + +// Mock path util +jest.mock('@/utils/path', () => ({ + getVaultPath: jest.fn().mockReturnValue('/test/vault'), +})); + +// Helper to create mock plugin +function createMockPlugin(overrides: Record = {}): any { + const claudeAgentMentionProvider = { searchAgents: jest.fn().mockReturnValue([]) }; + const codexAgentMentionProvider = { searchAgents: jest.fn().mockReturnValue([]) }; + const { settings: settingsOverrides = {}, ...pluginOverrides } = overrides; + const defaultProviderConfigs = { + claude: {}, + codex: { + enabled: true, + discoveredModels: TEST_CODEX_CATALOG, + }, + }; + const plugin: any = { + app: { + vault: { + adapter: { basePath: '/test/vault' }, + }, + }, + settings: { + excludedTags: [], + model: 'claude-sonnet-4-5', + thinkingBudget: 'low', + effortLevel: 'high', + serviceTier: 'default', + permissionMode: 'yolo', + keyboardNavigation: { + scrollUpKey: 'k', + scrollDownKey: 'j', + focusInputKey: 'i', + }, + persistentExternalContextPaths: [], + settingsProvider: 'claude', + codexEnabled: true, + savedProviderModel: { + claude: 'claude-sonnet-4-5', + }, + savedProviderEffort: { + claude: 'high', + }, + savedProviderServiceTier: { + claude: 'default', + }, + savedProviderThinkingBudget: { + claude: 'low', + }, + ...settingsOverrides, + providerConfigs: { + ...defaultProviderConfigs, + ...(settingsOverrides.providerConfigs ?? {}), + }, + }, + mcpManager: { getMcpServers: jest.fn().mockReturnValue([]) }, + agentManager: claudeAgentMentionProvider, + codexAgentMentionProvider, + getConversationById: jest.fn().mockResolvedValue(null), + getConversationSync: jest.fn().mockReturnValue(null), + updateConversation: jest.fn().mockResolvedValue(undefined), + saveSettings: jest.fn().mockResolvedValue(undefined), + getActiveEnvironmentVariables: jest.fn().mockReturnValue(''), + ...pluginOverrides, + }; + plugin.mutateSettings = jest.fn(async (mutation: (settings: any) => void | Promise) => { + await mutation(plugin.settings); + await plugin.saveSettings(); + }); + plugin.providerHost = plugin; + return plugin; +} + +// Helper to create mock MCP manager +function createMockMcpManager(): any { + return { + getMcpServers: jest.fn().mockReturnValue([]), + }; +} + +type TestTabCreateOptions = TabCreateOptions & { + mcpManager: ReturnType; +}; + +// Helper to create TabCreateOptions +function createMockOptions(overrides: Partial = {}): TestTabCreateOptions { + const options = { + plugin: createMockPlugin(), + mcpManager: createMockMcpManager(), + containerEl: createMockEl(), + ...overrides, + } as TestTabCreateOptions; + + const plugin = options.plugin as any; + ProviderWorkspaceRegistry.setServices('claude', { + mcpManager: plugin.mcpManager, + mcpServerManager: plugin.mcpManager, + agentMentionProvider: plugin.agentManager, + } as any); + ProviderWorkspaceRegistry.setServices('codex', { + agentMentionProvider: plugin.codexAgentMentionProvider, + } as any); + + return options; +} + +describe('Tab - Creation', () => { + describe('createTab', () => { + it('should create a new tab with unique ID', () => { + const options = createMockOptions(); + const tab = createTab(options); + + expect(tab.id).toBeDefined(); + expect(tab.id).toMatch(/^tab-/); + }); + + it('should use provided tab ID when specified', () => { + const options = createMockOptions({ tabId: 'custom-tab-id' }); + const tab = createTab(options); + + expect(tab.id).toBe('custom-tab-id'); + }); + + it('should initialize with null conversationId when no conversation provided', () => { + const options = createMockOptions(); + const tab = createTab(options); + + expect(tab.conversationId).toBeNull(); + }); + + it('should set conversationId when conversation is provided', () => { + const options = createMockOptions({ + conversation: { + id: 'conv-123', + providerId: 'claude', + title: 'Test Conversation', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + }); + const tab = createTab(options); + + expect(tab.conversationId).toBe('conv-123'); + }); + + it('should create tab with lazy-initialized service (null)', () => { + const options = createMockOptions(); + const tab = createTab(options); + + expect(tab.service).toBeNull(); + expect(tab.serviceInitialized).toBe(false); + }); + + it('should create ChatState with callbacks', () => { + const onStreamingChanged = jest.fn(); + const onAttentionChanged = jest.fn(); + const onConversationIdChanged = jest.fn(); + + const options = createMockOptions({ + onStreamingChanged, + onAttentionChanged, + onConversationIdChanged, + }); + const tab = createTab(options); + + expect(tab.state).toBeInstanceOf(ChatState); + }); + + it('should create DOM structure with hidden content', () => { + const containerEl = createMockEl(); + const options = createMockOptions({ containerEl }); + const tab = createTab(options); + + expect(tab.dom.contentEl).toBeDefined(); + expect(tab.dom.contentEl.style.display).toBe('none'); + expect(tab.dom.messagesEl).toBeDefined(); + expect(tab.dom.inputEl).toBeDefined(); + }); + + it('should initialize empty eventCleanups array', () => { + const options = createMockOptions(); + const tab = createTab(options); + + expect(tab.dom.eventCleanups).toEqual([]); + }); + + it('should initialize all controllers as null', () => { + const options = createMockOptions(); + const tab = createTab(options); + + expect(tab.controllers.selectionController).toBeNull(); + expect(tab.controllers.conversationController).toBeNull(); + expect(tab.controllers.streamController).toBeNull(); + expect(tab.controllers.inputController).toBeNull(); + expect(tab.controllers.navigationController).toBeNull(); + }); + + it('should derive the blank-tab provider from the default draft model', () => { + const plugin = createMockPlugin(); + plugin.settings.model = TEST_CODEX_MODEL; + + const tab = createTab(createMockOptions({ plugin })); + + expect(tab.lifecycleState).toBe('blank'); + expect(tab.draftModel).toBe(TEST_CODEX_MODEL); + expect(tab.providerId).toBe('codex'); + }); + + it('should resolve draft model from defaultProviderId via projection', () => { + const plugin = createMockPlugin(); + // Top-level model is Claude, but Codex has its own saved model + plugin.settings.model = 'claude-sonnet-4-5'; + plugin.settings.settingsProvider = 'claude'; + plugin.settings.savedProviderModel = { claude: 'claude-sonnet-4-5', codex: TEST_CODEX_MODEL }; + + const tab = createTab(createMockOptions({ plugin, defaultProviderId: 'codex' })); + + expect(tab.lifecycleState).toBe('blank'); + expect(tab.draftModel).toBe(TEST_CODEX_MODEL); + expect(tab.providerId).toBe('codex'); + }); + + it('should resolve draft model for Claude when defaultProviderId is claude', () => { + const plugin = createMockPlugin(); + // Simulate settings where top-level model drifted to a codex value + plugin.settings.model = 'gpt-5.4-mini'; + plugin.settings.settingsProvider = 'claude'; + plugin.settings.savedProviderModel = { claude: 'opus', codex: 'gpt-5.4-mini' }; + + const tab = createTab(createMockOptions({ plugin, defaultProviderId: 'claude' })); + + expect(tab.lifecycleState).toBe('blank'); + expect(tab.draftModel).toBe('opus'); + expect(tab.providerId).toBe('claude'); + }); + + it('should fall back to settings.model when no defaultProviderId is given', () => { + const plugin = createMockPlugin(); + plugin.settings.model = 'opus'; + + const tab = createTab(createMockOptions({ plugin })); + + expect(tab.lifecycleState).toBe('blank'); + expect(tab.draftModel).toBe('opus'); + expect(tab.providerId).toBe('claude'); + }); + + it('should keep a Claude custom gpt model on Claude when Codex is disabled', () => { + const plugin = createMockPlugin(); + plugin.settings.settingsProvider = 'claude'; + plugin.settings.model = TEST_CODEX_MODEL; + plugin.settings.providerConfigs = { + claude: { + environmentVariables: `ANTHROPIC_MODEL=${TEST_CODEX_MODEL}`, + }, + codex: { + enabled: false, + }, + }; + + const tab = createTab(createMockOptions({ plugin })); + + expect(tab.lifecycleState).toBe('blank'); + expect(tab.draftModel).toBe(TEST_CODEX_MODEL); + expect(tab.providerId).toBe('claude'); + }); + + it('should fall back to an enabled provider when defaultProviderId is disabled', () => { + const plugin = createMockPlugin(); + plugin.settings.settingsProvider = 'claude'; + plugin.settings.model = 'claude-sonnet-4-5'; + plugin.settings.providerConfigs = { + claude: {}, + codex: { + enabled: false, + }, + }; + plugin.settings.savedProviderModel = { + claude: 'opus', + codex: TEST_CODEX_MODEL, + }; + + const tab = createTab(createMockOptions({ plugin, defaultProviderId: 'codex' })); + + expect(tab.lifecycleState).toBe('blank'); + expect(tab.draftModel).toBe('opus'); + expect(tab.providerId).toBe('claude'); + }); + }); +}); + +describe('Tab - Service Initialization', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('initializeTabService', () => { + it('should not reinitialize if already initialized', async () => { + const options = createMockOptions(); + const tab = createTab(options); + tab.serviceInitialized = true; + tab.service = createMockClaudianService() as any; + + await initializeTabService(tab, options.plugin, options.mcpManager); + + // Service should not be replaced + expect(tab.service).toEqual(expect.objectContaining({ providerId: 'claude' })); + }); + + it('should create ClaudianService on first initialization', async () => { + const options = createMockOptions(); + const tab = createTab(options); + + await initializeTabService(tab, options.plugin, options.mcpManager); + + expect(tab.service).toBeDefined(); + expect(tab.serviceInitialized).toBe(true); + }); + + it('should create the runtime for the conversation provider', async () => { + const createChatRuntimeSpy = jest.spyOn(ProviderRegistry, 'createChatRuntime'); + const mockRuntime = createMockClaudianService({ providerId: 'codex' }); + createChatRuntimeSpy.mockReturnValue(mockRuntime as any); + + const conversation = { + id: 'conv-codex', + providerId: 'codex' as const, + title: 'Codex Conversation', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const plugin = createMockPlugin({ + getConversationById: jest.fn().mockResolvedValue(conversation), + }); + + const tab = createTab(createMockOptions({ + plugin, + conversation, + })); + + await initializeTabService(tab, plugin, createMockMcpManager()); + + expect(createChatRuntimeSpy).toHaveBeenCalledWith(expect.objectContaining({ + plugin, + providerId: 'codex', + })); + }); + + it('should recreate the runtime when the conversation provider changes', async () => { + const createChatRuntimeSpy = jest.spyOn(ProviderRegistry, 'createChatRuntime'); + const oldService = createMockClaudianService({ providerId: 'claude' }); + const newService = createMockClaudianService({ providerId: 'codex' }); + createChatRuntimeSpy.mockReturnValue(newService as any); + + const conversation = { + id: 'conv-codex', + providerId: 'codex' as const, + title: 'Codex Conversation', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const plugin = createMockPlugin({ + getConversationById: jest.fn().mockResolvedValue(conversation), + }); + + const tab = createTab(createMockOptions({ + plugin, + conversation, + })); + tab.service = oldService as any; + tab.serviceInitialized = true; + + await initializeTabService(tab, plugin, createMockMcpManager()); + + expect(oldService.cleanup).toHaveBeenCalled(); + expect(createChatRuntimeSpy).toHaveBeenCalledWith(expect.objectContaining({ + plugin, + providerId: 'codex', + })); + expect(tab.runtimeSupervisor.current).toBe(newService); + expect(tab.service).toBe(newService); + }); + + it('should NOT call ensureReady for blank tabs (lazy start)', async () => { + const mockEnsureReady = jest.fn().mockResolvedValue(true); + const runtimeModule = jest.requireMock('@/providers/claude/runtime/ClaudeChatRuntime') as { ClaudianService: jest.Mock }; + runtimeModule.ClaudianService.mockImplementationOnce(() => createMockClaudianService({ ensureReady: mockEnsureReady })); + + const options = createMockOptions(); + const tab = createTab(options); + + await initializeTabService(tab, options.plugin, options.mcpManager); + + // Runtime starts on demand in query(), not during initialization + expect(mockEnsureReady).not.toHaveBeenCalled(); + expect(tab.serviceInitialized).toBe(true); + expect(tab.lifecycleState).toBe('bound_active'); + }); + + it('should sync existing conversations with saved external contexts', async () => { + const mockSyncConversationState = jest.fn(); + const runtimeModule = jest.requireMock('@/providers/claude/runtime/ClaudeChatRuntime') as { ClaudianService: jest.Mock }; + runtimeModule.ClaudianService.mockImplementationOnce(() => createMockClaudianService({ + syncConversationState: mockSyncConversationState, + })); + + const conversation = { + id: 'conv-1', + providerId: 'claude' as const, + title: 'Existing Conversation', + messages: [{ id: 'msg-1', role: 'user' as const, content: 'test', timestamp: Date.now() }], + sessionId: 'session-123', + externalContextPaths: ['/saved/path'], + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + const plugin = createMockPlugin(); + plugin.settings.persistentExternalContextPaths = ['/persistent/path']; + plugin.getConversationById = jest.fn().mockResolvedValue(conversation); + + const options = createMockOptions({ plugin, conversation }); + const tab = createTab(options); + + await initializeTabService(tab, options.plugin, options.mcpManager); + + expect(mockSyncConversationState).toHaveBeenCalledWith(conversation, ['/saved/path']); + }); + + it('should initialize toolbar config for the tab provider', () => { + const getChatUIConfigSpy = jest.spyOn(ProviderRegistry, 'getChatUIConfig'); + const getCapabilitiesSpy = jest.spyOn(ProviderRegistry, 'getCapabilities'); + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + getChatUIConfigSpy.mockReturnValue({ + getModelOptions: jest.fn().mockReturnValue([]), + ownsModel: jest.fn().mockReturnValue(false), + isAdaptiveReasoningModel: jest.fn().mockReturnValue(false), + getReasoningOptions: jest.fn().mockReturnValue([]), + getDefaultReasoningValue: jest.fn().mockReturnValue('off'), + getContextWindowSize: jest.fn().mockReturnValue(200000), + isDefaultModel: jest.fn().mockReturnValue(true), + applyModelDefaults: jest.fn(), + normalizeModelVariant: jest.fn((model: string) => model), + getCustomModelIds: jest.fn().mockReturnValue(new Set()), + }); + getCapabilitiesSpy.mockReturnValue({ + providerId: 'codex', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: false, + supportsRewind: false, + supportsFork: false, + supportsProviderCommands: false, + supportsImageAttachments: true, + supportsInstructionMode: false, + supportsMcpTools: false, + reasoningControl: 'none', + }); + + const options = createMockOptions({ + conversation: { + id: 'conv-codex', + providerId: 'codex', + title: 'Codex Conversation', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + }); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as { + createInputToolbar: jest.Mock; + }; + const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1]; + expect(toolbarCallbacks).toBeDefined(); + + toolbarCallbacks.getUIConfig(); + toolbarCallbacks.getCapabilities(); + + expect(getChatUIConfigSpy).toHaveBeenCalledWith('codex'); + expect(getCapabilitiesSpy).toHaveBeenCalledWith('codex'); + }); + + it('resolves the agent mention service through the provider-specific lookup', () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + + const codexAgentMentionProvider = { searchAgents: jest.fn().mockReturnValue([]) }; + const getAgentMentionProviderSpy = jest.spyOn(ProviderWorkspaceRegistry, 'getAgentMentionProvider') + .mockReturnValue(codexAgentMentionProvider as any); + const plugin = createMockPlugin({ + codexAgentMentionProvider, + }); + const tab = createTab(createMockOptions({ + plugin, + conversation: { + id: 'conv-codex-agent-split', + providerId: 'codex', + title: 'Codex agent split', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + })); + + initializeTabUI(tab, plugin); + + expect(getAgentMentionProviderSpy).toHaveBeenCalledWith('codex'); + expect(mockFileContextManager.setAgentService).toHaveBeenCalledWith(codexAgentMentionProvider); + }); + + it('falls back blank Codex draft to Claude when Codex is disabled', () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + + // Simulate blank tab with Codex draft model + tab.draftModel = TEST_CODEX_MODEL; + tab.providerId = 'codex'; + tab.lifecycleState = 'blank'; + + const staleService = createMockClaudianService({ providerId: 'codex' }); + tab.service = staleService as any; + tab.serviceInitialized = true; + + // Disable Codex + plugin.settings.codexEnabled = false; + plugin.settings.providerConfigs.codex.enabled = false; + + onProviderAvailabilityChanged(tab, plugin); + + expect(staleService.cleanup).toHaveBeenCalled(); + expect(tab.providerId).toBe('claude'); + expect(tab.service).toBeNull(); + expect(tab.serviceInitialized).toBe(false); + expect(mockSlashCommandDropdown.resetSdkSkillsCache).toHaveBeenCalled(); + }); + + it('rebinds provider-scoped helper services when a newly enabled provider takes over the draft model', () => { + const createInstructionRefineServiceSpy = jest.spyOn(ProviderRegistry, 'createInstructionRefineService') + .mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + const createTitleGenerationServiceSpy = jest.spyOn(ProviderRegistry, 'createTitleGenerationService') + .mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + + const plugin = createMockPlugin(); + plugin.settings.settingsProvider = 'claude'; + plugin.settings.model = TEST_CODEX_MODEL; + plugin.settings.providerConfigs = { + claude: { + environmentVariables: `ANTHROPIC_MODEL=${TEST_CODEX_MODEL}`, + }, + codex: { + enabled: false, + }, + }; + + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + + expect(tab.draftModel).toBe(TEST_CODEX_MODEL); + expect(tab.providerId).toBe('claude'); + + plugin.settings.providerConfigs = { + ...plugin.settings.providerConfigs, + codex: { + enabled: true, + discoveredModels: TEST_CODEX_CATALOG, + }, + }; + + onProviderAvailabilityChanged(tab, plugin); + + expect(tab.providerId).toBe('codex'); + expect(createInstructionRefineServiceSpy).toHaveBeenLastCalledWith(plugin, 'codex'); + expect(createTitleGenerationServiceSpy).not.toHaveBeenCalledWith(plugin, 'codex'); + }); + + it('surfaces provider-scoped model settings for inactive-provider tabs and stores bound model changes on the conversation', async () => { + const plugin = createMockPlugin({ + settings: { + excludedTags: [], + model: 'claude-sonnet-4-5', + thinkingBudget: 'low', + effortLevel: 'high', + permissionMode: 'yolo', + keyboardNavigation: { + scrollUpKey: 'k', + scrollDownKey: 'j', + focusInputKey: 'i', + }, + persistentExternalContextPaths: [], + settingsProvider: 'claude', + codexEnabled: true, + savedProviderModel: { + claude: 'claude-sonnet-4-5', + codex: TEST_CODEX_MODEL, + }, + savedProviderEffort: { + claude: 'high', + codex: 'medium', + }, + savedProviderThinkingBudget: { + claude: 'low', + codex: 'off', + }, + }, + }); + + const tab = createTab(createMockOptions({ + plugin, + conversation: { + id: 'conv-codex-settings', + providerId: 'codex', + title: 'Codex conversation', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + })); + + initializeTabUI(tab, plugin); + + const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as { + createInputToolbar: jest.Mock; + }; + const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1]; + + expect(toolbarCallbacks.getSettings()).toEqual(expect.objectContaining({ + model: TEST_CODEX_MODEL, + effortLevel: 'medium', + })); + + await toolbarCallbacks.onModelChange(TEST_CODEX_MODEL); + + expect(plugin.settings.model).toBe('claude-sonnet-4-5'); + expect(plugin.settings.savedProviderModel).toEqual(expect.objectContaining({ + claude: 'claude-sonnet-4-5', + codex: TEST_CODEX_MODEL, + })); + expect(plugin.updateConversation).toHaveBeenCalledWith('conv-codex-settings', { + selectedModel: TEST_CODEX_MODEL, + }); + expect(plugin.saveSettings).not.toHaveBeenCalled(); + }); + + it('maps shared permission mode selections onto managed OpenCode modes', async () => { + const plugin = createMockPlugin({ + settings: { + excludedTags: [], + model: 'claude-sonnet-4-5', + thinkingBudget: 'low', + effortLevel: 'high', + permissionMode: 'yolo', + keyboardNavigation: { + scrollUpKey: 'k', + scrollDownKey: 'j', + focusInputKey: 'i', + }, + persistentExternalContextPaths: [], + settingsProvider: 'claude', + providerConfigs: { + opencode: { + availableModes: [ + { id: 'claudian-yolo', name: 'YOLO' }, + { id: 'claudian-safe', name: 'Safe' }, + { id: 'plan', name: 'Plan' }, + ], + enabled: true, + selectedMode: 'claudian-yolo', + }, + }, + savedProviderEffort: { + claude: 'high', + opencode: 'default', + }, + savedProviderModel: { + claude: 'claude-sonnet-4-5', + opencode: 'opencode:openai/gpt-5', + }, + savedProviderPermissionMode: { + claude: 'yolo', + }, + }, + }); + + const tab = createTab(createMockOptions({ + plugin, + conversation: { + id: 'conv-opencode-settings', + providerId: 'opencode', + title: 'OpenCode conversation', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + })); + + initializeTabUI(tab, plugin); + expect(mockPermissionToggle.setVisible).toHaveBeenLastCalledWith(true); + + const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as { + createInputToolbar: jest.Mock; + }; + const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1]; + + await toolbarCallbacks.onPermissionModeChange('normal'); + + expect(plugin.settings.providerConfigs.opencode.selectedMode).toBe('claudian-safe'); + expect(plugin.settings.savedProviderPermissionMode).toEqual(expect.objectContaining({ + claude: 'yolo', + opencode: 'normal', + })); + expect(plugin.settings.permissionMode).toBe('yolo'); + expect(plugin.saveSettings).toHaveBeenCalled(); + expect(mockPermissionToggle.updateDisplay).toHaveBeenCalled(); + }); + + it('does not update plan-mode UI before the serialized settings mutation completes', async () => { + const plugin = createMockPlugin(); + let releaseMutation!: () => void; + const mutationGate = new Promise((resolve) => { + releaseMutation = resolve; + }); + plugin.mutateSettings = jest.fn(async (mutation: (settings: any) => void) => { + await mutationGate; + mutation(plugin.settings); + }); + + const tab = createTab(createMockOptions({ plugin })); + tab.ui.permissionToggle = createMockPermissionToggle() as any; + + const updatePromise = updatePlanModeUI(tab, plugin, 'plan'); + await Promise.resolve(); + + expect(plugin.settings.permissionMode).toBe('yolo'); + expect(tab.ui.permissionToggle!.updateDisplay).not.toHaveBeenCalled(); + + releaseMutation(); + await updatePromise; + + expect(plugin.settings.permissionMode).toBe('plan'); + expect(tab.ui.permissionToggle!.updateDisplay).toHaveBeenCalledTimes(1); + expect(tab.dom.inputWrapper.hasClass('claudian-input-plan-mode')).toBe(true); + }); + + it('renders the in-memory permission mode when persistence fails after mutation', async () => { + const plugin = createMockPlugin(); + plugin.mutateSettings = jest.fn(async (mutation: (settings: any) => void) => { + mutation(plugin.settings); + throw new Error('persist failed'); + }); + const tab = createTab(createMockOptions({ plugin })); + tab.ui.permissionToggle = createMockPermissionToggle() as any; + + await expect(updatePlanModeUI(tab, plugin, 'plan')).rejects.toThrow('persist failed'); + + expect(plugin.settings.permissionMode).toBe('plan'); + expect(tab.ui.permissionToggle!.updateDisplay).toHaveBeenCalledTimes(1); + expect(tab.dom.inputWrapper.hasClass('claudian-input-plan-mode')).toBe(true); + }); + + it('resets to blank state when the new-conversation callback fires', () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + initializeTabControllers(tab, plugin, {} as any, createMockMcpManager()); + + // Simulate a bound tab + tab.lifecycleState = 'bound_cold'; + tab.conversationId = 'conv-1'; + + const convCtrlModule = jest.requireMock('@/features/chat/controllers/ConversationController') as { + ConversationController: jest.Mock; + }; + const callback = convCtrlModule.ConversationController.mock.calls.at(-1)?.[1]?.onNewConversation; + + expect(callback).toBeDefined(); + + callback(); + + expect(tab.lifecycleState).toBe('blank'); + expect(tab.conversationId).toBeNull(); + // Draft model is resolved via provider projection, not raw settings.model + expect(tab.draftModel).toBe(plugin.settings.savedProviderModel.claude); + expect(tab.serviceInitialized).toBe(false); + }); + + it('preserves codex provider on new session when tab was codex', () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + + const plugin = createMockPlugin(); + plugin.settings.savedProviderModel = { claude: 'claude-sonnet-4-5', codex: TEST_CODEX_MODEL }; + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + initializeTabControllers(tab, plugin, {} as any, createMockMcpManager()); + + // Simulate a bound Codex tab + tab.lifecycleState = 'bound_cold'; + tab.conversationId = 'conv-1'; + tab.providerId = 'codex'; + + const convCtrlModule = jest.requireMock('@/features/chat/controllers/ConversationController') as { + ConversationController: jest.Mock; + }; + const callback = convCtrlModule.ConversationController.mock.calls.at(-1)?.[1]?.onNewConversation; + + callback(); + + expect(tab.lifecycleState).toBe('blank'); + expect(tab.draftModel).toBe(TEST_CODEX_MODEL); + expect(tab.providerId).toBe('codex'); + }); + + it('cleans up the active runtime when resetting to a new blank session', () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + + const plugin = createMockPlugin(); + plugin.settings.savedProviderModel = { claude: 'claude-sonnet-4-5', codex: TEST_CODEX_MODEL }; + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + initializeTabControllers(tab, plugin, {} as any, createMockMcpManager()); + + const staleService = createMockClaudianService({ providerId: 'codex' }); + tab.lifecycleState = 'bound_active'; + tab.conversationId = 'conv-1'; + tab.providerId = 'codex'; + tab.service = staleService as any; + tab.serviceInitialized = true; + + const convCtrlModule = jest.requireMock('@/features/chat/controllers/ConversationController') as { + ConversationController: jest.Mock; + }; + const callback = convCtrlModule.ConversationController.mock.calls.at(-1)?.[1]?.onNewConversation; + + callback(); + + expect(staleService.cleanup).toHaveBeenCalledTimes(1); + expect(tab.service).toBeNull(); + expect(tab.serviceInitialized).toBe(false); + expect(tab.lifecycleState).toBe('blank'); + expect(tab.providerId).toBe('codex'); + expect(tab.draftModel).toBe(TEST_CODEX_MODEL); + }); + }); +}); + +describe('Tab - Activation/Deactivation', () => { + describe('activateTab', () => { + it('should show tab content', () => { + const options = createMockOptions(); + const tab = createTab(options); + + activateTab(tab); + + expect(tab.dom.contentEl.style.display).toBe('flex'); + }); + }); + + describe('deactivateTab', () => { + it('should hide tab content', () => { + const options = createMockOptions(); + const tab = createTab(options); + + // First activate, then deactivate + activateTab(tab); + deactivateTab(tab); + + expect(tab.dom.contentEl.style.display).toBe('none'); + }); + }); +}); + +describe('Tab - Event Wiring', () => { + describe('wireTabInputEvents', () => { + it('should register event listeners on input element', () => { + const options = createMockOptions(); + const tab = createTab(options); + + // Initialize minimal controllers needed + tab.controllers.inputController = { + sendMessage: jest.fn(), + cancelStreaming: jest.fn(), + } as any; + tab.controllers.selectionController = { + showHighlight: jest.fn(), + } as any; + + wireTabInputEvents(tab, options.plugin); + + // Check that event listeners were added (cast to any to access mock method) + const inputListeners = (tab.dom.inputEl as any).getEventListeners(); + expect(inputListeners.get('keydown')).toBeDefined(); + expect(inputListeners.get('input')).toBeDefined(); + }); + + it('should store cleanup functions for memory management', () => { + const options = createMockOptions(); + const tab = createTab(options); + + // Initialize minimal controllers + tab.controllers.inputController = { sendMessage: jest.fn() } as any; + tab.controllers.selectionController = { showHighlight: jest.fn() } as any; + + wireTabInputEvents(tab, options.plugin); + + expect(tab.dom.eventCleanups.length).toBe(3); // keydown, input, scroll + }); + }); +}); + +describe('Tab - Destruction', () => { + describe('destroyTab', () => { + it('should be an async function', async () => { + const options = createMockOptions(); + const tab = createTab(options); + + const result = destroyTab(tab); + + expect(result).toBeInstanceOf(Promise); + await result; // Should resolve without error + }); + + it('should call cleanup functions for event listeners', async () => { + const options = createMockOptions(); + const tab = createTab(options); + + const cleanup1 = jest.fn(); + const cleanup2 = jest.fn(); + tab.dom.eventCleanups = [cleanup1, cleanup2]; + + await destroyTab(tab); + + expect(cleanup1).toHaveBeenCalled(); + expect(cleanup2).toHaveBeenCalled(); + }); + + it('should clear eventCleanups array after cleanup', async () => { + const options = createMockOptions(); + const tab = createTab(options); + + tab.dom.eventCleanups = [jest.fn(), jest.fn()]; + + await destroyTab(tab); + + expect(tab.dom.eventCleanups.length).toBe(0); + }); + + it('should unsubscribe from ready state changes when tab is destroyed', async () => { + const unsubscribeFn = jest.fn(); + const mockOnReadyStateChange = jest.fn(() => unsubscribeFn); + + const runtimeModule = jest.requireMock('@/providers/claude/runtime/ClaudeChatRuntime') as { ClaudianService: jest.Mock }; + runtimeModule.ClaudianService.mockImplementationOnce(() => createMockClaudianService({ onReadyStateChange: mockOnReadyStateChange })); + + const options = createMockOptions(); + const tab = createTab(options); + initializeTabUI(tab, options.plugin); + + await initializeTabService(tab, options.plugin, options.mcpManager); + + expect(mockOnReadyStateChange).toHaveBeenCalled(); + + await destroyTab(tab); + + expect(unsubscribeFn).toHaveBeenCalled(); + }); + + it('should cleanup the runtime service', async () => { + const mockCleanup = jest.fn(); + const options = createMockOptions(); + const tab = createTab(options); + + tab.service = { + cleanup: mockCleanup, + } as any; + + await destroyTab(tab); + + expect(mockCleanup).toHaveBeenCalled(); + expect(tab.service).toBeNull(); + }); + + it('should cancel and await the active turn before removing the tab DOM', async () => { + const options = createMockOptions(); + const tab = createTab(options); + const cancel = jest.fn(); + const cleanup = jest.fn(); + const removeSpy = jest.spyOn(tab.dom.contentEl, 'remove'); + let resolveTurn!: () => void; + tab.session.activeTurn = new Promise((resolve) => { + resolveTurn = resolve; + }); + tab.state.isStreaming = true; + tab.service = { cancel, cleanup } as any; + + const destruction = destroyTab(tab); + await Promise.resolve(); + + expect(cancel).toHaveBeenCalled(); + expect(cleanup).toHaveBeenCalled(); + expect(removeSpy).not.toHaveBeenCalled(); + + resolveTurn(); + await destruction; + + expect(removeSpy).toHaveBeenCalled(); + expect(tab.service).toBeNull(); + }); + + it('should remove DOM element', async () => { + const options = createMockOptions(); + const tab = createTab(options); + const removeSpy = jest.spyOn(tab.dom.contentEl, 'remove'); + + await destroyTab(tab); + + expect(removeSpy).toHaveBeenCalled(); + }); + + it('should cleanup subagents', async () => { + const options = createMockOptions(); + const tab = createTab(options); + + const orphanAllActive = jest.fn(); + const clear = jest.fn(); + tab.services.subagentManager = { orphanAllActive, clear } as any; + + await destroyTab(tab); + + expect(orphanAllActive).toHaveBeenCalled(); + expect(clear).toHaveBeenCalled(); + }); + + it('should cleanup UI components', async () => { + const options = createMockOptions(); + const tab = createTab(options); + + const destroyFileContext = jest.fn(); + const destroySlashDropdown = jest.fn(); + const destroyInstructionMode = jest.fn(); + const cancelInstructionRefine = jest.fn(); + const cancelTitleGeneration = jest.fn(); + const destroyTodoPanel = jest.fn(); + const destroyResumeDropdown = jest.fn(); + + tab.controllers.inputController = { destroyResumeDropdown, dismissPendingApproval: jest.fn() } as any; + tab.ui.fileContextManager = { destroy: destroyFileContext } as any; + tab.ui.slashCommandDropdown = { destroy: destroySlashDropdown } as any; + tab.ui.instructionModeManager = { destroy: destroyInstructionMode } as any; + tab.services.instructionRefineService = { cancel: cancelInstructionRefine, resetConversation: jest.fn() } as any; + tab.services.titleGenerationService = { cancel: cancelTitleGeneration } as any; + tab.ui.statusPanel = { destroy: destroyTodoPanel } as any; + + await destroyTab(tab); + + expect(destroyResumeDropdown).toHaveBeenCalled(); + expect(destroyFileContext).toHaveBeenCalled(); + expect(destroySlashDropdown).toHaveBeenCalled(); + expect(destroyInstructionMode).toHaveBeenCalled(); + expect(cancelInstructionRefine).toHaveBeenCalled(); + expect(cancelTitleGeneration).toHaveBeenCalled(); + expect(destroyTodoPanel).toHaveBeenCalled(); + }); + }); +}); + +describe('Tab - Service Callbacks', () => { + describe('setupServiceCallbacks', () => { + function setupAutoTurnTest() { + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + const addMessageSpy = jest.spyOn(tab.state, 'addMessage'); + const addMessage = jest.fn(() => { + const msgEl = createMockEl(); + msgEl.createDiv({ cls: 'claudian-message-content' }); + return msgEl; + }); + const scrollToBottom = jest.fn(); + const handleStreamChunk = jest.fn().mockResolvedValue(undefined); + + Object.defineProperty(tab.dom.contentEl, 'isConnected', { + value: true, + writable: true, + configurable: true, + }); + + tab.renderer = { + addMessage, + renderContent: jest.fn(), + addTextCopyButton: jest.fn(), + scrollToBottom, + } as any; + tab.controllers.streamController = { + handleStreamChunk, + appendText: jest.fn().mockResolvedValue(undefined), + finalizeCurrentThinkingBlock: jest.fn().mockResolvedValue(undefined), + finalizeCurrentTextBlock: jest.fn().mockResolvedValue(undefined), + hideThinkingIndicator: jest.fn(), + } as any; + tab.controllers.inputController = { + handleApprovalRequest: jest.fn(), + dismissPendingApproval: jest.fn(), + handleAskUserQuestion: jest.fn(), + handleExitPlanMode: jest.fn(), + } as any; + tab.services.subagentManager = { + hasRunningSubagents: jest.fn().mockReturnValue(false), + resetStreamingState: jest.fn(), + } as any; + + const service = { + setApprovalCallback: jest.fn(), + setApprovalDismisser: jest.fn(), + setAskUserQuestionCallback: jest.fn(), + setExitPlanModeCallback: jest.fn(), + setSubagentHookProvider: jest.fn(), + setAutoTurnCallback: jest.fn(), + setPermissionModeSyncCallback: jest.fn(), + }; + tab.service = service as any; + + setupServiceCallbacks(tab, plugin); + + const autoTurnCallback = service.setAutoTurnCallback.mock.calls[0][0]; + return { tab, addMessageSpy, addMessage, handleStreamChunk, scrollToBottom, autoTurnCallback }; + } + + it('renders tool-only auto-triggered turns with a placeholder assistant message', async () => { + const { addMessageSpy, addMessage, handleStreamChunk, scrollToBottom, autoTurnCallback } = setupAutoTurnTest(); + + await autoTurnCallback({ + chunks: [ + { type: 'tool_result', id: 'task-1', content: 'done' }, + ], + metadata: {}, + }); + + expect(addMessageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + role: 'assistant', + content: '(background task completed)', + }) + ); + expect(addMessage).toHaveBeenCalled(); + expect(handleStreamChunk).toHaveBeenCalledWith( + { type: 'tool_result', id: 'task-1', content: 'done' }, + expect.objectContaining({ role: 'assistant' }) + ); + expect(scrollToBottom).toHaveBeenCalled(); + }); + + it('routes hidden async subagent auto-turn chunks without adding a placeholder message', async () => { + const { addMessageSpy, addMessage, handleStreamChunk, scrollToBottom, autoTurnCallback } = setupAutoTurnTest(); + + await autoTurnCallback({ + chunks: [ + { + type: 'async_subagent_result', + agentId: 'agent-1', + status: 'completed', + result: 'Done', + }, + ], + metadata: {}, + }); + + expect(handleStreamChunk).toHaveBeenCalledWith( + { + type: 'async_subagent_result', + agentId: 'agent-1', + status: 'completed', + result: 'Done', + }, + expect.objectContaining({ role: 'assistant' }) + ); + expect(addMessageSpy).not.toHaveBeenCalled(); + expect(addMessage).not.toHaveBeenCalled(); + expect(scrollToBottom).not.toHaveBeenCalled(); + }); + + it('skips auto-triggered rendering after the tab DOM is detached', async () => { + const { tab, addMessageSpy, addMessage, handleStreamChunk, scrollToBottom, autoTurnCallback } = setupAutoTurnTest(); + + (tab.dom.contentEl as any).isConnected = false; + await autoTurnCallback({ + chunks: [ + { type: 'text', content: 'Background result' }, + ], + metadata: {}, + }); + + expect(addMessageSpy).not.toHaveBeenCalled(); + expect(addMessage).not.toHaveBeenCalled(); + expect(handleStreamChunk).not.toHaveBeenCalled(); + expect(scrollToBottom).not.toHaveBeenCalled(); + }); + }); +}); + +describe('Tab - Title', () => { + describe('getTabTitle', () => { + it('should return "New Chat" for tab without conversation', () => { + const options = createMockOptions(); + const tab = createTab(options); + + const title = getTabTitle(tab, options.plugin); + + expect(title).toBe('New Chat'); + }); + + it('should return conversation title when available', () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue({ + id: 'conv-123', + title: 'My Conversation', + }), + }); + + const options = createMockOptions({ plugin }); + const tab = createTab(options); + tab.conversationId = 'conv-123'; + + const title = getTabTitle(tab, plugin); + + expect(title).toBe('My Conversation'); + }); + + it('should return "New Chat" when conversation has no title', () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue({ + id: 'conv-123', + title: null, + }), + }); + + const options = createMockOptions({ plugin }); + const tab = createTab(options); + tab.conversationId = 'conv-123'; + + const title = getTabTitle(tab, plugin); + + expect(title).toBe('New Chat'); + }); + }); +}); + +describe('Tab - UI Initialization', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('initializeTabUI', () => { + it('should create FileContextManager', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + expect(tab.ui.fileContextManager).toBeDefined(); + }); + + it('should wire FileContextManager to MCP service', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + expect(mockFileContextManager.setMcpManager).toHaveBeenCalledWith((options.plugin as any).mcpManager); + }); + + it('should create ImageContextManager', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + expect(tab.ui.imageContextManager).toBeDefined(); + }); + + it('should create a composer-owned context tray', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + expect(tab.ui.contextTray).toBeDefined(); + expect(tab.dom.contextRowEl.hasClass('has-content')).toBe(false); + }); + + it('should create SlashCommandDropdown', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + expect(tab.ui.slashCommandDropdown).toBeDefined(); + }); + + it('should create InstructionRefineService', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + expect(tab.services.instructionRefineService).toBeDefined(); + }); + + it('should create TitleGenerationService', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + expect(tab.services.titleGenerationService).toBeDefined(); + }); + + it('should create InstructionModeManager', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + expect(tab.ui.instructionModeManager).toBeDefined(); + }); + + it('should create and mount StatusPanel', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + expect(tab.ui.statusPanel).toBeDefined(); + expect(mockStatusPanel.mount).toHaveBeenCalledWith(tab.dom.statusPanelContainerEl); + }); + + it('should create input toolbar components', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + expect(tab.ui.modelSelector).toBeDefined(); + expect(tab.ui.thinkingBudgetSelector).toBeDefined(); + expect(tab.ui.contextUsageMeter).toBeDefined(); + expect(tab.ui.externalContextSelector).toBeDefined(); + expect(tab.ui.mcpServerSelector).toBeDefined(); + expect(tab.ui.permissionToggle).toBeDefined(); + }); + + it('should create bang-bash mode from provider UI config', () => { + const getEnhancedPathSpy = jest + .spyOn(envUtils, 'getEnhancedPath') + .mockReturnValue('/usr/bin'); + const plugin = createMockPlugin({ + settings: { + ...createMockPlugin().settings, + providerConfigs: { + claude: { enableBangBash: true }, + codex: { enabled: true }, + }, + }, + }); + const options = createMockOptions({ plugin }); + const tab = createTab(options); + + initializeTabUI(tab, plugin); + + expect(tab.ui.bangBashModeManager).toBeDefined(); + + getEnhancedPathSpy.mockRestore(); + }); + + it('should wire MCP server selector to MCP service', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + expect(mockMcpServerSelector.setMcpManager).toHaveBeenCalledWith((options.plugin as any).mcpManager); + }); + + it('should wire external context selector onChange', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + expect(mockExternalContextSelector.setOnChange).toHaveBeenCalled(); + }); + + it('should initialize persistent paths from settings', () => { + const plugin = createMockPlugin({ + settings: { + ...createMockPlugin().settings, + persistentExternalContextPaths: ['/path/1', '/path/2'], + }, + }); + const options = createMockOptions({ plugin }); + const tab = createTab(options); + + initializeTabUI(tab, plugin); + + expect(mockExternalContextSelector.setPersistentPaths).toHaveBeenCalledWith(['/path/1', '/path/2']); + }); + + it('should update ChatState callbacks for UI updates', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + // Verify callbacks are set by checking the state + expect(tab.state.callbacks.onUsageChanged).toBeDefined(); + expect(tab.state.callbacks.onTodosChanged).toBeDefined(); + }); + }); +}); + +describe('Tab - Controller Initialization', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('initializeTabControllers', () => { + it('should create MessageRenderer', () => { + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + expect(tab.renderer).toBeDefined(); + }); + + it('should create SelectionController', () => { + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + expect(tab.controllers.selectionController).toBeDefined(); + }); + + it('should include shared view controls in the selection focus scope', () => { + const options = createMockOptions(); + const tab = createTab(options); + const sharedFocusScopeEl = createMockEl(); + const mockComponent = { + getSharedSelectionFocusScopeEls: jest.fn(() => [sharedFocusScopeEl]), + } as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + expect(SelectionController).toHaveBeenCalledWith( + options.plugin.app, + tab.ui.contextTray, + tab.dom.inputEl, + undefined, + [tab.dom.contentEl, tab.dom.inputComposerEl, sharedFocusScopeEl], + ); + }); + + it('should create StreamController', () => { + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + expect(tab.controllers.streamController).toBeDefined(); + }); + + it('should create ConversationController', () => { + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + expect(tab.controllers.conversationController).toBeDefined(); + }); + + it('should forward rewind mode from renderer to ConversationController', async () => { + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + const { MessageRenderer } = jest.requireMock('@/features/chat/rendering/MessageRenderer') as { MessageRenderer: jest.Mock }; + const lastCall = MessageRenderer.mock.calls[MessageRenderer.mock.calls.length - 1]; + const rewindCallback = lastCall[3]; + + await rewindCallback('message-1', 'conversation'); + + expect(mockConversationController.rewind).toHaveBeenCalledWith('message-1', 'conversation'); + }); + + it('should create InputController', () => { + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + expect(tab.controllers.inputController).toBeDefined(); + }); + + it('should create and initialize NavigationController', () => { + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + expect(tab.controllers.navigationController).toBeDefined(); + expect(mockNavigationController.initialize).toHaveBeenCalled(); + }); + + it('should update SubagentManager with StreamController callback', () => { + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + // The subagent manager should have its callback set + expect(tab.services.subagentManager).toBeDefined(); + }); + + it('persists async subagent state changes when not streaming', async () => { + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + tab.state.currentConversationId = 'conv-1'; + tab.state.isStreaming = false; + + const setCallback = tab.services.subagentManager.setCallback as jest.Mock; + const callback = setCallback.mock.calls[0][0] as (subagent: any) => void; + + callback({ + id: 'task-1', + description: 'Background task', + mode: 'async', + asyncStatus: 'completed', + status: 'completed', + prompt: 'do work', + result: 'done', + toolCalls: [], + isExpanded: false, + }); + + // Wait one microtask so Promise chain from save(false) can run. + await Promise.resolve(); + + expect(mockStreamController.onAsyncSubagentStateChange).toHaveBeenCalled(); + expect(mockConversationController.save).toHaveBeenCalledWith(false); + }); + + it('does not persist async subagent state while main stream is active', async () => { + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + tab.state.currentConversationId = 'conv-1'; + tab.state.isStreaming = true; + + const setCallback = tab.services.subagentManager.setCallback as jest.Mock; + const callback = setCallback.mock.calls[0][0] as (subagent: any) => void; + + callback({ + id: 'task-1', + description: 'Background task', + mode: 'async', + asyncStatus: 'running', + status: 'running', + toolCalls: [], + isExpanded: false, + }); + + await Promise.resolve(); + + expect(mockConversationController.save).not.toHaveBeenCalled(); + }); + }); +}); + +describe('Tab - Event Handler Behavior', () => { + beforeEach(() => { + jest.clearAllMocks(); + Platform.isMacOS = true; + mockFileContextManager = createMockFileContextManager(); + mockSlashCommandDropdown = createMockSlashCommandDropdown(); + mockInstructionModeManager = createMockInstructionModeManager(); + mockBangBashModeManager = createMockBangBashModeManager(); + mockInputController = createMockInputController(); + mockSelectionController = createMockSelectionController(); + }); + + // Wire up a tab with all UI managers and controllers needed for keydown tests, + // then return the tab + a helper to fire keydown events. + function setupKeydownTab(overrides?: { + bangBashManager?: typeof mockBangBashModeManager; + }) { + const options = createMockOptions(); + const tab = createTab(options); + + tab.ui.bangBashModeManager = (overrides?.bangBashManager ?? mockBangBashModeManager) as any; + tab.ui.instructionModeManager = mockInstructionModeManager as any; + tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; + tab.ui.fileContextManager = mockFileContextManager as any; + tab.controllers.inputController = mockInputController as any; + tab.controllers.selectionController = mockSelectionController as any; + + wireTabInputEvents(tab, options.plugin); + + const listeners = (tab.dom.inputEl as any).getEventListeners(); + const fireKeydown = (event: Record) => listeners.get('keydown')[0](event); + + return { tab, options, listeners, fireKeydown }; + } + + describe('wireTabInputEvents - keydown handlers', () => { + it('should not pass keydown events to other handlers when bang-bash mode is active', () => { + const options = createMockOptions(); + const tab = createTab(options); + + tab.ui.bangBashModeManager = mockBangBashModeManager as any; + tab.ui.instructionModeManager = mockInstructionModeManager as any; + tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; + tab.ui.fileContextManager = mockFileContextManager as any; + tab.controllers.inputController = mockInputController as any; + tab.controllers.selectionController = mockSelectionController as any; + + mockBangBashModeManager.isActive.mockReturnValue(true); + + wireTabInputEvents(tab, options.plugin); + + const listeners = (tab.dom.inputEl as any).getEventListeners(); + const keydownHandler = listeners.get('keydown')[0]; + const event = { key: '#', preventDefault: jest.fn() }; + keydownHandler(event); + + expect(mockBangBashModeManager.handleKeydown).toHaveBeenCalled(); + expect(mockInstructionModeManager.handleTriggerKey).not.toHaveBeenCalled(); + expect(mockSlashCommandDropdown.handleKeydown).not.toHaveBeenCalled(); + expect(mockFileContextManager.handleMentionKeydown).not.toHaveBeenCalled(); + }); + + it('should suppress slash dropdown and mention handling on bang-bash enter/exit', () => { + const options = createMockOptions(); + const tab = createTab(options); + + let active = false; + tab.ui.bangBashModeManager = { + isActive: jest.fn(() => active), + handleTriggerKey: jest.fn((e: any) => { + active = true; + e.preventDefault(); + return true; + }), + handleKeydown: jest.fn((e: any) => { + if (!active) return false; + if (e.key === 'Escape') { + active = false; + e.preventDefault(); + return true; + } + return false; + }), + handleInputChange: jest.fn(), + destroy: jest.fn(), + } as any; + + tab.ui.instructionModeManager = mockInstructionModeManager as any; + tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; + tab.ui.fileContextManager = mockFileContextManager as any; + tab.controllers.inputController = mockInputController as any; + tab.controllers.selectionController = mockSelectionController as any; + + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValue(false); + + wireTabInputEvents(tab, options.plugin); + + const listeners = (tab.dom.inputEl as any).getEventListeners(); + const keydownHandler = listeners.get('keydown')[0]; + + keydownHandler({ key: '!', preventDefault: jest.fn() }); + expect(mockSlashCommandDropdown.setEnabled).toHaveBeenCalledWith(false); + expect(mockFileContextManager.hideMentionDropdown).toHaveBeenCalled(); + + keydownHandler({ key: 'Escape', preventDefault: jest.fn() }); + expect(mockSlashCommandDropdown.setEnabled).toHaveBeenCalledWith(true); + }); + + it('should handle instruction mode trigger key', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValueOnce(true); + const { fireKeydown } = setupKeydownTab(); + + fireKeydown({ key: '#', preventDefault: jest.fn() }); + + expect(mockInstructionModeManager.handleTriggerKey).toHaveBeenCalled(); + }); + + it('should handle instruction mode keydown', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValueOnce(true); + const { fireKeydown } = setupKeydownTab(); + + fireKeydown({ key: 'Tab', preventDefault: jest.fn() }); + + expect(mockInstructionModeManager.handleKeydown).toHaveBeenCalled(); + }); + + it('should handle slash command dropdown keydown', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValue(false); + mockSlashCommandDropdown.handleKeydown.mockReturnValueOnce(true); + const { fireKeydown } = setupKeydownTab(); + + fireKeydown({ key: 'ArrowDown', preventDefault: jest.fn() }); + + expect(mockSlashCommandDropdown.handleKeydown).toHaveBeenCalled(); + }); + + it('should let explicit Command+Enter send before slash dropdown handles Enter', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValue(false); + mockSlashCommandDropdown.handleKeydown.mockReturnValue(true); + mockFileContextManager.handleMentionKeydown.mockReturnValue(false); + const { fireKeydown } = setupKeydownTab(); + Platform.isMacOS = true; + + const event = { + key: 'Enter', + shiftKey: false, + ctrlKey: false, + metaKey: true, + altKey: false, + isComposing: false, + preventDefault: jest.fn(), + }; + fireKeydown(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockInputController.sendMessage).toHaveBeenCalled(); + expect(mockSlashCommandDropdown.handleKeydown).not.toHaveBeenCalled(); + }); + + it('should keep plain Enter routed to visible slash dropdown before sending', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValue(false); + mockSlashCommandDropdown.handleKeydown.mockReturnValue(true); + mockFileContextManager.handleMentionKeydown.mockReturnValue(false); + const { fireKeydown } = setupKeydownTab(); + + const event = { + key: 'Enter', + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false, + isComposing: false, + preventDefault: jest.fn(), + }; + fireKeydown(event); + + expect(mockSlashCommandDropdown.handleKeydown).toHaveBeenCalled(); + expect(mockInputController.sendMessage).not.toHaveBeenCalled(); + }); + + it('should handle resume dropdown keydown', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValue(false); + mockInputController.handleResumeKeydown.mockReturnValueOnce(true); + const { fireKeydown } = setupKeydownTab(); + + fireKeydown({ key: 'ArrowDown', preventDefault: jest.fn() }); + + expect(mockInputController.handleResumeKeydown).toHaveBeenCalled(); + expect(mockSlashCommandDropdown.handleKeydown).not.toHaveBeenCalled(); + expect(mockFileContextManager.handleMentionKeydown).not.toHaveBeenCalled(); + }); + + it('should handle file context mention keydown', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValue(false); + mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); + mockFileContextManager.handleMentionKeydown.mockReturnValueOnce(true); + const { fireKeydown } = setupKeydownTab(); + + fireKeydown({ key: 'ArrowUp', preventDefault: jest.fn() }); + + expect(mockFileContextManager.handleMentionKeydown).toHaveBeenCalled(); + }); + + it('should cancel streaming on Escape when streaming', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValue(false); + mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); + mockFileContextManager.handleMentionKeydown.mockReturnValue(false); + const { tab, fireKeydown } = setupKeydownTab(); + tab.state.isStreaming = true; + + const event = { key: 'Escape', isComposing: false, preventDefault: jest.fn() }; + fireKeydown(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockInputController.cancelStreaming).toHaveBeenCalled(); + }); + + it('should not cancel streaming on Escape when isComposing (IME)', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValue(false); + mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); + mockFileContextManager.handleMentionKeydown.mockReturnValue(false); + const { tab, fireKeydown } = setupKeydownTab(); + tab.state.isStreaming = true; + + const event = { key: 'Escape', isComposing: true, preventDefault: jest.fn() }; + fireKeydown(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mockInputController.cancelStreaming).not.toHaveBeenCalled(); + }); + + it('should send message on Enter (without Shift)', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValue(false); + mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); + mockFileContextManager.handleMentionKeydown.mockReturnValue(false); + const { fireKeydown } = setupKeydownTab(); + + const event = { key: 'Enter', shiftKey: false, isComposing: false, preventDefault: jest.fn() }; + fireKeydown(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(mockInputController.sendMessage).toHaveBeenCalled(); + }); + + it('should not send message on Shift+Enter (newline)', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValue(false); + mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); + mockFileContextManager.handleMentionKeydown.mockReturnValue(false); + const { fireKeydown } = setupKeydownTab(); + + const event = { key: 'Enter', shiftKey: true, isComposing: false, preventDefault: jest.fn() }; + fireKeydown(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mockInputController.sendMessage).not.toHaveBeenCalled(); + }); + + it('should require Command+Enter on macOS when the send shortcut setting is enabled', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValue(false); + mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); + mockFileContextManager.handleMentionKeydown.mockReturnValue(false); + const { options, fireKeydown } = setupKeydownTab(); + Platform.isMacOS = true; + options.plugin.settings.requireCommandOrControlEnterToSend = true; + + const enterEvent = { key: 'Enter', shiftKey: false, ctrlKey: false, metaKey: false, isComposing: false, preventDefault: jest.fn() }; + fireKeydown(enterEvent); + + expect(enterEvent.preventDefault).not.toHaveBeenCalled(); + expect(mockInputController.sendMessage).not.toHaveBeenCalled(); + + const controlEnterEvent = { key: 'Enter', shiftKey: false, ctrlKey: true, metaKey: false, isComposing: false, preventDefault: jest.fn() }; + fireKeydown(controlEnterEvent); + + expect(controlEnterEvent.preventDefault).not.toHaveBeenCalled(); + expect(mockInputController.sendMessage).not.toHaveBeenCalled(); + + const commandEnterEvent = { key: 'Enter', shiftKey: false, ctrlKey: false, metaKey: true, isComposing: false, preventDefault: jest.fn() }; + fireKeydown(commandEnterEvent); + + expect(commandEnterEvent.preventDefault).toHaveBeenCalled(); + expect(mockInputController.sendMessage).toHaveBeenCalled(); + }); + + it('should require Ctrl+Enter off macOS when the send shortcut setting is enabled', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValue(false); + mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); + mockFileContextManager.handleMentionKeydown.mockReturnValue(false); + const { options, fireKeydown } = setupKeydownTab(); + Platform.isMacOS = false; + options.plugin.settings.requireCommandOrControlEnterToSend = true; + + const commandEnterEvent = { key: 'Enter', shiftKey: false, ctrlKey: false, metaKey: true, isComposing: false, preventDefault: jest.fn() }; + fireKeydown(commandEnterEvent); + + expect(commandEnterEvent.preventDefault).not.toHaveBeenCalled(); + expect(mockInputController.sendMessage).not.toHaveBeenCalled(); + + const controlEnterEvent = { key: 'Enter', shiftKey: false, ctrlKey: true, metaKey: false, isComposing: false, preventDefault: jest.fn() }; + fireKeydown(controlEnterEvent); + + expect(controlEnterEvent.preventDefault).toHaveBeenCalled(); + expect(mockInputController.sendMessage).toHaveBeenCalled(); + }); + + it('should not send message on Enter when isComposing (IME)', () => { + mockInstructionModeManager.handleTriggerKey.mockReturnValue(false); + mockInstructionModeManager.handleKeydown.mockReturnValue(false); + mockSlashCommandDropdown.handleKeydown.mockReturnValue(false); + mockFileContextManager.handleMentionKeydown.mockReturnValue(false); + const { fireKeydown } = setupKeydownTab(); + + const event = { key: 'Enter', shiftKey: false, isComposing: true, preventDefault: jest.fn() }; + fireKeydown(event); + + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(mockInputController.sendMessage).not.toHaveBeenCalled(); + }); + }); + + describe('wireTabInputEvents - input handler', () => { + it('should trigger file context input change', () => { + const options = createMockOptions(); + const tab = createTab(options); + + tab.ui.fileContextManager = mockFileContextManager as any; + tab.ui.instructionModeManager = mockInstructionModeManager as any; + tab.controllers.inputController = mockInputController as any; + tab.controllers.selectionController = mockSelectionController as any; + + wireTabInputEvents(tab, options.plugin); + + const listeners = (tab.dom.inputEl as any).getEventListeners(); + const inputHandler = listeners.get('input')[0]; + inputHandler(); + + expect(mockFileContextManager.handleInputChange).toHaveBeenCalled(); + expect(mockInstructionModeManager.handleInputChange).toHaveBeenCalled(); + }); + }); + + describe('wireTabInputEvents - input handlers', () => { + it('should not call FileContextManager.handleInputChange when bang-bash mode is active', () => { + const options = createMockOptions(); + const tab = createTab(options); + + tab.ui.bangBashModeManager = mockBangBashModeManager as any; + tab.ui.instructionModeManager = mockInstructionModeManager as any; + tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any; + tab.ui.fileContextManager = mockFileContextManager as any; + + mockBangBashModeManager.isActive.mockReturnValue(true); + + wireTabInputEvents(tab, options.plugin); + + const listeners = (tab.dom.inputEl as any).getEventListeners(); + const inputHandler = listeners.get('input')[0]; + inputHandler(); + + expect(mockFileContextManager.handleInputChange).not.toHaveBeenCalled(); + expect(mockBangBashModeManager.handleInputChange).toHaveBeenCalled(); + }); + }); +}); + +describe('Tab - ChatState Callback Integration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should invoke onStreamingChanged callback when streaming state changes', () => { + const onStreamingChanged = jest.fn(); + const options = createMockOptions({ onStreamingChanged }); + const tab = createTab(options); + + // Trigger the callback through ChatState + tab.state.callbacks.onStreamingStateChanged?.(true); + + expect(onStreamingChanged).toHaveBeenCalledWith(true); + }); + + it('should invoke onAttentionChanged callback when attention state changes', () => { + const onAttentionChanged = jest.fn(); + const options = createMockOptions({ onAttentionChanged }); + const tab = createTab(options); + + // Trigger the callback through ChatState + tab.state.callbacks.onAttentionChanged?.(true); + + expect(onAttentionChanged).toHaveBeenCalledWith(true); + }); + + it('should invoke onConversationIdChanged callback when conversation changes', () => { + const onConversationIdChanged = jest.fn(); + const options = createMockOptions({ onConversationIdChanged }); + const tab = createTab(options); + + // Trigger the callback through ChatState + tab.state.callbacks.onConversationChanged?.('new-conv-id'); + + expect(onConversationIdChanged).toHaveBeenCalledWith('new-conv-id'); + }); +}); + +describe('Tab - UI Callback Wiring', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('initializeTabUI callbacks', () => { + it('should scroll to bottom when note context changes', () => { + const options = createMockOptions(); + const tab = createTab(options); + + // Initialize UI to wire callbacks + initializeTabUI(tab, options.plugin); + + // Set up renderer + tab.renderer = mockMessageRenderer as any; + + tab.ui.contextTray?.setItems('current-note', [{ + id: 'note', + kind: 'note', + label: 'Note.md', + }]); + + expect(mockMessageRenderer.scrollToBottomIfNeeded).toHaveBeenCalled(); + }); + + it('should scroll to bottom when image context changes', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + tab.renderer = mockMessageRenderer as any; + + tab.ui.contextTray?.setItems('images', [{ + id: 'image', + kind: 'image', + label: 'image.png', + }]); + + expect(mockMessageRenderer.scrollToBottomIfNeeded).toHaveBeenCalled(); + }); + + it('should wire getExcludedTags to return plugin settings', () => { + const plugin = createMockPlugin({ + settings: { + ...createMockPlugin().settings, + excludedTags: ['tag1', 'tag2'], + }, + }); + const options = createMockOptions({ plugin }); + const tab = createTab(options); + + initializeTabUI(tab, plugin); + + const { FileContextManager } = jest.requireMock('@/features/chat/ui/FileContext'); + const constructorCall = FileContextManager.mock.calls[0]; + const callbacks = constructorCall[3]; + + const excludedTags = callbacks.getExcludedTags(); + + expect(excludedTags).toEqual(['tag1', 'tag2']); + }); + + it('should wire getExternalContexts to return external context selector contexts', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + // Mock external context selector return value + mockExternalContextSelector.getExternalContexts.mockReturnValue(['/path/1', '/path/2']); + + const { FileContextManager } = jest.requireMock('@/features/chat/ui/FileContext'); + const constructorCall = FileContextManager.mock.calls[0]; + const callbacks = constructorCall[3]; + + const contexts = callbacks.getExternalContexts(); + + expect(contexts).toEqual(['/path/1', '/path/2']); + }); + + it('should wire MCP mention change to add servers to selector', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + // Get the setOnMcpMentionChange callback + const onMcpMentionChange = mockFileContextManager.setOnMcpMentionChange.mock.calls[0][0]; + + // Trigger with server list + onMcpMentionChange(['server1', 'server2']); + + expect(mockMcpServerSelector.addMentionedServers).toHaveBeenCalledWith(['server1', 'server2']); + }); + + it('should wire external context onChange to pre-scan contexts', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + // Get the setOnChange callback + const onChange = mockExternalContextSelector.setOnChange.mock.calls[0][0]; + + // Trigger onChange + onChange(); + + expect(mockFileContextManager.preScanExternalContexts).toHaveBeenCalled(); + }); + + it('should wire persistence change to save settings', async () => { + const saveSettings = jest.fn().mockResolvedValue(undefined); + const plugin = createMockPlugin({ saveSettings }); + const options = createMockOptions({ plugin }); + const tab = createTab(options); + + initializeTabUI(tab, plugin); + + // Get the setOnPersistenceChange callback + const onPersistenceChange = mockExternalContextSelector.setOnPersistenceChange.mock.calls[0][0]; + + // Trigger with new paths + await onPersistenceChange(['/new/path1', '/new/path2']); + + expect(plugin.settings.persistentExternalContextPaths).toEqual(['/new/path1', '/new/path2']); + expect(saveSettings).toHaveBeenCalled(); + }); + + it('should wire onUsageChanged callback to update context meter', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + // Verify callback is wired + const usage = { inputTokens: 1000, outputTokens: 500 }; + tab.state.callbacks.onUsageChanged?.(usage as any); + + expect(mockContextUsageMeter.update).toHaveBeenCalledWith(usage); + }); + + it('should update context meter for Codex tabs on usage change', () => { + const getCapabilitiesSpy = jest.spyOn(ProviderRegistry, 'getCapabilities'); + getCapabilitiesSpy.mockReturnValue({ + providerId: 'codex', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: false, + supportsRewind: false, + supportsFork: false, + supportsProviderCommands: false, + supportsImageAttachments: true, + supportsInstructionMode: false, + supportsMcpTools: false, + reasoningControl: 'none', + }); + + const options = createMockOptions({ + conversation: { + id: 'conv-codex', + providerId: 'codex', + title: 'Codex Conversation', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + }); + const tab = createTab(options); + initializeTabUI(tab, options.plugin); + + mockContextUsageMeter.update.mockClear(); + + const usage = { + inputTokens: 5000, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 1000, + contextWindow: 200000, + contextTokens: 6000, + percentage: 3, + }; + tab.state.callbacks.onUsageChanged?.(usage as any); + + expect(mockContextUsageMeter.update).toHaveBeenCalledWith(usage); + + getCapabilitiesSpy.mockRestore(); + }); + + it('should wire onTodosChanged callback to update todo panel', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + // Verify callback is wired + const todos = [{ id: '1', content: 'Test todo', status: 'pending' }]; + tab.state.callbacks.onTodosChanged?.(todos as any); + + expect(mockStatusPanel.updateTodos).toHaveBeenCalledWith(todos); + }); + + it('should wire instruction mode onSubmit to input controller', async () => { + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + // Get the InstructionModeManager constructor arguments + const { InstructionModeManager } = jest.requireMock('@/features/chat/ui/InstructionModeManager'); + const constructorCall = InstructionModeManager.mock.calls[0]; + const callbacks = constructorCall[1]; // 2nd argument is callbacks + + // Trigger onSubmit + await callbacks.onSubmit('refined instruction'); + + expect(mockInputController.handleInstructionSubmit).toHaveBeenCalledWith('refined instruction'); + }); + + it('should wire getInputWrapper to return input wrapper element', () => { + const options = createMockOptions(); + const tab = createTab(options); + + initializeTabUI(tab, options.plugin); + + const { InstructionModeManager } = jest.requireMock('@/features/chat/ui/InstructionModeManager'); + const constructorCall = InstructionModeManager.mock.calls[0]; + const callbacks = constructorCall[1]; + + const wrapper = callbacks.getInputWrapper(); + + expect(wrapper).toBe(tab.dom.inputWrapper); + }); + + it('should wire provider catalog config when provided in options', async () => { + const mockEntries = [{ + id: 'cmd-review', + providerId: 'claude' as const, + kind: 'command' as const, + name: 'review', + description: 'Review code', + content: '', + scope: 'vault' as const, + source: 'user' as const, + isEditable: true, + isDeletable: true, + displayPrefix: '/', + insertPrefix: '/', + }]; + const mockConfig = { providerId: 'claude' as const, triggerChars: ['/'], builtInPrefix: '/', skillPrefix: '/', commandPrefix: '/' }; + const plugin = createMockPlugin(); + const options = createMockOptions({ plugin }); + const tab = createTab(options); + + initializeTabUI(tab, plugin, { + getProviderCatalogConfig: () => ({ + config: mockConfig, + getEntries: jest.fn().mockResolvedValue(mockEntries), + }), + }); + + const { SlashCommandDropdown } = jest.requireMock('@/shared/components/SlashCommandDropdown'); + const constructorCall = SlashCommandDropdown.mock.calls[0]; + const opts = constructorCall[3]; // 4th argument is options + + expect(opts.providerConfig).toEqual(mockConfig); + expect(typeof opts.getProviderEntries).toBe('function'); + }); + + it('should wire provider-scoped hidden commands into the slash dropdown', () => { + const plugin = createMockPlugin({ + settings: { + excludedTags: [], + model: TEST_CODEX_MODEL, + thinkingBudget: 'low', + effortLevel: 'high', + permissionMode: 'yolo', + keyboardNavigation: { + scrollUpKey: 'k', + scrollDownKey: 'j', + focusInputKey: 'i', + }, + persistentExternalContextPaths: [], + settingsProvider: 'claude', + codexEnabled: true, + savedProviderModel: { + claude: 'claude-sonnet-4-5', + codex: TEST_CODEX_MODEL, + }, + savedProviderEffort: { + claude: 'high', + codex: 'medium', + }, + savedProviderThinkingBudget: { + claude: 'low', + codex: 'off', + }, + hiddenProviderCommands: { + claude: ['commit'], + codex: ['analyze'], + }, + }, + }); + const tab = createTab(createMockOptions({ plugin })); + + initializeTabUI(tab, plugin); + + const { SlashCommandDropdown } = jest.requireMock('@/shared/components/SlashCommandDropdown'); + const constructorCall = SlashCommandDropdown.mock.calls[0]; + const opts = constructorCall[3]; + + expect(Array.from(opts.hiddenCommands)).toEqual(['analyze']); + }); + }); +}); + +describe('Tab - Service Initialization Error Handling', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should skip re-initialization if already initialized', async () => { + const options = createMockOptions(); + const tab = createTab(options); + + // Mark as already initialized + tab.serviceInitialized = true; + const originalService = createMockClaudianService() as any; + tab.service = originalService; + + await initializeTabService(tab, options.plugin, options.mcpManager); + + // Should not change existing service + expect(tab.runtimeSupervisor.current).toBe(originalService); + expect(tab.service).toBe(originalService); + expect(tab.serviceInitialized).toBe(true); + }); + + it('should set serviceInitialized to true after successful initialization', async () => { + const options = createMockOptions(); + const tab = createTab(options); + + expect(tab.serviceInitialized).toBe(false); + expect(tab.service).toBeNull(); + + await initializeTabService(tab, options.plugin, options.mcpManager); + + expect(tab.serviceInitialized).toBe(true); + expect(tab.service).not.toBeNull(); + }); + +}); + +describe('Tab - Controller Configuration', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('InputController configuration', () => { + it('should wire ensureServiceInitialized to return true when already initialized and bound_active', async () => { + const { InputController } = jest.requireMock('@/features/chat/controllers/InputController'); + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + // Get InputController constructor config + const constructorCall = InputController.mock.calls[0]; + const config = constructorCall[0]; + + // Test ensureServiceInitialized when already initialized and bound_active + tab.serviceInitialized = true; + tab.lifecycleState = 'bound_active'; + const result = await config.ensureServiceInitialized(); + expect(result).toBe(true); + }); + + it('should wire getAgentService to return tab service', () => { + const { InputController } = jest.requireMock('@/features/chat/controllers/InputController'); + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + const constructorCall = InputController.mock.calls[0]; + const config = constructorCall[0]; + + // Verify getAgentService returns tab's service + tab.service = { id: 'test-service' } as any; + expect(config.getAgentService()).toBe(tab.service); + }); + + it('should wire getters to return tab UI components', () => { + const { InputController } = jest.requireMock('@/features/chat/controllers/InputController'); + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + const constructorCall = InputController.mock.calls[0]; + const config = constructorCall[0]; + + // Test getters return correct UI components + expect(config.getInputEl()).toBe(tab.dom.inputEl); + expect(config.getMessagesEl()).toBe(tab.dom.messagesEl); + expect(config.getFileContextManager()).toBe(tab.ui.fileContextManager); + expect(config.getImageContextManager()).toBe(tab.ui.imageContextManager); + expect(config.getMcpServerSelector()).toBe(tab.ui.mcpServerSelector); + expect(config.getExternalContextSelector()).toBe(tab.ui.externalContextSelector); + expect(config.getInstructionModeManager()).toBe(tab.ui.instructionModeManager); + expect(config.getInstructionRefineService()).toBe(tab.services.instructionRefineService); + expect(config.getTitleGenerationService()).toBe(tab.services.titleGenerationService); + }); + + }); + + describe('StreamController configuration', () => { + it('should wire updateQueueIndicator to input controller', () => { + const { StreamController } = jest.requireMock('@/features/chat/controllers/StreamController'); + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + const constructorCall = StreamController.mock.calls[0]; + const config = constructorCall[0]; + + config.updateQueueIndicator(); + + expect(mockInputController.updateQueueIndicator).toHaveBeenCalled(); + }); + + it('should wire getAgentService to return tab service', () => { + const { StreamController } = jest.requireMock('@/features/chat/controllers/StreamController'); + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + tab.service = { id: 'test-service' } as any; + + const constructorCall = StreamController.mock.calls[0]; + const config = constructorCall[0]; + + expect(config.getAgentService()).toBe(tab.service); + }); + + it('should wire getMessagesEl to return tab messages element', () => { + const { StreamController } = jest.requireMock('@/features/chat/controllers/StreamController'); + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + const constructorCall = StreamController.mock.calls[0]; + const config = constructorCall[0]; + + expect(config.getMessagesEl()).toBe(tab.dom.messagesEl); + }); + }); + + describe('NavigationController configuration', () => { + it('should wire shouldSkipEscapeHandling to check UI state', () => { + const { NavigationController } = jest.requireMock('@/features/chat/controllers/NavigationController'); + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + const constructorCall = NavigationController.mock.calls[0]; + const config = constructorCall[0]; + + // Test when instruction mode is active + mockInstructionModeManager.isActive.mockReturnValue(true); + expect(config.shouldSkipEscapeHandling()).toBe(true); + + // Test when slash command dropdown is visible + mockInstructionModeManager.isActive.mockReturnValue(false); + mockSlashCommandDropdown.isVisible.mockReturnValue(true); + expect(config.shouldSkipEscapeHandling()).toBe(true); + + // Test when mention dropdown is visible + mockSlashCommandDropdown.isVisible.mockReturnValue(false); + mockFileContextManager.isMentionDropdownVisible.mockReturnValue(true); + expect(config.shouldSkipEscapeHandling()).toBe(true); + + // Test when resume dropdown is visible + mockFileContextManager.isMentionDropdownVisible.mockReturnValue(false); + mockInputController.isResumeDropdownVisible.mockReturnValue(true); + expect(config.shouldSkipEscapeHandling()).toBe(true); + + // Test when nothing active + mockInputController.isResumeDropdownVisible.mockReturnValue(false); + expect(config.shouldSkipEscapeHandling()).toBe(false); + }); + + it('should wire isStreaming to return tab state', () => { + const { NavigationController } = jest.requireMock('@/features/chat/controllers/NavigationController'); + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + const constructorCall = NavigationController.mock.calls[0]; + const config = constructorCall[0]; + + tab.state.isStreaming = true; + expect(config.isStreaming()).toBe(true); + + tab.state.isStreaming = false; + expect(config.isStreaming()).toBe(false); + }); + + it('should wire getSettings to return keyboard navigation settings', () => { + const keyboardNavigation = { + scrollUpKey: 'k', + scrollDownKey: 'j', + focusInputKey: 'i', + }; + const plugin = createMockPlugin({ + settings: { + ...createMockPlugin().settings, + keyboardNavigation, + }, + }); + const { NavigationController } = jest.requireMock('@/features/chat/controllers/NavigationController'); + const options = createMockOptions({ plugin }); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, plugin); + initializeTabControllers(tab, plugin, mockComponent, options.mcpManager); + + const constructorCall = NavigationController.mock.calls[0]; + const config = constructorCall[0]; + + expect(config.getSettings()).toEqual(keyboardNavigation); + }); + }); + + describe('ConversationController configuration', () => { + it('should wire getHistoryDropdown to return null (tab has no dropdown)', () => { + const { ConversationController } = jest.requireMock('@/features/chat/controllers/ConversationController'); + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + const constructorCall = ConversationController.mock.calls[0]; + const config = constructorCall[0]; + + expect(config.getHistoryDropdown()).toBeNull(); + }); + + it('should wire welcome element getters and setters', () => { + const { ConversationController } = jest.requireMock('@/features/chat/controllers/ConversationController'); + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + const constructorCall = ConversationController.mock.calls[0]; + const config = constructorCall[0]; + + // Test getter - use mock element + const mockWelcome = { id: 'welcome-el' } as any; + tab.dom.welcomeEl = mockWelcome; + expect(config.getWelcomeEl()).toBe(mockWelcome); + + // Test setter + const newWelcomeEl = { id: 'new-welcome-el' } as any; + config.setWelcomeEl(newWelcomeEl); + expect(tab.dom.welcomeEl).toBe(newWelcomeEl); + }); + + it('should reset slash-command cache across conversation lifecycle events', () => { + const { ConversationController } = jest.requireMock('@/features/chat/controllers/ConversationController'); + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + const constructorCall = ConversationController.mock.calls[0]; + const callbacks = constructorCall[1]; + + callbacks.onNewConversation(); + callbacks.onConversationLoaded(); + callbacks.onConversationSwitched(); + + expect(mockSlashCommandDropdown.resetSdkSkillsCache).toHaveBeenCalledTimes(3); + }); + }); +}); + +const mockNotice = Notice as jest.Mock; + +describe('Tab - handleForkRequest', () => { + + function setupForkTest(overrides: Record = {}) { + const options = createMockOptions(overrides); + const tab = createTab(options); + const mockComponent = {} as any; + const forkRequestCallback = jest.fn().mockResolvedValue(undefined); + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager, forkRequestCallback); + + // Extract the fork callback from the MessageRenderer constructor + const { MessageRenderer } = jest.requireMock('@/features/chat/rendering/MessageRenderer') as { MessageRenderer: jest.Mock }; + const lastCall = MessageRenderer.mock.calls[MessageRenderer.mock.calls.length - 1]; + const forkCallback = lastCall[4]; // 5th argument is forkCallback + + return { tab, forkCallback, forkRequestCallback, plugin: options.plugin }; + } + + beforeEach(() => { + mockNotice.mockClear(); + }); + + it('should show notice when streaming', async () => { + const { tab, forkCallback } = setupForkTest(); + + tab.state.isStreaming = true; + tab.state.messages = [ + { id: 'u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'user-u' }, + ]; + + await forkCallback('u1'); + + expect(mockNotice).toHaveBeenCalled(); + }); + + it('should show notice when message ID not found', async () => { + const { forkCallback, forkRequestCallback } = setupForkTest(); + + await forkCallback('nonexistent'); + + expect(forkRequestCallback).not.toHaveBeenCalled(); + expect(mockNotice).toHaveBeenCalledWith('Fork failed: Message not found'); + }); + + it('should show notice when user message has no userMessageId', async () => { + const { tab, forkCallback, forkRequestCallback } = setupForkTest(); + + tab.state.messages = [ + { id: 'u1', role: 'user', content: 'hello', timestamp: 1 }, + ]; + + await forkCallback('u1'); + + expect(mockNotice).toHaveBeenCalled(); + expect(forkRequestCallback).not.toHaveBeenCalled(); + }); + + it('should show notice when no assistant response follows the user message', async () => { + const { tab, forkCallback, forkRequestCallback } = setupForkTest(); + + // User message without a following assistant response with UUID + tab.state.messages = [ + { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' }, + { id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' }, + // No assistant response after u1 + ]; + + await forkCallback('u1'); + + expect(mockNotice).toHaveBeenCalled(); + expect(forkRequestCallback).not.toHaveBeenCalled(); + }); + + it('should show notice when no session ID is available', async () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue(null), + }); + const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); + + tab.state.messages = [ + { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' }, + { id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' }, + ]; + // No service and no conversation + tab.service = null; + + await forkCallback('u1'); + + expect(mockNotice).toHaveBeenCalled(); + expect(forkRequestCallback).not.toHaveBeenCalled(); + }); + + it('should call forkRequestCallback with correct ForkContext on success', async () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue({ + title: 'My Conversation', + currentNote: 'notes/test.md', + }), + }); + const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); + + tab.state.messages = [ + { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' }, + { id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' }, + { id: 'u2', role: 'user', content: 'world', timestamp: 4, userMessageId: 'user-u2' }, + { id: 'a2', role: 'assistant', content: 'resp2', timestamp: 5, assistantMessageId: 'asst-2' }, + ]; + + // Service has a session ID + tab.service = { + getSessionId: jest.fn().mockReturnValue('session-abc'), + resolveSessionIdForFork: jest.fn().mockReturnValue('session-abc'), + } as any; + tab.conversationId = 'conv-1'; + + await forkCallback('u2'); + + expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ + sourceSessionId: 'session-abc', + resumeAt: 'asst-1', // prev assistant UUID before u2 + sourceTitle: 'My Conversation', + currentNote: 'notes/test.md', + forkAtUserMessage: 2, // u2 is the 2nd user message + })); + + // Messages should be deep-cloned and sliced before the fork point + const ctx = forkRequestCallback.mock.calls[0][0]; + expect(ctx.messages).toHaveLength(3); // a0, u1, a1 (before u2) + expect(ctx.messages.map((m: any) => m.id)).toEqual(['a0', 'u1', 'a1']); + }); + + it('should fall back to conversation session ID when service has none', async () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue({ + providerState: { providerSessionId: 'conv-session-xyz' }, + title: 'Fallback Chat', + }), + }); + const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); + + tab.state.messages = [ + { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' }, + { id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' }, + ]; + tab.service = null; + tab.conversationId = 'conv-1'; + + await forkCallback('u1'); + + expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ + sourceSessionId: 'conv-session-xyz', + })); + }); + + it('should produce deep-cloned messages that do not share references with originals', async () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue({ title: 'Test' }), + }); + const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); + + const originalMsg = { id: 'a0', role: 'assistant' as const, content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' }; + tab.state.messages = [ + originalMsg, + { id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' }, + ]; + tab.service = { getSessionId: jest.fn().mockReturnValue('session-1'), resolveSessionIdForFork: jest.fn().mockReturnValue('session-1') } as any; + tab.conversationId = 'conv-1'; + + await forkCallback('u1'); + + const ctx = forkRequestCallback.mock.calls[0][0]; + // Deep clone should not share references + expect(ctx.messages[0]).not.toBe(originalMsg); + expect(ctx.messages[0]).toEqual(originalMsg); + }); + + it('should fork at first user message with empty messages before fork', async () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue({ title: 'First Fork' }), + }); + const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); + + tab.state.messages = [ + { id: 'u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'user-u1' }, + { id: 'a1', role: 'assistant', content: 'hi', timestamp: 2, assistantMessageId: 'asst-1' }, + ]; + tab.service = { getSessionId: jest.fn().mockReturnValue('session-1'), resolveSessionIdForFork: jest.fn().mockReturnValue('session-1') } as any; + tab.conversationId = 'conv-1'; + + await forkCallback('u1'); + + // No assistant message before u1, so findRewindContext returns no prevAssistantUuid + expect(forkRequestCallback).not.toHaveBeenCalled(); + expect(mockNotice).toHaveBeenCalled(); + }); + + it('should fall back to conversation forkSource.sessionId when no sessionId or providerSessionId', async () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue({ + title: 'Nested Fork', + providerState: { forkSource: { sessionId: 'original-source-session', resumeAt: 'asst-prev' } }, + }), + }); + const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); + + tab.state.messages = [ + { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' }, + { id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' }, + ]; + tab.service = null; + tab.conversationId = 'conv-1'; + + await forkCallback('u1'); + + expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ + sourceSessionId: 'original-source-session', + })); + }); + + it('should prefer service session ID over conversation metadata', async () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue({ + title: 'Test', + providerState: { providerSessionId: 'conv-session' }, + sessionId: 'old-session', + }), + }); + const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); + + tab.state.messages = [ + { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' }, + { id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' }, + ]; + tab.service = { getSessionId: jest.fn().mockReturnValue('service-session'), resolveSessionIdForFork: jest.fn().mockReturnValue('service-session') } as any; + tab.conversationId = 'conv-1'; + + await forkCallback('u1'); + + expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ + sourceSessionId: 'service-session', + })); + }); + + it('should set forkAtUserMessage to 1 for the first user message', async () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue({ title: 'Test' }), + }); + const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin }); + + tab.state.messages = [ + { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' }, + { id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u1' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' }, + ]; + tab.service = { getSessionId: jest.fn().mockReturnValue('session-1'), resolveSessionIdForFork: jest.fn().mockReturnValue('session-1') } as any; + tab.conversationId = 'conv-1'; + + await forkCallback('u1'); + + expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ + forkAtUserMessage: 1, + })); + }); + + it('should not set forkCallback on renderer when no forkRequestCallback provided', () => { + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + const { MessageRenderer } = jest.requireMock('@/features/chat/rendering/MessageRenderer') as { MessageRenderer: jest.Mock }; + const lastCall = MessageRenderer.mock.calls[MessageRenderer.mock.calls.length - 1]; + const forkCallback = lastCall[4]; + + expect(forkCallback).toBeUndefined(); + }); +}); + +describe('Tab - handleForkAll (via /fork command)', () => { + + function setupForkAllTest(overrides: Record = {}) { + const options = createMockOptions(overrides); + const tab = createTab(options); + const mockComponent = {} as any; + const forkRequestCallback = jest.fn().mockResolvedValue(undefined); + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager, forkRequestCallback); + + // Extract onForkAll from InputController constructor call + const { InputController } = jest.requireMock('@/features/chat/controllers/InputController') as { InputController: jest.Mock }; + const lastCall = InputController.mock.calls[InputController.mock.calls.length - 1]; + const config = lastCall[0]; + const onForkAll = config.onForkAll as (() => Promise) | undefined; + + return { tab, onForkAll: onForkAll!, forkRequestCallback, plugin: options.plugin }; + } + + beforeEach(() => { + mockNotice.mockClear(); + }); + + it('should call forkRequestCallback with all messages and last assistant UUID', async () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue({ + title: 'My Conversation', + currentNote: 'notes/test.md', + }), + }); + const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin }); + + tab.state.messages = [ + { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' }, + { id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u1' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' }, + { id: 'u2', role: 'user', content: 'world', timestamp: 4, userMessageId: 'user-u2' }, + { id: 'a2', role: 'assistant', content: 'resp2', timestamp: 5, assistantMessageId: 'asst-2' }, + ]; + tab.service = { getSessionId: jest.fn().mockReturnValue('session-abc'), resolveSessionIdForFork: jest.fn().mockReturnValue('session-abc') } as any; + tab.conversationId = 'conv-1'; + + await onForkAll(); + + expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ + sourceSessionId: 'session-abc', + resumeAt: 'asst-2', // last assistant UUID + sourceTitle: 'My Conversation', + currentNote: 'notes/test.md', + })); + + const ctx = forkRequestCallback.mock.calls[0][0]; + expect(ctx.messages).toHaveLength(5); // all messages + expect(ctx.messages.map((m: any) => m.id)).toEqual(['a0', 'u1', 'a1', 'u2', 'a2']); + expect(ctx.forkAtUserMessage).toBe(3); // 2 user messages + 1 + }); + + it('should include trailing user + interrupt messages and not count interrupt for fork number', async () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue({ + title: 'My Conversation', + currentNote: 'notes/test.md', + }), + }); + const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin }); + + tab.state.messages = [ + { id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' }, + { id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u1' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' }, + { id: 'u2', role: 'user', content: 'world', timestamp: 4, userMessageId: 'user-u2' }, + { id: 'a2', role: 'assistant', content: 'resp2', timestamp: 5, assistantMessageId: 'asst-2' }, + { id: 'u3', role: 'user', content: 'more', timestamp: 6, userMessageId: 'user-u3' }, + { id: 'int-1', role: 'user', content: '[Request interrupted by user]', timestamp: 7, userMessageId: 'user-int', isInterrupt: true }, + ]; + tab.service = { getSessionId: jest.fn().mockReturnValue('session-abc'), resolveSessionIdForFork: jest.fn().mockReturnValue('session-abc') } as any; + tab.conversationId = 'conv-1'; + + await onForkAll(); + + expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ + sourceSessionId: 'session-abc', + resumeAt: 'asst-2', + forkAtUserMessage: 4, // u1, u2, u3 + 1 (interrupt excluded) + })); + + const ctx = forkRequestCallback.mock.calls[0][0]; + expect(ctx.messages).toHaveLength(7); + expect(ctx.messages.map((m: any) => m.id)).toEqual(['a0', 'u1', 'a1', 'u2', 'a2', 'u3', 'int-1']); + }); + + it('should show notice when streaming', async () => { + const { tab, onForkAll, forkRequestCallback } = setupForkAllTest(); + + tab.state.isStreaming = true; + tab.state.messages = [ + { id: 'u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'user-u' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 2, assistantMessageId: 'asst-1' }, + ]; + + await onForkAll(); + + expect(mockNotice).toHaveBeenCalled(); + expect(forkRequestCallback).not.toHaveBeenCalled(); + }); + + it('should show notice when no messages', async () => { + const { tab, onForkAll, forkRequestCallback } = setupForkAllTest(); + + tab.state.messages = []; + + await onForkAll(); + + expect(mockNotice).toHaveBeenCalledWith('Cannot fork: no messages in conversation'); + expect(forkRequestCallback).not.toHaveBeenCalled(); + }); + + it('should show notice when no assistant message has assistantMessageId', async () => { + const { tab, onForkAll, forkRequestCallback } = setupForkAllTest(); + + tab.state.messages = [ + { id: 'u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'user-u' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 2 }, + ]; + + await onForkAll(); + + expect(mockNotice).toHaveBeenCalledWith('Cannot fork: no assistant response with identifiers'); + expect(forkRequestCallback).not.toHaveBeenCalled(); + }); + + it('should show notice when no session ID is available', async () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue(null), + }); + const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin }); + + tab.state.messages = [ + { id: 'u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'user-u' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 2, assistantMessageId: 'asst-1' }, + ]; + tab.service = null; + + await onForkAll(); + + expect(mockNotice).toHaveBeenCalled(); + expect(forkRequestCallback).not.toHaveBeenCalled(); + }); + + it('should fall back to conversation session ID when service has none', async () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue({ + providerState: { providerSessionId: 'conv-session-xyz' }, + title: 'Fallback Chat', + }), + }); + const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin }); + + tab.state.messages = [ + { id: 'u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'user-u' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 2, assistantMessageId: 'asst-1' }, + ]; + tab.service = null; + tab.conversationId = 'conv-1'; + + await onForkAll(); + + expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({ + sourceSessionId: 'conv-session-xyz', + })); + }); + + it('should deep-clone messages (not share references)', async () => { + const plugin = createMockPlugin({ + getConversationSync: jest.fn().mockReturnValue({ title: 'Test' }), + }); + const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin }); + + const originalMsg = { id: 'a0', role: 'assistant' as const, content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' }; + tab.state.messages = [ + originalMsg, + { id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' }, + { id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' }, + ]; + tab.service = { getSessionId: jest.fn().mockReturnValue('session-1'), resolveSessionIdForFork: jest.fn().mockReturnValue('session-1') } as any; + tab.conversationId = 'conv-1'; + + await onForkAll(); + + const ctx = forkRequestCallback.mock.calls[0][0]; + expect(ctx.messages[0]).not.toBe(originalMsg); + expect(ctx.messages[0]).toEqual(originalMsg); + }); + + it('should not set onForkAll on InputController when no forkRequestCallback provided', () => { + const options = createMockOptions(); + const tab = createTab(options); + const mockComponent = {} as any; + + initializeTabUI(tab, options.plugin); + initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager); + + const { InputController } = jest.requireMock('@/features/chat/controllers/InputController') as { InputController: jest.Mock }; + const lastCall = InputController.mock.calls[InputController.mock.calls.length - 1]; + const config = lastCall[0]; + expect(config.onForkAll).toBeUndefined(); + }); +}); + +describe('Tab - Blank Tab Model Selector', () => { + afterEach(() => { + ProviderWorkspaceRegistry.clear(); + jest.restoreAllMocks(); + }); + + it('returns Claude-only models when Codex is disabled', () => { + const claudeModels = [ + { value: 'haiku', label: 'Haiku' }, + { value: 'sonnet', label: 'Sonnet' }, + ]; + jest.spyOn(ProviderRegistry, 'getEnabledProviderIds').mockReturnValue(['claude']); + jest.spyOn(ProviderRegistry, 'getProviderDisplayName').mockImplementation((providerId) => ( + providerId === 'claude' ? 'Claude' : 'Codex' + )); + jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockImplementation((providerId?: string) => ({ + getModelOptions: () => providerId === 'claude' ? claudeModels : [], + getProviderIcon: jest.fn().mockReturnValue(null), + } as any)); + + const result = getBlankTabModelOptions({ codexEnabled: false }); + expect(result).toEqual(claudeModels.map(m => ({ ...m, group: 'Claude' }))); + }); + + it('returns Claude + Codex models when Codex is enabled', () => { + const claudeModels = [ + { value: 'haiku', label: 'Haiku' }, + { value: 'sonnet', label: 'Sonnet' }, + ]; + const codexModels = [ + { value: TEST_CODEX_MODEL, label: TEST_CODEX_MODEL_LABEL }, + ]; + + jest.spyOn(ProviderRegistry, 'getEnabledProviderIds').mockReturnValue(['codex', 'claude']); + jest.spyOn(ProviderRegistry, 'getProviderDisplayName').mockImplementation((providerId) => ( + providerId === 'codex' ? 'Codex' : 'Claude' + )); + jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockImplementation((providerId?: string) => ({ + getModelOptions: () => providerId === 'codex' ? codexModels : claudeModels, + getProviderIcon: jest.fn().mockReturnValue(null), + } as any)); + + const result = getBlankTabModelOptions({ codexEnabled: true }); + expect(result).toEqual([ + ...codexModels.map(m => ({ ...m, group: 'Codex' })), + ...claudeModels.map(m => ({ ...m, group: 'Claude' })), + ]); + }); + + it('includes Codex models for blank tabs even when saved provider state is Claude-only', () => { + const result = getBlankTabModelOptions({ + settingsProvider: 'claude', + model: 'haiku', + savedProviderModel: { + claude: 'haiku', + }, + providerConfigs: { + claude: {}, + codex: { + enabled: true, + discoveredModels: TEST_CODEX_CATALOG, + }, + }, + }); + + expect(result).toEqual(expect.arrayContaining([ + expect.objectContaining({ + value: TEST_CODEX_MODEL, + group: 'Codex', + }), + ])); + }); +}); + +describe('Tab - Cross-Provider Model Rejection', () => { + it('rejects cross-provider model change on bound tab via toolbar onModelChange', async () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + + // Simulate bound Claude tab + tab.lifecycleState = 'bound_cold'; + tab.providerId = 'claude'; + tab.conversationId = 'conv-1'; + + // Get the onModelChange callback from toolbar + const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as { + createInputToolbar: jest.Mock; + }; + const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1]; + expect(toolbarCallbacks).toBeDefined(); + + // Attempt cross-provider model change (Claude -> Codex) + await toolbarCallbacks.onModelChange(TEST_CODEX_MODEL); + + // Should show a Notice rejecting it + expect(Notice).toHaveBeenCalledWith(expect.stringContaining('Cannot switch provider')); + // Provider should remain Claude + expect(tab.providerId).toBe('claude'); + }); + + it('allows same-provider model change on bound tab', async () => { + (Notice as unknown as jest.Mock).mockClear(); + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockReturnValue({ + getModelOptions: jest.fn().mockReturnValue([]), + ownsModel: jest.fn((model: string) => model.startsWith('gpt-') || /^o\d/.test(model)), + isAdaptiveReasoningModel: jest.fn().mockReturnValue(false), + getReasoningOptions: jest.fn().mockReturnValue([]), + getDefaultReasoningValue: jest.fn().mockReturnValue('off'), + getContextWindowSize: jest.fn().mockReturnValue(200000), + isDefaultModel: jest.fn().mockReturnValue(false), + applyModelDefaults: jest.fn(), + normalizeModelVariant: jest.fn((model: string) => model), + getCustomModelIds: jest.fn().mockReturnValue(new Set()), + } as any); + + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + + // Simulate bound Claude tab + tab.lifecycleState = 'bound_cold'; + tab.providerId = 'claude'; + tab.conversationId = 'conv-1'; + + const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as { + createInputToolbar: jest.Mock; + }; + const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1]; + + // Same-provider model change (Claude -> Claude) + await toolbarCallbacks.onModelChange('opus'); + + expect(Notice).not.toHaveBeenCalled(); + expect(plugin.updateConversation).toHaveBeenCalledWith('conv-1', { + selectedModel: 'opus', + }); + expect(plugin.saveSettings).not.toHaveBeenCalled(); + }); +}); + +describe('Tab - Blank Tab Draft Model Change', () => { + it('updates draft model and provider without creating runtime', async () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockReturnValue({ + getModelOptions: jest.fn().mockReturnValue([]), + ownsModel: jest.fn((model: string) => model.startsWith('gpt-') || /^o\d/.test(model)), + isAdaptiveReasoningModel: jest.fn().mockReturnValue(false), + getReasoningOptions: jest.fn().mockReturnValue([]), + getDefaultReasoningValue: jest.fn().mockReturnValue('off'), + getContextWindowSize: jest.fn().mockReturnValue(200000), + isDefaultModel: jest.fn().mockReturnValue(false), + applyModelDefaults: jest.fn(), + normalizeModelVariant: jest.fn((model: string) => model), + getCustomModelIds: jest.fn().mockReturnValue(new Set()), + } as any); + + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + + expect(tab.lifecycleState).toBe('blank'); + expect(tab.service).toBeNull(); + + const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as { + createInputToolbar: jest.Mock; + }; + const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1]; + + // Switch to Codex model on blank tab + await toolbarCallbacks.onModelChange(TEST_CODEX_MODEL); + + expect(tab.draftModel).toBe(TEST_CODEX_MODEL); + expect(tab.providerId).toBe('codex'); + // No runtime should have been created + expect(tab.service).toBeNull(); + expect(tab.serviceInitialized).toBe(false); + expect(tab.lifecycleState).toBe('blank'); + }); + + it('refreshes the service-tier toggle when the model changes on a blank tab', async () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockReturnValue({ + getModelOptions: jest.fn().mockReturnValue([]), + ownsModel: jest.fn((model: string) => model.startsWith('gpt-') || /^o\d/.test(model)), + isAdaptiveReasoningModel: jest.fn().mockReturnValue(false), + getReasoningOptions: jest.fn().mockReturnValue([]), + getDefaultReasoningValue: jest.fn().mockReturnValue('off'), + getContextWindowSize: jest.fn().mockReturnValue(200000), + isDefaultModel: jest.fn().mockReturnValue(false), + applyModelDefaults: jest.fn(), + normalizeModelVariant: jest.fn((model: string) => model), + getCustomModelIds: jest.fn().mockReturnValue(new Set()), + } as any); + + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + + const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as { + createInputToolbar: jest.Mock; + }; + const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1]; + + mockServiceTierToggle.updateDisplay.mockClear(); + + await toolbarCallbacks.onModelChange(TEST_CODEX_MODEL); + + expect(mockServiceTierToggle.updateDisplay).toHaveBeenCalled(); + }); + + it('awaits async provider warmup callbacks before resolving blank-tab provider changes', async () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + let releaseWarmup!: () => void; + const onProviderChanged = jest.fn().mockImplementation(() => new Promise((resolve) => { + releaseWarmup = resolve; + })); + initializeTabUI(tab, plugin, { onProviderChanged }); + + const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as { + createInputToolbar: jest.Mock; + }; + const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1]; + + let settled = false; + const changePromise = toolbarCallbacks.onModelChange('gpt-5.4') + .then(() => { settled = true; }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(onProviderChanged).toHaveBeenCalledWith('codex'); + expect(settled).toBe(false); + + releaseWarmup(); + await changePromise; + + expect(settled).toBe(true); + }); + + it('does not trigger provider warmup when a blank-tab model switch stays on OpenCode', async () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + jest.spyOn(ProviderRegistry, 'resolveProviderForModel').mockImplementation((model: string) => { + if (model.startsWith('opencode:')) { + return 'opencode'; + } + if (model.startsWith('gpt-') || /^o\d/.test(model)) { + return 'codex'; + } + return 'claude'; + }); + + const plugin = createMockPlugin(); + plugin.settings.providerConfigs = { + opencode: { + enabled: true, + }, + }; + plugin.settings.savedProviderModel = { + ...plugin.settings.savedProviderModel, + opencode: 'opencode:openai/gpt-5', + }; + + const tab = createTab(createMockOptions({ + draftModel: 'opencode:openai/gpt-5', + plugin, + })); + + let releaseWarmup!: () => void; + const onProviderChanged = jest.fn().mockImplementation(() => new Promise((resolve) => { + releaseWarmup = resolve; + })); + initializeTabUI(tab, plugin, { onProviderChanged }); + + const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as { + createInputToolbar: jest.Mock; + }; + const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1]; + + let settled = false; + const changePromise = toolbarCallbacks.onModelChange('opencode:anthropic/claude-sonnet-4') + .then(() => { settled = true; }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(tab.providerId).toBe('opencode'); + expect(tab.draftModel).toBe('opencode:anthropic/claude-sonnet-4'); + expect(onProviderChanged).not.toHaveBeenCalled(); + + await changePromise; + expect(settled).toBe(true); + + if (releaseWarmup) { + releaseWarmup(); + } + }); + + it('preserves the saved Codex fast preference when switching away and back', async () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + + const plugin = createMockPlugin(); + plugin.settings.settingsProvider = 'codex'; + plugin.settings.model = TEST_CODEX_MODEL; + plugin.settings.effortLevel = 'medium'; + plugin.settings.serviceTier = 'fast'; + plugin.settings.savedProviderModel = { + claude: 'claude-sonnet-4-5', + codex: TEST_CODEX_MODEL, + }; + plugin.settings.savedProviderEffort = { + claude: 'high', + codex: 'medium', + }; + plugin.settings.savedProviderServiceTier = { + claude: 'default', + codex: 'fast', + }; + plugin.settings.savedProviderThinkingBudget = { + claude: 'low', + codex: 'off', + }; + + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + + const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as { + createInputToolbar: jest.Mock; + }; + const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1]; + + await toolbarCallbacks.onModelChange('gpt-5.4-mini'); + expect(plugin.settings.savedProviderServiceTier.codex).toBe('fast'); + + await toolbarCallbacks.onModelChange(TEST_CODEX_MODEL); + expect(plugin.settings.savedProviderServiceTier.codex).toBe('fast'); + }); + + it('swaps dropdown provider catalog on blank tab model change', async () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockReturnValue({ + getModelOptions: jest.fn().mockReturnValue([]), + ownsModel: jest.fn((model: string) => model.startsWith('gpt-') || /^o\d/.test(model)), + isAdaptiveReasoningModel: jest.fn().mockReturnValue(false), + getReasoningOptions: jest.fn().mockReturnValue([]), + getDefaultReasoningValue: jest.fn().mockReturnValue('off'), + getContextWindowSize: jest.fn().mockReturnValue(200000), + isDefaultModel: jest.fn().mockReturnValue(false), + applyModelDefaults: jest.fn(), + normalizeModelVariant: jest.fn((model: string) => model), + getCustomModelIds: jest.fn().mockReturnValue(new Set()), + } as any); + + const codexCatalog = { + listDropdownEntries: jest.fn().mockResolvedValue([]), + listVaultEntries: jest.fn(), + saveVaultEntry: jest.fn(), + deleteVaultEntry: jest.fn(), + getDropdownConfig: jest.fn().mockReturnValue({ + triggerChars: ['/', '$'], + builtInPrefix: '/', + skillPrefix: '$', + commandPrefix: '/', + }), + refresh: jest.fn(), + }; + const managerGetEntries = jest.fn().mockResolvedValue([ + { + id: 'codex-skill-analyze', + providerId: 'codex', + kind: 'skill', + name: 'analyze', + description: 'Analyze', + content: 'Analyze code', + scope: 'vault', + source: 'user', + isEditable: true, + isDeletable: true, + displayPrefix: '$', + insertPrefix: '$', + }, + ]); + + ProviderWorkspaceRegistry.setServices('codex', { commandCatalog: codexCatalog as any }); + + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin, { + getProviderCatalogConfig: () => ( + tab.providerId === 'codex' + ? { + config: codexCatalog.getDropdownConfig(), + getEntries: managerGetEntries, + } + : null + ), + }); + + // Mock setProviderCatalog on the dropdown + const setProviderCatalogSpy = jest.fn(); + tab.ui.slashCommandDropdown!.setProviderCatalog = setProviderCatalogSpy; + + const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as { + createInputToolbar: jest.Mock; + }; + const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1]; + + // Switch to Codex model → should swap catalog + await toolbarCallbacks.onModelChange(TEST_CODEX_MODEL); + + expect(setProviderCatalogSpy).toHaveBeenCalledTimes(1); + const [config, getEntries] = setProviderCatalogSpy.mock.calls[0]; + expect(config.triggerChars).toEqual(['/', '$']); + expect(config.skillPrefix).toBe('$'); + expect(typeof getEntries).toBe('function'); + await getEntries(); + expect(managerGetEntries).toHaveBeenCalledTimes(1); + expect(codexCatalog.listDropdownEntries).not.toHaveBeenCalled(); + }); + + it('updates hidden commands on blank tab model change', async () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockReturnValue({ + getModelOptions: jest.fn().mockReturnValue([]), + ownsModel: jest.fn((model: string) => model.startsWith('gpt-') || /^o\d/.test(model)), + isAdaptiveReasoningModel: jest.fn().mockReturnValue(false), + getReasoningOptions: jest.fn().mockReturnValue([]), + getDefaultReasoningValue: jest.fn().mockReturnValue('off'), + getContextWindowSize: jest.fn().mockReturnValue(200000), + isDefaultModel: jest.fn().mockReturnValue(false), + applyModelDefaults: jest.fn(), + normalizeModelVariant: jest.fn((model: string) => model), + getCustomModelIds: jest.fn().mockReturnValue(new Set()), + } as any); + + const codexCatalog = { + listDropdownEntries: jest.fn().mockResolvedValue([]), + listVaultEntries: jest.fn(), + saveVaultEntry: jest.fn(), + deleteVaultEntry: jest.fn(), + getDropdownConfig: jest.fn().mockReturnValue({ + providerId: 'codex', + triggerChars: ['/', '$'], + builtInPrefix: '/', + skillPrefix: '$', + commandPrefix: '/', + }), + refresh: jest.fn(), + }; + + ProviderWorkspaceRegistry.setServices('codex', { commandCatalog: codexCatalog as any }); + + const plugin = createMockPlugin({ + settings: { + excludedTags: [], + model: 'claude-sonnet-4-5', + thinkingBudget: 'low', + effortLevel: 'high', + permissionMode: 'yolo', + keyboardNavigation: { + scrollUpKey: 'k', + scrollDownKey: 'j', + focusInputKey: 'i', + }, + persistentExternalContextPaths: [], + settingsProvider: 'claude', + codexEnabled: true, + savedProviderModel: { + claude: 'claude-sonnet-4-5', + codex: TEST_CODEX_MODEL, + }, + savedProviderEffort: { + claude: 'high', + codex: 'medium', + }, + savedProviderThinkingBudget: { + claude: 'low', + codex: 'off', + }, + hiddenProviderCommands: { + claude: ['commit'], + codex: ['analyze'], + }, + }, + }); + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + + const setProviderCatalogSpy = jest.fn(); + const setHiddenCommandsSpy = jest.fn(); + tab.ui.slashCommandDropdown!.setProviderCatalog = setProviderCatalogSpy; + tab.ui.slashCommandDropdown!.setHiddenCommands = setHiddenCommandsSpy; + + const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as { + createInputToolbar: jest.Mock; + }; + const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1]; + + await toolbarCallbacks.onModelChange(TEST_CODEX_MODEL); + + expect(setHiddenCommandsSpy).toHaveBeenCalledWith(new Set(['analyze'])); + }); + + it('rebinds provider helper services and clears stale runtime on blank tab provider change', async () => { + const createInstructionRefineServiceSpy = jest.spyOn(ProviderRegistry, 'createInstructionRefineService') + .mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + const createTitleGenerationServiceSpy = jest.spyOn(ProviderRegistry, 'createTitleGenerationService') + .mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockReturnValue({ + getModelOptions: jest.fn().mockReturnValue([]), + ownsModel: jest.fn((model: string) => model.startsWith('gpt-') || /^o\d/.test(model)), + isAdaptiveReasoningModel: jest.fn().mockReturnValue(false), + getReasoningOptions: jest.fn().mockReturnValue([]), + getDefaultReasoningValue: jest.fn().mockReturnValue('off'), + getContextWindowSize: jest.fn().mockReturnValue(200000), + isDefaultModel: jest.fn().mockReturnValue(false), + applyModelDefaults: jest.fn(), + normalizeModelVariant: jest.fn((model: string) => model), + getCustomModelIds: jest.fn().mockReturnValue(new Set()), + } as any); + + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + + const staleService = createMockClaudianService({ providerId: 'codex' }); + tab.service = staleService as any; + tab.serviceInitialized = false; + + const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as { + createInputToolbar: jest.Mock; + }; + const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1]; + + const initialInstructionCalls = createInstructionRefineServiceSpy.mock.calls.length; + const initialTitleCalls = createTitleGenerationServiceSpy.mock.calls.length; + + await toolbarCallbacks.onModelChange(TEST_CODEX_MODEL); + await toolbarCallbacks.onModelChange('opus'); + + expect(staleService.cleanup).toHaveBeenCalledTimes(1); + expect(tab.service).toBeNull(); + expect(tab.serviceInitialized).toBe(false); + expect(tab.providerId).toBe('claude'); + expect(createInstructionRefineServiceSpy.mock.calls.length).toBeGreaterThan(initialInstructionCalls); + expect(createTitleGenerationServiceSpy.mock.calls.length).toBe(initialTitleCalls); + }); +}); + +describe('Tab - First Send Binding', () => { + it('derives provider from draft model on first send (Claude)', async () => { + const mockEnsureReady = jest.fn().mockResolvedValue(true); + const runtimeModule = jest.requireMock('@/providers/claude/runtime/ClaudeChatRuntime') as { ClaudianService: jest.Mock }; + runtimeModule.ClaudianService.mockImplementationOnce(() => createMockClaudianService({ ensureReady: mockEnsureReady })); + const createChatRuntimeSpy = jest.spyOn(ProviderRegistry, 'createChatRuntime') + .mockReturnValue(createMockClaudianService() as any); + + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + + tab.draftModel = 'sonnet'; + tab.lifecycleState = 'blank'; + + await initializeTabService(tab, plugin, createMockMcpManager()); + + expect(createChatRuntimeSpy).toHaveBeenCalledWith(expect.objectContaining({ + providerId: 'claude', + })); + expect(tab.lifecycleState).toBe('bound_active'); + expect(tab.draftModel).toBeNull(); + }); + + it('derives provider from draft model on first send (Codex)', async () => { + const createChatRuntimeSpy = jest.spyOn(ProviderRegistry, 'createChatRuntime') + .mockReturnValue(createMockClaudianService({ providerId: 'codex' }) as any); + + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + + tab.draftModel = TEST_CODEX_MODEL; + tab.providerId = 'codex'; + tab.lifecycleState = 'blank'; + + await initializeTabService(tab, plugin, createMockMcpManager()); + + expect(createChatRuntimeSpy).toHaveBeenCalledWith(expect.objectContaining({ + providerId: 'codex', + })); + expect(tab.lifecycleState).toBe('bound_active'); + expect(tab.draftModel).toBeNull(); + }); +}); + +describe('Tab - History Bind Without Runtime', () => { + it('ensureServiceForConversation binds to bound_cold without starting runtime', async () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + initializeTabControllers(tab, plugin, {} as any, createMockMcpManager()); + + const convCtrlModule = jest.requireMock('@/features/chat/controllers/ConversationController') as { + ConversationController: jest.Mock; + }; + const deps = convCtrlModule.ConversationController.mock.calls.at(-1)?.[0]; + const ensureServiceForConversation = deps?.ensureServiceForConversation; + + const conversation = { + id: 'conv-history', + providerId: 'codex' as const, + messages: [{ id: 'msg-1', role: 'user' as const, content: 'hi', timestamp: Date.now() }], + }; + + await ensureServiceForConversation(conversation); + + expect(tab.lifecycleState).toBe('bound_cold'); + expect(tab.providerId).toBe('codex'); + expect(tab.conversationId).toBe('conv-history'); + expect(tab.draftModel).toBeNull(); + // No runtime created + expect(tab.serviceInitialized).toBe(false); + }); + + it('ensureServiceForConversation updates hidden commands when the provider changes', async () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + + const codexCatalog = { + listDropdownEntries: jest.fn().mockResolvedValue([]), + listVaultEntries: jest.fn(), + saveVaultEntry: jest.fn(), + deleteVaultEntry: jest.fn(), + getDropdownConfig: jest.fn().mockReturnValue({ + providerId: 'codex', + triggerChars: ['/', '$'], + builtInPrefix: '/', + skillPrefix: '$', + commandPrefix: '/', + }), + refresh: jest.fn(), + }; + const managerGetEntries = jest.fn().mockResolvedValue([ + { + id: 'codex-skill-analyze', + providerId: 'codex', + kind: 'skill', + name: 'analyze', + description: 'Analyze', + content: 'Analyze code', + scope: 'vault', + source: 'user', + isEditable: true, + isDeletable: true, + displayPrefix: '$', + insertPrefix: '$', + }, + ]); + ProviderWorkspaceRegistry.setServices('codex', { commandCatalog: codexCatalog as any }); + + const plugin = createMockPlugin({ + settings: { + excludedTags: [], + model: 'claude-sonnet-4-5', + thinkingBudget: 'low', + effortLevel: 'high', + permissionMode: 'yolo', + keyboardNavigation: { + scrollUpKey: 'k', + scrollDownKey: 'j', + focusInputKey: 'i', + }, + persistentExternalContextPaths: [], + settingsProvider: 'claude', + codexEnabled: true, + savedProviderModel: { + claude: 'claude-sonnet-4-5', + codex: TEST_CODEX_MODEL, + }, + savedProviderEffort: { + claude: 'high', + codex: 'medium', + }, + savedProviderThinkingBudget: { + claude: 'low', + codex: 'off', + }, + hiddenProviderCommands: { + claude: ['commit'], + codex: ['analyze'], + }, + }, + }); + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin, { + getProviderCatalogConfig: () => ( + tab.providerId === 'codex' + ? { + config: codexCatalog.getDropdownConfig(), + getEntries: managerGetEntries, + } + : null + ), + }); + initializeTabControllers( + tab, + plugin, + {} as any, + createMockMcpManager(), + undefined, + undefined, + () => ( + tab.providerId === 'codex' + ? { + config: codexCatalog.getDropdownConfig(), + getEntries: managerGetEntries, + } + : null + ), + ); + + const setProviderCatalogSpy = jest.fn(); + const setHiddenCommandsSpy = jest.fn(); + tab.ui.slashCommandDropdown!.setProviderCatalog = setProviderCatalogSpy; + tab.ui.slashCommandDropdown!.setHiddenCommands = setHiddenCommandsSpy; + + const convCtrlModule = jest.requireMock('@/features/chat/controllers/ConversationController') as { + ConversationController: jest.Mock; + }; + const deps = convCtrlModule.ConversationController.mock.calls.at(-1)?.[0]; + const ensureServiceForConversation = deps?.ensureServiceForConversation; + + await ensureServiceForConversation({ + id: 'conv-history', + providerId: 'codex' as const, + messages: [{ id: 'msg-1', role: 'user' as const, content: 'hi', timestamp: Date.now() }], + }); + + expect(setProviderCatalogSpy).toHaveBeenCalledTimes(1); + expect(setHiddenCommandsSpy).toHaveBeenCalledWith(new Set(['analyze'])); + const [, getEntries] = setProviderCatalogSpy.mock.calls[0]; + await getEntries(); + expect(managerGetEntries).toHaveBeenCalledTimes(1); + expect(codexCatalog.listDropdownEntries).not.toHaveBeenCalled(); + }); +}); + +describe('Tab - Destroy Lifecycle Transition', () => { + it('transitions to closing state and cleans up runtime', async () => { + const mockCleanup = jest.fn(); + const options = createMockOptions(); + const tab = createTab(options); + + tab.lifecycleState = 'bound_active'; + tab.service = { cleanup: mockCleanup } as any; + tab.serviceInitialized = true; + + await destroyTab(tab); + + expect(tab.lifecycleState).toBe('closing'); + expect(mockCleanup).toHaveBeenCalled(); + expect(tab.service).toBeNull(); + }); + + it('does not fail when destroying a blank tab with no runtime', async () => { + const options = createMockOptions(); + const tab = createTab(options); + + expect(tab.lifecycleState).toBe('blank'); + expect(tab.service).toBeNull(); + + await destroyTab(tab); + + expect(tab.lifecycleState).toBe('closing'); + }); + + it('does not fail when destroying a bound_cold tab with no runtime', async () => { + const options = createMockOptions({ + conversation: { + id: 'conv-1', + providerId: 'claude' as any, + title: 'Test', + messages: [], + sessionId: null, + createdAt: Date.now(), + updatedAt: Date.now(), + }, + }); + const tab = createTab(options); + + expect(tab.lifecycleState).toBe('bound_cold'); + expect(tab.service).toBeNull(); + + await destroyTab(tab); + + expect(tab.lifecycleState).toBe('closing'); + }); +}); + +describe('Tab - InputController getTabProviderId wiring', () => { + it('wires getTabProviderId to InputController deps', () => { + jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any); + jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any); + + const plugin = createMockPlugin(); + const tab = createTab(createMockOptions({ plugin })); + initializeTabUI(tab, plugin); + initializeTabControllers(tab, plugin, {} as any, createMockMcpManager()); + + const { InputController } = jest.requireMock('@/features/chat/controllers/InputController') as { InputController: jest.Mock }; + const lastCall = InputController.mock.calls[InputController.mock.calls.length - 1]; + const config = lastCall[0]; + expect(config.getTabProviderId).toBeDefined(); + expect(typeof config.getTabProviderId).toBe('function'); + + // For a blank tab with default model, should resolve to claude + const result = config.getTabProviderId(); + expect(result).toBe('claude'); + }); +}); diff --git a/tests/unit/features/chat/tabs/TabBar.test.ts b/tests/unit/features/chat/tabs/TabBar.test.ts new file mode 100644 index 0000000..4c63d66 --- /dev/null +++ b/tests/unit/features/chat/tabs/TabBar.test.ts @@ -0,0 +1,388 @@ +import { createMockEl } from '@test/helpers/mockElement'; + +import { TabBar, type TabBarCallbacks } from '@/features/chat/tabs/TabBar'; +import type { TabBarItem } from '@/features/chat/tabs/types'; + +// Helper to create mock callbacks +function createMockCallbacks(): TabBarCallbacks { + return { + onTabClick: jest.fn(), + onTabClose: jest.fn(), + onNewTab: jest.fn(), + }; +} + +// Helper to create tab bar items +function createTabBarItem(overrides: Partial = {}): TabBarItem { + return { + id: 'tab-1', + index: 1, + title: 'Test Tab', + providerId: 'claude', + isActive: false, + isStreaming: false, + needsAttention: false, + canClose: true, + ...overrides, + }; +} + +describe('TabBar', () => { + describe('constructor', () => { + it('should add tab badges class to container', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + + new TabBar(containerEl, callbacks); + + expect(containerEl._classList.has('claudian-tab-badges')).toBe(true); + }); + }); + + describe('update', () => { + it('should clear existing badges before rendering', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + // First update + tabBar.update([createTabBarItem()]); + expect(containerEl._children.length).toBe(1); + + // Second update should clear first + tabBar.update([createTabBarItem(), createTabBarItem({ id: 'tab-2', index: 2 })]); + expect(containerEl._children.length).toBe(2); + }); + + it('should render badge for each tab item', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([ + createTabBarItem({ id: 'tab-1', index: 1 }), + createTabBarItem({ id: 'tab-2', index: 2 }), + createTabBarItem({ id: 'tab-3', index: 3 }), + ]); + + expect(containerEl._children.length).toBe(3); + }); + + it('should render empty when no items', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([]); + + expect(containerEl._children.length).toBe(0); + }); + }); + + describe('badge rendering', () => { + it('should display index number as text', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ index: 5 })]); + + expect(containerEl._children[0].textContent).toBe('5'); + }); + + it('should use aria-label as the single tab title tooltip source', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ title: 'My Conversation' })]); + + expect(containerEl._children[0].getAttribute('aria-label')).toBe('My Conversation'); + expect(containerEl._children[0].getAttribute('title')).toBeNull(); + }); + + it('should set a provider attribute for per-tab streaming colors', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ providerId: 'opencode' })]); + + expect(containerEl._children[0].getAttribute('data-provider')).toBe('opencode'); + }); + + it('should toggle between index and title labels on double click', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ index: 2, title: 'My Conversation' })]); + + const badge = containerEl._children[0]; + const event = { preventDefault: jest.fn(), stopPropagation: jest.fn() }; + + badge.dispatchEvent('dblclick', event); + + expect(badge.textContent).toBe('My Conversation'); + expect(badge.hasClass('claudian-tab-badge-expanded')).toBe(true); + expect(badge.getAttribute('data-title-expanded')).toBe('true'); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + + badge.dispatchEvent('dblclick', { preventDefault: jest.fn(), stopPropagation: jest.fn() }); + + expect(badge.textContent).toBe('2'); + expect(badge.hasClass('claudian-tab-badge-expanded')).toBe(false); + expect(badge.getAttribute('data-title-expanded')).toBe('false'); + }); + + it('should notify when title expansion state changes', () => { + const containerEl = createMockEl(); + const callbacks = { + ...createMockCallbacks(), + onTitleExpansionChanged: jest.fn(), + }; + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ id: 'tab-2', index: 2, title: 'My Conversation' })]); + + const badge = containerEl._children[0]; + badge.dispatchEvent('dblclick', { preventDefault: jest.fn(), stopPropagation: jest.fn() }); + badge.dispatchEvent('dblclick', { preventDefault: jest.fn(), stopPropagation: jest.fn() }); + + expect(callbacks.onTitleExpansionChanged).toHaveBeenNthCalledWith(1, ['tab-2']); + expect(callbacks.onTitleExpansionChanged).toHaveBeenNthCalledWith(2, []); + }); + + it('should render restored expanded title state', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.setExpandedTitleTabIds(['tab-1']); + tabBar.update([createTabBarItem({ id: 'tab-1', index: 1, title: 'Restored Title' })]); + + expect(containerEl._children[0].textContent).toBe('Restored Title'); + expect(containerEl._children[0].getAttribute('data-title-expanded')).toBe('true'); + expect(tabBar.getExpandedTitleTabIds()).toEqual(['tab-1']); + }); + + it('should truncate expanded title labels with a literal ellipsis suffix', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + const title = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + + tabBar.update([createTabBarItem({ title })]); + containerEl._children[0].dispatchEvent('dblclick', { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }); + + expect(containerEl._children[0].textContent).toBe('ABCDEFGHIJKLMNOPQRSTUVWXYZ012...'); + expect(containerEl._children[0].textContent.endsWith('...')).toBe(true); + }); + + it('should keep expanded title state across tab bar updates', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ id: 'tab-1', index: 1, title: 'First Title' })]); + containerEl._children[0].dispatchEvent('dblclick', { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }); + + tabBar.update([createTabBarItem({ id: 'tab-1', index: 1, title: 'Renamed Title' })]); + + expect(containerEl._children[0].textContent).toBe('Renamed Title'); + expect(containerEl._children[0].hasClass('claudian-tab-badge-expanded')).toBe(true); + }); + + it('should preserve horizontal scroll position across tab bar updates', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([ + createTabBarItem({ id: 'tab-1', index: 1 }), + createTabBarItem({ id: 'tab-2', index: 2 }), + ]); + containerEl.scrollLeft = 72; + + tabBar.update([ + createTabBarItem({ id: 'tab-1', index: 1 }), + createTabBarItem({ id: 'tab-2', index: 2, isActive: true }), + ]); + + expect(containerEl.scrollLeft).toBe(72); + }); + + it('should restore the last known scroll position when live DOM scroll resets before update', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([ + createTabBarItem({ id: 'tab-1', index: 1 }), + createTabBarItem({ id: 'tab-2', index: 2 }), + createTabBarItem({ id: 'tab-3', index: 3 }), + ]); + containerEl.scrollLeft = 96; + containerEl.dispatchEvent('scroll'); + containerEl.scrollLeft = 0; + + tabBar.update([ + createTabBarItem({ id: 'tab-1', index: 1 }), + createTabBarItem({ id: 'tab-2', index: 2 }), + createTabBarItem({ id: 'tab-3', index: 3, isActive: true }), + ]); + + expect(containerEl.scrollLeft).toBe(96); + }); + }); + + describe('badge state classes', () => { + it('should apply idle class for inactive tab', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ isActive: false, isStreaming: false, needsAttention: false })]); + + expect(containerEl._children[0]._classList.has('claudian-tab-badge-idle')).toBe(true); + }); + + it('should apply active class for active tab', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ isActive: true })]); + + expect(containerEl._children[0]._classList.has('claudian-tab-badge-active')).toBe(true); + }); + + it('should apply streaming class for streaming tab', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ isStreaming: true })]); + + expect(containerEl._children[0]._classList.has('claudian-tab-badge-streaming')).toBe(true); + }); + + it('should apply attention class for tab needing attention', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ needsAttention: true })]); + + expect(containerEl._children[0]._classList.has('claudian-tab-badge-attention')).toBe(true); + }); + + it('should prioritize active over attention', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ isActive: true, needsAttention: true })]); + + expect(containerEl._children[0]._classList.has('claudian-tab-badge-active')).toBe(true); + expect(containerEl._children[0]._classList.has('claudian-tab-badge-attention')).toBe(false); + }); + + it('should prioritize attention over streaming', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ isStreaming: true, needsAttention: true })]); + + expect(containerEl._children[0]._classList.has('claudian-tab-badge-attention')).toBe(true); + expect(containerEl._children[0]._classList.has('claudian-tab-badge-streaming')).toBe(false); + }); + + it('should prioritize active over streaming', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ isActive: true, isStreaming: true })]); + + expect(containerEl._children[0]._classList.has('claudian-tab-badge-active')).toBe(true); + expect(containerEl._children[0]._classList.has('claudian-tab-badge-streaming')).toBe(false); + }); + }); + + describe('badge interactions', () => { + it('should call onTabClick when badge is clicked', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ id: 'clicked-tab' })]); + + // Simulate click + containerEl._children[0].dispatchEvent('click'); + + expect(callbacks.onTabClick).toHaveBeenCalledWith('clicked-tab'); + }); + + it('should call onTabClose on right-click when canClose is true', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ id: 'closeable-tab', canClose: true })]); + + // Simulate right-click (contextmenu) + const mockEvent = { preventDefault: jest.fn() }; + containerEl._children[0].dispatchEvent('contextmenu', mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(callbacks.onTabClose).toHaveBeenCalledWith('closeable-tab'); + }); + + it('should not register contextmenu handler when canClose is false', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem({ id: 'uncloseable-tab', canClose: false })]); + + // Check that contextmenu handler was not registered + expect(containerEl._children[0]._eventListeners.has('contextmenu')).toBe(false); + }); + }); + + describe('destroy', () => { + it('should empty container', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + tabBar.update([createTabBarItem(), createTabBarItem({ id: 'tab-2', index: 2 })]); + expect(containerEl._children.length).toBe(2); + + tabBar.destroy(); + + expect(containerEl._children.length).toBe(0); + }); + + it('should remove tab badges class from container', () => { + const containerEl = createMockEl(); + const callbacks = createMockCallbacks(); + const tabBar = new TabBar(containerEl, callbacks); + + expect(containerEl._classList.has('claudian-tab-badges')).toBe(true); + + tabBar.destroy(); + + expect(containerEl._classList.has('claudian-tab-badges')).toBe(false); + }); + }); +}); diff --git a/tests/unit/features/chat/tabs/TabManager.test.ts b/tests/unit/features/chat/tabs/TabManager.test.ts new file mode 100644 index 0000000..616807d --- /dev/null +++ b/tests/unit/features/chat/tabs/TabManager.test.ts @@ -0,0 +1,3024 @@ +import { TEST_CODEX_MODEL } from '@test/helpers/codexModels'; +import { createMockEl } from '@test/helpers/mockElement'; + +import { ProviderWorkspaceRegistry } from '@/core/providers/ProviderWorkspaceRegistry'; +import { TabManager } from '@/features/chat/tabs/TabManager'; +import { + DEFAULT_MAX_TABS, + type PersistedTabManagerState, + type TabManagerCallbacks, +} from '@/features/chat/tabs/types'; + +// Mock Tab module functions +const mockCreateTab = jest.fn(); +const mockDestroyTab = jest.fn().mockResolvedValue(undefined); +const mockActivateTab = jest.fn(); +const mockDeactivateTab = jest.fn(); +const mockInitializeTabUI = jest.fn(); +const mockInitializeTabControllers = jest.fn(); +const mockInitializeTabService = jest.fn().mockResolvedValue(undefined); +const mockSetupServiceCallbacks = jest.fn(); +const mockWireTabInputEvents = jest.fn(); +const mockGetTabTitle = jest.fn().mockReturnValue('Test Tab'); +const mockCreateChatRuntime = jest.fn(); +const mockGetProviderSettingsSnapshot = jest.fn().mockImplementation(() => ({})); +const commandWarmupPolicy = { resolveMode: jest.fn().mockReturnValue('commands') }; + +jest.mock('@/features/chat/tabs/Tab', () => ({ + createTab: (...args: any[]) => mockCreateTab(...args), + destroyTab: (...args: any[]) => mockDestroyTab(...args), + activateTab: (...args: any[]) => mockActivateTab(...args), + deactivateTab: (...args: any[]) => mockDeactivateTab(...args), + initializeTabUI: (...args: any[]) => mockInitializeTabUI(...args), + initializeTabControllers: (...args: any[]) => mockInitializeTabControllers(...args), + initializeTabService: (...args: any[]) => mockInitializeTabService(...args), + setupServiceCallbacks: (...args: any[]) => mockSetupServiceCallbacks(...args), + wireTabInputEvents: (...args: any[]) => mockWireTabInputEvents(...args), + getTabTitle: (...args: any[]) => mockGetTabTitle(...args), +})); + +const mockChooseForkTarget = jest.fn(); +jest.mock('@/shared/modals/ForkTargetModal', () => ({ + chooseForkTarget: (...args: any[]) => mockChooseForkTarget(...args), +})); + +const mockBuildForkProviderState = jest.fn( + (sourceSessionId: string, resumeAt: string) => ({ + forkSource: { sessionId: sourceSessionId, resumeAt }, + }), +); +const mockGetCapabilities = jest.fn().mockReturnValue({ + providerId: 'claude', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: true, + supportsFork: true, + supportsProviderCommands: true, + supportsImageAttachments: true, + supportsInstructionMode: true, + supportsMcpTools: true, + reasoningControl: 'effort', +}); +const mockCommandCatalogs: Record = {}; +const mockRuntimeCommandLoaders: Record = {}; +const mockTabWarmupPolicies: Record = {}; +jest.mock('@/core/providers/ProviderRegistry', () => ({ + ProviderRegistry: { + createChatRuntime: (...args: any[]) => mockCreateChatRuntime(...args), + getConversationHistoryService: () => ({ + buildForkProviderState: mockBuildForkProviderState, + }), + getCapabilities: (...args: any[]) => mockGetCapabilities(...args), + resolveProviderForModel: (model: string) => ( + model.startsWith('opencode:') ? 'opencode' + : model.startsWith('gpt-') || /^o\d/.test(model) ? 'codex' : 'claude' + ), + }, +})); + +jest.mock('@/core/providers/ProviderWorkspaceRegistry', () => ({ + ProviderWorkspaceRegistry: { + getCommandCatalog: (providerId: string) => mockCommandCatalogs[providerId] ?? null, + getRuntimeCommandLoader: (providerId: string) => mockRuntimeCommandLoaders[providerId] ?? null, + getTabWarmupPolicy: (providerId: string) => mockTabWarmupPolicies[providerId] ?? null, + setServices: (providerId: string, services: any) => { + if (services?.commandCatalog) { + mockCommandCatalogs[providerId] = services.commandCatalog; + } else { + delete mockCommandCatalogs[providerId]; + } + if (services?.runtimeCommandLoader) { + mockRuntimeCommandLoaders[providerId] = services.runtimeCommandLoader; + } else { + delete mockRuntimeCommandLoaders[providerId]; + } + if (services?.tabWarmupPolicy) { + mockTabWarmupPolicies[providerId] = services.tabWarmupPolicy; + } else { + delete mockTabWarmupPolicies[providerId]; + } + }, + }, +})); + +jest.mock('@/core/providers/ProviderSettingsCoordinator', () => ({ + ProviderSettingsCoordinator: { + getProviderSettingsSnapshot: (...args: any[]) => mockGetProviderSettingsSnapshot(...args), + }, +})); + +function createMockPlugin(overrides: Record = {}): any { + return { + app: { + workspace: { + setActiveLeaf: jest.fn(), + revealLeaf: jest.fn(), + }, + }, + settings: { + maxTabs: DEFAULT_MAX_TABS, + ...(overrides.settings || {}), + }, + getConversationById: jest.fn().mockResolvedValue(null), + getConversationSync: jest.fn().mockReturnValue(null), + getConversationList: jest.fn().mockReturnValue([]), + findConversationAcrossViews: jest.fn().mockReturnValue(null), + ...overrides, + }; +} + +function createMockMcpManager(): any { + return {}; +} + +function createMockView(): any { + return { + leaf: { id: 'leaf-1' }, + getTabManager: jest.fn().mockReturnValue(null), + }; +} + +async function flushMicrotasks(count = 4): Promise { + for (let i = 0; i < count; i++) { + await Promise.resolve(); + } +} + +function createMockTabData(overrides: Record = {}): any { + const defaultState = { + isStreaming: false, + hasPendingConversationSave: false, + needsAttention: false, + messages: [], + currentConversationId: null, + }; + + const defaultControllers = { + conversationController: { + save: jest.fn().mockResolvedValue(undefined), + switchTo: jest.fn().mockResolvedValue(undefined), + initializeWelcome: jest.fn(), + }, + inputController: { + handleApprovalRequest: jest.fn(), + }, + }; + + // Extract state and controllers from overrides to merge properly + const { state: stateOverrides, controllers: controllersOverrides, ...restOverrides } = overrides; + + return { + id: `tab-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`, + providerId: 'claude', + conversationId: null, + service: null, + serviceInitialized: false, + state: { + ...defaultState, + ...(stateOverrides || {}), + }, + controllers: { + ...defaultControllers, + ...(controllersOverrides || {}), + }, + dom: { + contentEl: createMockEl(), + }, + ui: { + externalContextSelector: null, + slashCommandDropdown: null, + }, + ...restOverrides, + }; +} + +function createManager(options: { + plugin?: any; + callbacks?: TabManagerCallbacks; + tabFactory?: (counter: number) => any; +} = {}): TabManager { + jest.clearAllMocks(); + let tabCounter = 0; + const factory = options.tabFactory ?? ((n: number) => createMockTabData({ id: `tab-${n}` })); + mockCreateTab.mockImplementation(() => { + tabCounter++; + return factory(tabCounter); + }); + + return new TabManager( + options.plugin ?? createMockPlugin(), + createMockMcpManager(), + createMockEl(), + createMockView(), + options.callbacks + ); +} + +beforeEach(() => { + for (const providerId of Object.keys(mockCommandCatalogs)) { + delete mockCommandCatalogs[providerId]; + } + for (const providerId of Object.keys(mockRuntimeCommandLoaders)) { + delete mockRuntimeCommandLoaders[providerId]; + } + for (const providerId of Object.keys(mockTabWarmupPolicies)) { + delete mockTabWarmupPolicies[providerId]; + } +}); + +describe('TabManager - Tab Lifecycle', () => { + let callbacks: TabManagerCallbacks; + + beforeEach(() => { + callbacks = { + onTabCreated: jest.fn(), + onTabSwitched: jest.fn(), + onTabClosed: jest.fn(), + onTabStreamingChanged: jest.fn(), + onTabTitleChanged: jest.fn(), + onTabAttentionChanged: jest.fn(), + }; + }); + + describe('createTab', () => { + it('should create a new tab', async () => { + const manager = createManager({ callbacks }); + + const tab = await manager.createTab(); + + expect(tab).toBeDefined(); + expect(mockCreateTab).toHaveBeenCalled(); + expect(mockInitializeTabUI).toHaveBeenCalled(); + expect(mockInitializeTabControllers).toHaveBeenCalled(); + expect(mockWireTabInputEvents).toHaveBeenCalled(); + }); + + it('should call onTabCreated callback', async () => { + const manager = createManager({ callbacks }); + + await manager.createTab(); + + expect(callbacks.onTabCreated).toHaveBeenCalled(); + }); + + it('should activate first tab automatically', async () => { + const manager = createManager({ callbacks }); + + await manager.createTab(); + + expect(mockActivateTab).toHaveBeenCalled(); + // Service initialization is now lazy (on first query), not on switch + expect(mockInitializeTabService).not.toHaveBeenCalled(); + }); + + it('should enforce max tabs limit', async () => { + const manager = createManager({ callbacks }); + + for (let i = 0; i < DEFAULT_MAX_TABS; i++) { + await manager.createTab(); + } + + const extraTab = await manager.createTab(); + + expect(extraTab).toBeNull(); + expect(manager.getTabCount()).toBe(DEFAULT_MAX_TABS); + }); + + it('reserves capacity before awaited conversation loading', async () => { + let releaseConversation!: () => void; + const conversationGate = new Promise(resolve => { + releaseConversation = () => resolve(null); + }); + const plugin = createMockPlugin({ + getConversationById: jest.fn().mockReturnValue(conversationGate), + }); + const manager = createManager({ plugin }); + await manager.createTab(); + await manager.createTab(); + + const third = manager.createTab('conversation-3'); + const fourth = manager.createTab('conversation-4'); + + await expect(fourth).resolves.toBeNull(); + releaseConversation(); + await expect(third).resolves.toBeTruthy(); + expect(manager.getTabCount()).toBe(DEFAULT_MAX_TABS); + }); + + it('should use provided tab ID for restoration', async () => { + const manager = createManager({ callbacks }); + mockCreateTab.mockImplementationOnce(() => + createMockTabData({ id: 'restored-tab-id' }) + ); + + await manager.createTab('conv-123', 'restored-tab-id'); + + expect(mockCreateTab).toHaveBeenCalledWith( + expect.objectContaining({ tabId: 'restored-tab-id' }) + ); + }); + }); + + describe('switchToTab', () => { + it('should switch to existing tab', async () => { + const manager = createManager({ callbacks }); + + const tab1 = await manager.createTab(); + const tab2 = await manager.createTab(); + + // First, switch to tab2 to make it active (tab1 is active after creation) + await manager.switchToTab(tab2!.id); + + jest.clearAllMocks(); + + await manager.switchToTab(tab1!.id); + + expect(mockDeactivateTab).toHaveBeenCalled(); + expect(mockActivateTab).toHaveBeenCalled(); + expect(callbacks.onTabSwitched).toHaveBeenCalled(); + }); + + it('should not switch to non-existent tab', async () => { + const manager = createManager({ callbacks }); + await manager.createTab(); + + jest.clearAllMocks(); + await manager.switchToTab('non-existent-id'); + + expect(mockActivateTab).not.toHaveBeenCalled(); + }); + + it('should NOT initialize service on switch (lazy until first query)', async () => { + const manager = createManager({ callbacks }); + + await manager.createTab(); + + // Service initialization is now lazy (on first query), not on switch + expect(mockInitializeTabService).not.toHaveBeenCalled(); + }); + + it('notifies active tab change before cold conversation load completes', async () => { + const callbacks: TabManagerCallbacks = { + onActiveTabChanged: jest.fn(), + onTabSwitched: jest.fn(), + }; + const manager = createManager({ callbacks }); + const tab1 = await manager.createTab(); + const tab2 = await manager.createTab(); + let resolveSwitchTo!: () => void; + const pendingSwitch = new Promise(resolve => { + resolveSwitchTo = resolve; + }); + + tab1!.conversationId = 'conv-1'; + tab1!.state.messages = []; + tab1!.controllers.conversationController!.switchTo = jest.fn().mockReturnValue(pendingSwitch); + jest.clearAllMocks(); + + const switchPromise = manager.switchToTab(tab1!.id); + + expect(callbacks.onActiveTabChanged).toHaveBeenCalledWith(tab2!.id, tab1!.id); + expect(callbacks.onTabSwitched).not.toHaveBeenCalled(); + + resolveSwitchTo(); + await switchPromise; + + expect(callbacks.onTabSwitched).toHaveBeenCalledWith(tab2!.id, tab1!.id); + }); + }); + + describe('closeTab', () => { + it('should close a tab', async () => { + const manager = createManager({ callbacks }); + + const tab1 = await manager.createTab(); + await manager.createTab(); // Need at least 2 tabs to close one + + const closed = await manager.closeTab(tab1!.id); + + expect(closed).toBe(true); + expect(mockDestroyTab).toHaveBeenCalled(); + expect(callbacks.onTabClosed).toHaveBeenCalledWith(tab1!.id); + }); + + it('should not close streaming tab unless forced', async () => { + const streamingTab = createMockTabData({ + id: 'streaming-tab', + state: { isStreaming: true }, + }); + mockCreateTab.mockReturnValueOnce(streamingTab); + + const manager = createManager({ callbacks }); + await manager.createTab(); + + const closed = await manager.closeTab('streaming-tab'); + + expect(closed).toBe(false); + expect(mockDestroyTab).not.toHaveBeenCalled(); + }); + + it('should close streaming tab when forced', async () => { + const streamingTab = createMockTabData({ + id: 'streaming-tab', + state: { isStreaming: true }, + }); + mockCreateTab.mockReturnValueOnce(streamingTab); + + const manager = createManager({ callbacks }); + await manager.createTab(); + await manager.createTab(); // Need second tab + + const closed = await manager.closeTab('streaming-tab', true); + + expect(closed).toBe(true); + expect(mockDestroyTab).toHaveBeenCalled(); + }); + + it('should switch to another tab after closing active tab', async () => { + const manager = createManager({ callbacks }); + + // Create two tabs (variables intentionally unused - we just need tabs to exist) + await manager.createTab(); + await manager.createTab(); + + // Close active tab + await manager.closeTab(manager.getActiveTabId()!); + + // Should have switched to remaining tab + expect(manager.getTabCount()).toBe(1); + }); + + it('should prefer previous tab when closing a middle tab', async () => { + const manager = createManager({ callbacks }); + + const tab1 = await manager.createTab(); + const tab2 = await manager.createTab(); + await manager.createTab(); + + await manager.switchToTab(tab2!.id); + + const switchSpy = jest.spyOn(manager, 'switchToTab'); + + await manager.closeTab(tab2!.id); + + expect(switchSpy).toHaveBeenCalledWith(tab1!.id); + }); + + it('should fall back to next tab when closing the first tab', async () => { + const manager = createManager({ callbacks }); + + const tab1 = await manager.createTab(); + const tab2 = await manager.createTab(); + await manager.createTab(); + + await manager.switchToTab(tab1!.id); + + const switchSpy = jest.spyOn(manager, 'switchToTab'); + + await manager.closeTab(tab1!.id); + + expect(switchSpy).toHaveBeenCalledWith(tab2!.id); + }); + + it('should create new tab if all tabs are closed', async () => { + const manager = createManager({ callbacks }); + + const tab = await manager.createTab(); + await manager.closeTab(tab!.id, true); + + expect(manager.getTabCount()).toBe(1); + }); + + it('should save conversation before closing', async () => { + const mockSave = jest.fn().mockResolvedValue(undefined); + const tabWithSave = createMockTabData({ id: 'tab-with-save' }); + tabWithSave.controllers.conversationController.save = mockSave; + + mockCreateTab.mockReturnValueOnce(tabWithSave); + + const manager = createManager({ callbacks }); + await manager.createTab(); + await manager.createTab(); // Need second tab + + await manager.closeTab('tab-with-save', true); + + expect(mockSave).toHaveBeenCalled(); + }); + + it('destroys and removes the tab even when save-on-close fails', async () => { + const saveError = new Error('save failed'); + const tab = createMockTabData({ id: 'tab-save-failure' }); + tab.controllers.conversationController.save = jest.fn().mockRejectedValue(saveError); + mockCreateTab.mockReturnValueOnce(tab); + const manager = createManager({ callbacks }); + await manager.createTab(); + await manager.createTab(); + + await expect(manager.closeTab(tab.id, true)).rejects.toBe(saveError); + expect(mockDestroyTab).toHaveBeenCalledWith(tab); + expect(manager.getTab(tab.id)).toBeNull(); + }); + + it('should switch to next tab when closing first tab', async () => { + const manager = createManager({ callbacks }); + + const tab1 = await manager.createTab(); + const tab2 = await manager.createTab(); + await manager.createTab(); // tab-3 + + await manager.switchToTab(tab1!.id); + expect(manager.getActiveTabId()).toBe(tab1!.id); + + await manager.closeTab(tab1!.id); + + // Should switch to tab-2 (next tab, not previous since there is none) + expect(manager.getActiveTabId()).toBe(tab2!.id); + }); + + it('should switch to previous tab when closing middle tab', async () => { + const manager = createManager({ callbacks }); + + const tab1 = await manager.createTab(); + const tab2 = await manager.createTab(); + await manager.createTab(); // tab-3 + + await manager.switchToTab(tab2!.id); + expect(manager.getActiveTabId()).toBe(tab2!.id); + + await manager.closeTab(tab2!.id); + + // Should switch to tab-1 (previous tab) + expect(manager.getActiveTabId()).toBe(tab1!.id); + }); + + it('should switch to previous tab when closing last tab in list', async () => { + const manager = createManager({ callbacks }); + + await manager.createTab(); // tab-1 + const tab2 = await manager.createTab(); + const tab3 = await manager.createTab(); + + await manager.switchToTab(tab3!.id); + expect(manager.getActiveTabId()).toBe(tab3!.id); + + await manager.closeTab(tab3!.id); + + // Should switch to tab-2 (previous tab) + expect(manager.getActiveTabId()).toBe(tab2!.id); + }); + }); +}); + +describe('TabManager - Tab Queries', () => { + let manager: TabManager; + + beforeEach(async () => { + manager = createManager(); + await manager.createTab(); + }); + + describe('getActiveTab', () => { + it('should return the active tab', () => { + const activeTab = manager.getActiveTab(); + expect(activeTab).toBeDefined(); + }); + }); + + describe('getActiveTabId', () => { + it('should return the active tab ID', () => { + const activeTabId = manager.getActiveTabId(); + expect(activeTabId).toBeDefined(); + }); + }); + + describe('getTab', () => { + it('should return tab by ID', () => { + const activeTabId = manager.getActiveTabId()!; + const tab = manager.getTab(activeTabId); + expect(tab).toBeDefined(); + expect(tab?.id).toBe(activeTabId); + }); + + it('should return null for non-existent tab', () => { + const tab = manager.getTab('non-existent'); + expect(tab).toBeNull(); + }); + }); + + describe('getAllTabs', () => { + it('should return all tabs', async () => { + await manager.createTab(); + await manager.createTab(); + + const tabs = manager.getAllTabs(); + expect(tabs.length).toBe(3); + }); + }); + + describe('getTabCount', () => { + it('should return correct count', async () => { + expect(manager.getTabCount()).toBe(1); + + await manager.createTab(); + expect(manager.getTabCount()).toBe(2); + }); + }); + + describe('canCreateTab', () => { + it('should return true when under limit', () => { + expect(manager.canCreateTab()).toBe(true); + }); + + it('should return false when at limit', async () => { + for (let i = 1; i < DEFAULT_MAX_TABS; i++) { + await manager.createTab(); + } + expect(manager.canCreateTab()).toBe(false); + }); + }); +}); + +describe('TabManager - Tab Bar Data', () => { + let manager: TabManager; + + beforeEach(async () => { + manager = createManager({ + tabFactory: (n) => createMockTabData({ + id: `tab-${n}`, + state: { + isStreaming: n === 2, + needsAttention: n === 3, + }, + }), + }); + }); + + describe('getTabBarItems', () => { + it('should return tab bar items with correct structure', async () => { + await manager.createTab(); + await manager.createTab(); + + const items = manager.getTabBarItems(); + + expect(items.length).toBe(2); + expect(items[0]).toHaveProperty('id'); + expect(items[0]).toHaveProperty('index'); + expect(items[0]).toHaveProperty('title'); + expect(items[0]).toHaveProperty('providerId'); + expect(items[0]).toHaveProperty('isActive'); + expect(items[0]).toHaveProperty('isStreaming'); + expect(items[0]).toHaveProperty('needsAttention'); + expect(items[0]).toHaveProperty('canClose'); + }); + + it('should have 1-based indices', async () => { + await manager.createTab(); + await manager.createTab(); + await manager.createTab(); + + const items = manager.getTabBarItems(); + + expect(items[0].index).toBe(1); + expect(items[1].index).toBe(2); + expect(items[2].index).toBe(3); + }); + + it('should mark streaming tabs', async () => { + await manager.createTab(); // Not streaming + await manager.createTab(); // Streaming + + const items = manager.getTabBarItems(); + + expect(items[0].isStreaming).toBe(false); + expect(items[1].isStreaming).toBe(true); + }); + + it('should resolve badge provider from the live tab context', async () => { + manager = createManager({ + plugin: createMockPlugin({ + getConversationSync: jest.fn().mockImplementation((conversationId: string) => ( + conversationId === 'conv-codex' + ? { id: 'conv-codex', providerId: 'codex' } + : null + )), + }), + tabFactory: () => createMockTabData({ + id: 'tab-1', + providerId: 'claude', + conversationId: 'conv-codex', + state: { isStreaming: true }, + }), + }); + + await manager.createTab(); + + const items = manager.getTabBarItems(); + + expect(items[0].providerId).toBe('codex'); + }); + }); +}); + +describe('TabManager - Conversation Management', () => { + let manager: TabManager; + let plugin: any; + + beforeEach(async () => { + plugin = createMockPlugin(); + manager = createManager({ plugin }); + await manager.createTab(); + }); + + describe('openConversation', () => { + it('should switch to tab if conversation is already open', async () => { + const tabWithConv = createMockTabData({ + id: 'tab-with-conv', + conversationId: 'conv-123', + }); + mockCreateTab.mockReturnValueOnce(tabWithConv); + await manager.createTab(); + + const switchSpy = jest.spyOn(manager, 'switchToTab'); + await manager.openConversation('conv-123'); + + expect(switchSpy).toHaveBeenCalledWith('tab-with-conv'); + }); + + it('should create new tab when preferNewTab is true', async () => { + plugin.getConversationById.mockResolvedValue({ id: 'conv-new' }); + + await manager.openConversation('conv-new', true); + + expect(mockCreateTab).toHaveBeenCalledWith( + expect.objectContaining({ + conversation: { id: 'conv-new' }, + }) + ); + }); + + it('should create a background tab without switching focus', async () => { + plugin.getConversationById.mockResolvedValue({ id: 'conv-background' }); + const initialActiveTabId = manager.getActiveTabId(); + + await manager.openConversation('conv-background', { + preferNewTab: true, + activate: false, + }); + + expect(mockCreateTab).toHaveBeenCalledWith( + expect.objectContaining({ + conversation: { id: 'conv-background' }, + }) + ); + expect(manager.getActiveTabId()).toBe(initialActiveTabId); + }); + + it('should check for cross-view duplicates', async () => { + plugin.findConversationAcrossViews.mockReturnValue({ + view: { leaf: { id: 'other-leaf' }, getTabManager: () => ({ switchToTab: jest.fn() }) }, + tabId: 'other-tab', + }); + + await manager.openConversation('conv-123'); + + expect(plugin.app.workspace.revealLeaf).toHaveBeenCalledWith({ id: 'other-leaf' }); + }); + }); + + describe('createNewConversation', () => { + it('should create new conversation in active tab', async () => { + const activeTab = manager.getActiveTab(); + const createNew = jest.fn().mockResolvedValue(undefined); + activeTab!.controllers.conversationController = { createNew } as any; + + await manager.createNewConversation(); + + expect(createNew).toHaveBeenCalled(); + }); + }); +}); + +describe('TabManager - Persistence', () => { + let manager: TabManager; + + beforeEach(async () => { + manager = createManager({ + tabFactory: (n) => createMockTabData({ + id: `tab-${n}`, + conversationId: n === 2 ? 'conv-456' : null, + }), + }); + }); + + describe('getPersistedState', () => { + it('should return current tab state for persistence', async () => { + await manager.createTab(); + await manager.createTab(); + + const state = manager.getPersistedState(); + + expect(state.openTabs).toHaveLength(2); + expect(state.activeTabId).toBeDefined(); + expect(state.openTabs[0]).toHaveProperty('tabId'); + expect(state.openTabs[0]).toHaveProperty('conversationId'); + }); + + it('should persist draftModel for blank tabs', async () => { + const blankManager = createManager({ + tabFactory: () => createMockTabData({ + id: 'blank-opencode', + conversationId: null, + lifecycleState: 'blank', + draftModel: 'opencode:google/gemini-3.1-pro-preview', + providerId: 'opencode', + }), + }); + + await blankManager.createTab(); + + expect(blankManager.getPersistedState()).toEqual({ + activeTabId: 'blank-opencode', + openTabs: [{ + tabId: 'blank-opencode', + conversationId: null, + draftModel: 'opencode:google/gemini-3.1-pro-preview', + }], + }); + }); + }); + + describe('restoreState', () => { + it('should restore tabs from persisted state', async () => { + const persistedState: PersistedTabManagerState = { + openTabs: [ + { tabId: 'restored-1', conversationId: 'conv-1' }, + { tabId: 'restored-2', conversationId: 'conv-2' }, + ], + activeTabId: 'restored-2', + }; + + await manager.restoreState(persistedState); + + expect(mockCreateTab).toHaveBeenCalledTimes(2); + }); + + it('should restore draftModel for blank tabs', async () => { + const persistedState: PersistedTabManagerState = { + openTabs: [ + { + tabId: 'restored-blank', + conversationId: null, + draftModel: 'opencode:google/gemini-3.1-pro-preview', + }, + ], + activeTabId: 'restored-blank', + }; + + await manager.restoreState(persistedState); + + expect(mockCreateTab).toHaveBeenCalledWith(expect.objectContaining({ + tabId: 'restored-blank', + draftModel: 'opencode:google/gemini-3.1-pro-preview', + })); + }); + + it('should switch to previously active tab', async () => { + mockCreateTab.mockImplementation((opts: any) => + createMockTabData({ id: opts.tabId || 'default-tab' }) + ); + + const persistedState: PersistedTabManagerState = { + openTabs: [ + { tabId: 'restored-1', conversationId: null }, + { tabId: 'restored-2', conversationId: null }, + ], + activeTabId: 'restored-2', + }; + + await manager.restoreState(persistedState); + + expect(manager.getActiveTabId()).toBe('restored-2'); + }); + + it('should create default tab if no tabs restored', async () => { + // Reset mock to return valid tab data + mockCreateTab.mockReturnValue(createMockTabData({ id: 'default-tab' })); + + await manager.restoreState({ openTabs: [], activeTabId: null }); + + expect(mockCreateTab).toHaveBeenCalled(); + expect(manager.getTabCount()).toBe(1); + }); + + it('should handle tab restoration errors gracefully', async () => { + let callCount = 0; + mockCreateTab.mockImplementation(() => { + callCount++; + if (callCount === 1) { + throw new Error('Tab creation failed'); + } + return createMockTabData({ id: `tab-${callCount}` }); + }); + + const persistedState: PersistedTabManagerState = { + openTabs: [ + { tabId: 'fail-tab', conversationId: null }, + { tabId: 'success-tab', conversationId: null }, + ], + activeTabId: null, + }; + + // Should not throw + await expect(manager.restoreState(persistedState)).resolves.not.toThrow(); + + // Should have created at least one tab + expect(manager.getTabCount()).toBeGreaterThanOrEqual(1); + }); + + it('keeps non-active restored pre-session OpenCode tabs cold until the final active tab is chosen', async () => { + const runtimeCommandLoader = { + isAvailable: jest.fn().mockReturnValue(true), + loadCommands: jest.fn().mockResolvedValue([{ id: 'acp:review', name: 'review', content: '' }]), + }; + const mockCatalog = { + setRuntimeCommands: jest.fn(), + }; + + ProviderWorkspaceRegistry.setServices('opencode', { + commandCatalog: mockCatalog as any, + runtimeCommandLoader: runtimeCommandLoader as any, + tabWarmupPolicy: commandWarmupPolicy as any, + }); + mockGetCapabilities.mockImplementation((providerId: string) => ({ + providerId, + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: providerId === 'claude', + supportsRewind: providerId === 'claude', + supportsFork: providerId === 'claude', + supportsProviderCommands: providerId === 'opencode' || providerId === 'claude', + reasoningControl: providerId === 'opencode' ? 'effort' : 'none', + })); + + const plugin = createMockPlugin({ + getConversationById: jest.fn().mockImplementation(async (conversationId: string) => { + if (conversationId === 'conv-opencode') { + return { + id: 'conv-opencode', + messages: [{ id: 'm1' }], + providerState: {}, + sessionId: null, + }; + } + + return { + id: 'conv-claude', + messages: [], + providerState: {}, + sessionId: 'session-1', + }; + }), + }); + const manager = createManager({ + plugin, + tabFactory: (n) => createMockTabData({ + id: n === 1 ? 'restored-opencode' : 'restored-claude', + providerId: n === 1 ? 'opencode' : 'claude', + conversationId: n === 1 ? 'conv-opencode' : 'conv-claude', + lifecycleState: 'bound_cold', + ui: { + externalContextSelector: { + getExternalContexts: jest.fn().mockReturnValue([]), + }, + }, + }), + }); + + await manager.restoreState({ + openTabs: [ + { tabId: 'restored-opencode', conversationId: 'conv-opencode' }, + { tabId: 'restored-claude', conversationId: 'conv-claude' }, + ], + activeTabId: 'restored-claude', + }); + await flushMicrotasks(); + + expect(manager.getActiveTabId()).toBe('restored-claude'); + expect(runtimeCommandLoader.loadCommands).not.toHaveBeenCalled(); + expect(mockCatalog.setRuntimeCommands).not.toHaveBeenCalled(); + }); + }); +}); + +describe('TabManager - Broadcast', () => { + let manager: TabManager; + + beforeEach(async () => { + manager = createManager({ + tabFactory: (n) => createMockTabData({ + id: `tab-${n}`, + service: { someMethod: jest.fn() }, + serviceInitialized: true, + }), + }); + await manager.createTab(); + await manager.createTab(); + }); + + describe('broadcastToAllTabs', () => { + it('should call function on all initialized services', async () => { + const broadcastFn = jest.fn().mockResolvedValue(undefined); + + await manager.broadcastToAllTabs(broadcastFn); + + expect(broadcastFn).toHaveBeenCalledTimes(2); + }); + + it('should handle errors in broadcast gracefully', async () => { + const broadcastFn = jest.fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('Broadcast failed')); + + // Should not throw + await expect(manager.broadcastToAllTabs(broadcastFn)).resolves.not.toThrow(); + }); + + it('should skip tabs without initialized services', async () => { + // Create tab without initialized service + mockCreateTab.mockReturnValueOnce( + createMockTabData({ service: null, serviceInitialized: false }) + ); + await manager.createTab(); + + const broadcastFn = jest.fn().mockResolvedValue(undefined); + await manager.broadcastToAllTabs(broadcastFn); + + // Should only be called for the 2 initialized tabs, not the 3rd + expect(broadcastFn).toHaveBeenCalledTimes(2); + }); + }); + + describe('broadcastToProviderTabs', () => { + it('should only call initialized runtimes for the requested provider', async () => { + manager = createManager({ + tabFactory: (n) => createMockTabData({ + id: `tab-${n}`, + providerId: n === 1 ? 'claude' : 'opencode', + service: { + providerId: n === 1 ? 'claude' : 'opencode', + }, + serviceInitialized: true, + }), + }); + await manager.createTab(); + await manager.createTab(); + + const broadcastFn = jest.fn().mockResolvedValue(undefined); + await manager.broadcastToProviderTabs('opencode', broadcastFn); + + expect(broadcastFn).toHaveBeenCalledTimes(1); + expect(broadcastFn).toHaveBeenCalledWith(expect.objectContaining({ providerId: 'opencode' })); + }); + }); + + describe('recycleProviderRuntimes', () => { + it('disposes and detaches matching runtimes so the next turn creates a fresh instance', async () => { + const opencodeCleanup = jest.fn(); + const claudeCleanup = jest.fn(); + manager = createManager({ + tabFactory: (n) => createMockTabData({ + id: `tab-${n}`, + conversationId: `conversation-${n}`, + lifecycleState: 'bound_active', + providerId: n === 1 ? 'claude' : 'opencode', + runtimeSupervisor: { + cleanup: n === 1 ? claudeCleanup : opencodeCleanup, + }, + service: { + providerId: n === 1 ? 'claude' : 'opencode', + }, + serviceInitialized: true, + }), + }); + await manager.createTab(); + const opencodeTab = (await manager.createTab())!; + + await manager.recycleProviderRuntimes('opencode'); + + expect(opencodeCleanup).toHaveBeenCalledTimes(1); + expect(opencodeTab.service).toBeNull(); + expect(opencodeTab.serviceInitialized).toBe(false); + expect(opencodeTab.lifecycleState).toBe('bound_cold'); + expect(claudeCleanup).not.toHaveBeenCalled(); + }); + }); +}); + +describe('TabManager - SDK Commands', () => { + beforeEach(() => { + mockGetCapabilities.mockReset(); + mockGetCapabilities.mockReturnValue({ + providerId: 'claude', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: true, + supportsFork: true, + supportsProviderCommands: true, + reasoningControl: 'effort', + }); + }); + + it('should return commands from the target tab runtime when it is ready', async () => { + const supportedCommands = [{ id: 'sdk:commit', name: 'commit', content: '' }]; + const readyService = { + providerId: 'claude', + isReady: jest.fn().mockReturnValue(true), + getSupportedCommands: jest.fn().mockResolvedValue(supportedCommands), + }; + const manager = createManager({ + tabFactory: () => createMockTabData({ + id: 'tab-ready', + providerId: 'claude', + service: readyService, + }), + }); + + const tab = await manager.createTab(); + + await expect(manager.getSdkCommands(tab!.id)).resolves.toEqual(supportedCommands); + expect(readyService.getSupportedCommands).toHaveBeenCalledTimes(1); + }); + + it('should reuse commands from another ready tab with the same provider', async () => { + const supportedCommands = [{ id: 'sdk:commit', name: 'commit', content: '' }]; + const readyClaudeService = { + providerId: 'claude', + isReady: jest.fn().mockReturnValue(true), + getSupportedCommands: jest.fn().mockResolvedValue(supportedCommands), + }; + const manager = createManager({ + tabFactory: (n) => createMockTabData({ + id: `tab-${n}`, + providerId: 'claude', + service: n === 1 ? readyClaudeService : null, + }), + }); + + await manager.createTab(); + const lazyClaudeTab = await manager.createTab(); + + await expect(manager.getSdkCommands(lazyClaudeTab!.id)).resolves.toEqual(supportedCommands); + expect(readyClaudeService.getSupportedCommands).toHaveBeenCalledTimes(1); + }); + + it('should not leak commands across providers', async () => { + const claudeCommands = [{ id: 'sdk:commit', name: 'commit', content: '' }]; + const readyClaudeService = { + providerId: 'claude', + isReady: jest.fn().mockReturnValue(true), + getSupportedCommands: jest.fn().mockResolvedValue(claudeCommands), + }; + const manager = createManager({ + tabFactory: (n) => createMockTabData({ + id: `tab-${n}`, + providerId: n === 2 ? 'codex' : 'claude', + service: n === 1 ? readyClaudeService : null, + }), + }); + + await manager.createTab(); + const codexTab = await manager.createTab(); + mockGetCapabilities.mockImplementation((providerId: string) => ({ + providerId, + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: providerId === 'claude', + supportsRewind: providerId === 'claude', + supportsFork: providerId === 'claude', + supportsProviderCommands: providerId === 'claude', + reasoningControl: providerId === 'claude' ? 'effort' : 'none', + })); + + await expect(manager.getSdkCommands(codexTab!.id)).resolves.toEqual([]); + expect(readyClaudeService.getSupportedCommands).not.toHaveBeenCalled(); + }); + + it('should resolve blank-tab SDK command provider from draftModel instead of stale providerId', async () => { + const claudeCommands = [{ id: 'sdk:commit', name: 'commit', content: '' }]; + const readyClaudeService = { + providerId: 'claude', + isReady: jest.fn().mockReturnValue(true), + getSupportedCommands: jest.fn().mockResolvedValue(claudeCommands), + }; + const manager = createManager({ + tabFactory: (n) => createMockTabData({ + id: `tab-${n}`, + lifecycleState: n === 2 ? 'blank' : 'bound_cold', + draftModel: n === 2 ? TEST_CODEX_MODEL : null, + providerId: 'claude', + service: n === 1 ? readyClaudeService : null, + }), + }); + + await manager.createTab(); + const blankCodexTab = await manager.createTab(); + mockGetCapabilities.mockImplementation((providerId: string) => ({ + providerId, + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: providerId === 'claude', + supportsRewind: providerId === 'claude', + supportsFork: providerId === 'claude', + supportsProviderCommands: providerId === 'claude', + reasoningControl: providerId === 'claude' ? 'effort' : 'none', + })); + + await expect(manager.getSdkCommands(blankCodexTab!.id)).resolves.toEqual([]); + expect(readyClaudeService.getSupportedCommands).not.toHaveBeenCalled(); + }); + + it('should keep inactive blank OpenCode tabs cold when SDK commands are requested', async () => { + const mockCatalog = { + setRuntimeCommands: jest.fn(), + }; + const runtimeCommandLoader = { + isAvailable: jest.fn().mockReturnValue(true), + loadCommands: jest.fn(), + }; + + ProviderWorkspaceRegistry.setServices('opencode', { + commandCatalog: mockCatalog as any, + runtimeCommandLoader: runtimeCommandLoader as any, + tabWarmupPolicy: commandWarmupPolicy as any, + }); + mockGetCapabilities.mockImplementation((providerId: string) => ({ + providerId, + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: providerId === 'claude', + supportsRewind: providerId === 'claude', + supportsFork: providerId === 'claude', + supportsProviderCommands: providerId === 'opencode' || providerId === 'claude', + reasoningControl: providerId === 'opencode' ? 'effort' : 'none', + })); + const manager = createManager({ + plugin: createMockPlugin(), + tabFactory: (n) => createMockTabData( + n === 1 + ? { + id: 'tab-claude', + providerId: 'claude', + } + : { + id: 'tab-opencode', + providerId: 'opencode', + draftModel: 'opencode:openai/gpt-5', + lifecycleState: 'blank', + ui: { + externalContextSelector: { + getExternalContexts: jest.fn().mockReturnValue([]), + }, + }, + } + ), + }); + + await manager.createTab(); + const tab = await manager.createTab(undefined, 'tab-opencode', { activate: false }); + + await expect(manager.getSdkCommands(tab!.id)).resolves.toEqual([]); + expect(mockInitializeTabService).not.toHaveBeenCalled(); + expect(mockSetupServiceCallbacks).not.toHaveBeenCalled(); + expect(runtimeCommandLoader.loadCommands).not.toHaveBeenCalled(); + expect(mockCatalog.setRuntimeCommands).toHaveBeenLastCalledWith([]); + expect(tab!.lifecycleState).toBe('blank'); + expect(tab!.serviceInitialized).toBe(false); + }); + + it('should invalidate cached OpenCode commands when the saved session context changes', async () => { + const firstCommands = [{ id: 'acp:review', name: 'review', content: '' }]; + const secondCommands = [{ id: 'acp:compact', name: 'compact', content: '' }]; + const mockCatalog = { + setRuntimeCommands: jest.fn(), + }; + const runtimeCommandLoader = { + isAvailable: jest.fn().mockReturnValue(true), + loadCommands: jest.fn() + .mockResolvedValueOnce(firstCommands) + .mockResolvedValueOnce(secondCommands), + }; + + ProviderWorkspaceRegistry.setServices('opencode', { + commandCatalog: mockCatalog as any, + runtimeCommandLoader: runtimeCommandLoader as any, + tabWarmupPolicy: commandWarmupPolicy as any, + }); + mockGetCapabilities.mockImplementation((providerId: string) => ({ + providerId, + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: providerId === 'claude', + supportsRewind: providerId === 'claude', + supportsFork: providerId === 'claude', + supportsProviderCommands: providerId === 'opencode' || providerId === 'claude', + reasoningControl: providerId === 'opencode' ? 'effort' : 'none', + })); + const resetSdkSkillsCache = jest.fn(); + const plugin = createMockPlugin({ + getConversationById: jest.fn() + .mockResolvedValueOnce({ + id: 'conv-opencode', + messages: [{ id: 'm1' }], + providerState: { databasePath: '/persisted/opencode.db' }, + sessionId: 'session-1', + }) + .mockResolvedValueOnce({ + id: 'conv-opencode', + messages: [{ id: 'm1' }], + providerState: { databasePath: '/persisted/opencode.db' }, + sessionId: 'session-1', + }) + .mockResolvedValueOnce({ + id: 'conv-opencode', + messages: [{ id: 'm1' }], + providerState: { databasePath: '/persisted/opencode.db' }, + sessionId: 'session-1', + }) + .mockResolvedValueOnce({ + id: 'conv-opencode', + messages: [{ id: 'm1' }], + providerState: { databasePath: '/persisted/opencode.db' }, + sessionId: 'session-1', + }) + .mockResolvedValueOnce({ + id: 'conv-opencode', + messages: [{ id: 'm1' }], + providerState: { databasePath: '/persisted/opencode.db' }, + sessionId: 'session-2', + }), + }); + const manager = createManager({ + plugin, + tabFactory: (n) => createMockTabData( + n === 1 + ? { + id: 'tab-claude', + providerId: 'claude', + } + : { + id: 'tab-opencode', + providerId: 'opencode', + conversationId: 'conv-opencode', + lifecycleState: 'bound_cold', + ui: { + externalContextSelector: { + getExternalContexts: jest.fn().mockReturnValue([]), + }, + slashCommandDropdown: { + resetSdkSkillsCache, + }, + }, + } + ), + }); + + await manager.createTab(); + const tab = await manager.createTab('conv-opencode', 'tab-opencode', { activate: false }); + + await expect(manager.getSdkCommands(tab!.id)).resolves.toEqual(firstCommands); + await expect(manager.getSdkCommands(tab!.id)).resolves.toEqual(firstCommands); + + await expect(manager.getSdkCommands(tab!.id)).resolves.toEqual(secondCommands); + expect(runtimeCommandLoader.loadCommands).toHaveBeenCalledTimes(2); + expect(resetSdkSkillsCache).not.toHaveBeenCalled(); + }); + + it('should prime active blank OpenCode tabs automatically', async () => { + const supportedCommands = [{ id: 'acp:review', name: 'review', content: '' }]; + const mockCatalog = { + setRuntimeCommands: jest.fn(), + }; + const runtimeCommandLoader = { + isAvailable: jest.fn().mockReturnValue(true), + loadCommands: jest.fn().mockResolvedValue(supportedCommands), + }; + + ProviderWorkspaceRegistry.setServices('opencode', { + commandCatalog: mockCatalog as any, + runtimeCommandLoader: runtimeCommandLoader as any, + tabWarmupPolicy: commandWarmupPolicy as any, + }); + mockGetCapabilities.mockImplementation((providerId: string) => ({ + providerId, + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: providerId === 'claude', + supportsRewind: providerId === 'claude', + supportsFork: providerId === 'claude', + supportsProviderCommands: providerId === 'opencode' || providerId === 'claude', + reasoningControl: providerId === 'opencode' ? 'effort' : 'none', + })); + const manager = createManager({ + plugin: createMockPlugin(), + tabFactory: () => createMockTabData({ + id: 'tab-opencode', + providerId: 'opencode', + draftModel: 'opencode:openai/gpt-5', + lifecycleState: 'blank', + ui: { + externalContextSelector: { + getExternalContexts: jest.fn().mockReturnValue([]), + }, + }, + }), + }); + + const tab = await manager.createTab(); + await flushMicrotasks(); + + expect(runtimeCommandLoader.loadCommands).toHaveBeenCalledTimes(1); + expect(mockCatalog.setRuntimeCommands).toHaveBeenLastCalledWith(supportedCommands); + expect(tab!.lifecycleState).toBe('blank'); + expect(tab!.serviceInitialized).toBe(false); + }); + + it('should prime the active restored OpenCode conversation tab automatically', async () => { + const supportedCommands = [{ id: 'acp:review', name: 'review', content: '' }]; + const mockCatalog = { + setRuntimeCommands: jest.fn(), + }; + const runtimeCommandLoader = { + isAvailable: jest.fn().mockReturnValue(true), + loadCommands: jest.fn().mockResolvedValue(supportedCommands), + }; + + ProviderWorkspaceRegistry.setServices('opencode', { + commandCatalog: mockCatalog as any, + runtimeCommandLoader: runtimeCommandLoader as any, + tabWarmupPolicy: commandWarmupPolicy as any, + }); + mockGetCapabilities.mockImplementation((providerId: string) => ({ + providerId, + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: providerId === 'claude', + supportsRewind: providerId === 'claude', + supportsFork: providerId === 'claude', + supportsProviderCommands: providerId === 'opencode' || providerId === 'claude', + reasoningControl: providerId === 'opencode' ? 'effort' : 'none', + })); + const plugin = createMockPlugin({ + getConversationById: jest.fn().mockResolvedValue({ + id: 'conv-opencode', + messages: [{ id: 'm1' }], + providerState: { databasePath: '/persisted/opencode.db' }, + sessionId: 'session-1', + }), + }); + const manager = createManager({ + plugin, + tabFactory: () => createMockTabData({ + id: 'tab-opencode-restored', + providerId: 'opencode', + conversationId: 'conv-opencode', + lifecycleState: 'bound_cold', + ui: { + externalContextSelector: { + getExternalContexts: jest.fn().mockReturnValue([]), + }, + }, + }), + }); + + await manager.createTab('conv-opencode', 'tab-opencode-restored', { activate: false }); + await flushMicrotasks(); + + expect(runtimeCommandLoader.loadCommands).toHaveBeenCalledTimes(1); + expect(mockCatalog.setRuntimeCommands).toHaveBeenLastCalledWith(supportedCommands); + }); + + it('should prime the active restored pre-session OpenCode conversation tab automatically', async () => { + const supportedCommands = [{ id: 'acp:review', name: 'review', content: '' }]; + const mockCatalog = { + setRuntimeCommands: jest.fn(), + }; + const runtimeCommandLoader = { + isAvailable: jest.fn().mockReturnValue(true), + loadCommands: jest.fn().mockResolvedValue(supportedCommands), + }; + + ProviderWorkspaceRegistry.setServices('opencode', { + commandCatalog: mockCatalog as any, + runtimeCommandLoader: runtimeCommandLoader as any, + tabWarmupPolicy: commandWarmupPolicy as any, + }); + mockGetCapabilities.mockImplementation((providerId: string) => ({ + providerId, + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: providerId === 'claude', + supportsRewind: providerId === 'claude', + supportsFork: providerId === 'claude', + supportsProviderCommands: providerId === 'opencode' || providerId === 'claude', + reasoningControl: providerId === 'opencode' ? 'effort' : 'none', + })); + const plugin = createMockPlugin({ + getConversationById: jest.fn().mockResolvedValue({ + id: 'conv-opencode', + messages: [{ id: 'm1' }], + providerState: {}, + sessionId: null, + }), + }); + const manager = createManager({ + plugin, + tabFactory: () => createMockTabData({ + id: 'tab-opencode-pre-session', + providerId: 'opencode', + conversationId: 'conv-opencode', + lifecycleState: 'bound_cold', + ui: { + externalContextSelector: { + getExternalContexts: jest.fn().mockReturnValue([]), + }, + }, + }), + }); + + await manager.createTab('conv-opencode', 'tab-opencode-pre-session', { activate: false }); + await flushMicrotasks(); + + expect(runtimeCommandLoader.loadCommands).toHaveBeenCalledTimes(1); + expect(mockCatalog.setRuntimeCommands).toHaveBeenLastCalledWith(supportedCommands); + }); + + it('should keep inactive restored OpenCode conversation tabs cold', async () => { + const mockCatalog = { + setRuntimeCommands: jest.fn(), + }; + const runtimeCommandLoader = { + isAvailable: jest.fn().mockReturnValue(true), + loadCommands: jest.fn(), + }; + + ProviderWorkspaceRegistry.setServices('opencode', { + commandCatalog: mockCatalog as any, + runtimeCommandLoader: runtimeCommandLoader as any, + tabWarmupPolicy: commandWarmupPolicy as any, + }); + mockGetCapabilities.mockImplementation((providerId: string) => ({ + providerId, + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: providerId === 'claude', + supportsRewind: providerId === 'claude', + supportsFork: providerId === 'claude', + supportsProviderCommands: providerId === 'opencode' || providerId === 'claude', + reasoningControl: providerId === 'opencode' ? 'effort' : 'none', + })); + const plugin = createMockPlugin({ + getConversationById: jest.fn().mockResolvedValue({ + id: 'conv-opencode', + messages: [{ id: 'm1' }], + providerState: { databasePath: '/persisted/opencode.db' }, + sessionId: 'session-1', + }), + }); + const manager = createManager({ + plugin, + tabFactory: (n) => createMockTabData({ + id: n === 1 ? 'tab-claude' : 'tab-opencode-restored', + providerId: n === 1 ? 'claude' : 'opencode', + conversationId: n === 1 ? 'conv-claude' : 'conv-opencode', + lifecycleState: n === 1 ? 'bound_active' : 'bound_cold', + ui: { + externalContextSelector: { + getExternalContexts: jest.fn().mockReturnValue([]), + }, + }, + }), + }); + + await manager.createTab('conv-claude', 'tab-claude'); + runtimeCommandLoader.loadCommands.mockClear(); + mockCatalog.setRuntimeCommands.mockClear(); + + await manager.createTab('conv-opencode', 'tab-opencode-restored', { activate: false }); + await flushMicrotasks(); + + expect(runtimeCommandLoader.loadCommands).not.toHaveBeenCalled(); + expect(mockCatalog.setRuntimeCommands).not.toHaveBeenCalled(); + }); + + it('should not borrow ready OpenCode commands from another tab session', async () => { + const readyCommands = [{ id: 'acp:review', name: 'review', content: '' }]; + const loaderCommands = [{ id: 'acp:compact', name: 'compact', content: '' }]; + const readyService = { + providerId: 'opencode', + isReady: jest.fn().mockReturnValue(true), + getSupportedCommands: jest.fn().mockResolvedValue(readyCommands), + }; + const mockCatalog = { + setRuntimeCommands: jest.fn(), + }; + const runtimeCommandLoader = { + isAvailable: jest.fn().mockReturnValue(true), + loadCommands: jest.fn().mockResolvedValue(loaderCommands), + }; + + ProviderWorkspaceRegistry.setServices('opencode', { + commandCatalog: mockCatalog as any, + runtimeCommandLoader: runtimeCommandLoader as any, + tabWarmupPolicy: commandWarmupPolicy as any, + }); + mockGetCapabilities.mockImplementation((providerId: string) => ({ + providerId, + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: providerId === 'claude', + supportsRewind: providerId === 'claude', + supportsFork: providerId === 'claude', + supportsProviderCommands: providerId === 'opencode' || providerId === 'claude', + reasoningControl: providerId === 'opencode' ? 'effort' : 'none', + })); + const plugin = createMockPlugin({ + getConversationById: jest.fn().mockResolvedValue({ + id: 'conv-opencode', + messages: [{ id: 'm1' }], + providerState: { databasePath: '/persisted/opencode.db' }, + sessionId: 'session-2', + }), + }); + const manager = createManager({ + plugin, + tabFactory: (n) => createMockTabData({ + id: `tab-${n}`, + providerId: 'opencode', + conversationId: n === 2 ? 'conv-opencode' : null, + lifecycleState: n === 2 ? 'bound_cold' : 'bound_active', + service: n === 1 ? readyService : null, + ui: { + externalContextSelector: { + getExternalContexts: jest.fn().mockReturnValue([]), + }, + }, + }), + }); + + await manager.createTab(); + readyService.getSupportedCommands.mockClear(); + const coldTab = await manager.createTab('conv-opencode'); + + await expect(manager.getSdkCommands(coldTab!.id)).resolves.toEqual(loaderCommands); + expect(readyService.getSupportedCommands).not.toHaveBeenCalled(); + expect(runtimeCommandLoader.loadCommands).toHaveBeenCalledTimes(1); + }); + + it('should keep an active restored Claude conversation tab cold', async () => { + ProviderWorkspaceRegistry.setServices('claude', { + commandCatalog: { + setRuntimeCommands: jest.fn(), + } as any, + }); + mockGetCapabilities.mockImplementation((providerId: string) => ({ + providerId, + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: true, + supportsFork: true, + supportsProviderCommands: providerId === 'claude', + reasoningControl: 'effort', + })); + const plugin = createMockPlugin({ + getConversationById: jest.fn().mockResolvedValue({ + id: 'conv-claude', + messages: [{ id: 'm1' }], + providerState: {}, + sessionId: 'session-1', + }), + }); + const manager = createManager({ + plugin, + tabFactory: () => createMockTabData({ + id: 'tab-claude-restored', + providerId: 'claude', + conversationId: 'conv-claude', + lifecycleState: 'bound_cold', + ui: { + externalContextSelector: { + getExternalContexts: jest.fn().mockReturnValue([]), + }, + }, + }), + }); + + const tab = await manager.createTab('conv-claude', 'tab-claude-restored', { activate: false }); + await flushMicrotasks(); + + expect(mockInitializeTabService).not.toHaveBeenCalled(); + expect(mockSetupServiceCallbacks).not.toHaveBeenCalled(); + expect(tab!.service).toBeNull(); + expect(tab!.serviceInitialized).toBe(false); + }); +}); + +describe('TabManager - Provider Command Catalog', () => { + const mockCatalogEntries = [ + { + id: 'codex-skill-analyze', providerId: 'codex', kind: 'skill', + name: 'analyze', description: 'Analyze code', content: '', + scope: 'vault', source: 'user', isEditable: true, isDeletable: true, + displayPrefix: '$', insertPrefix: '$', + }, + ]; + + const mockCatalog = { + listDropdownEntries: jest.fn().mockResolvedValue(mockCatalogEntries), + listVaultEntries: jest.fn().mockResolvedValue(mockCatalogEntries), + saveVaultEntry: jest.fn(), + deleteVaultEntry: jest.fn(), + setRuntimeCommands: jest.fn(), + getDropdownConfig: jest.fn().mockReturnValue({ + triggerChars: ['/', '$'], + builtInPrefix: '/', + skillPrefix: '$', + commandPrefix: '/', + }), + refresh: jest.fn(), + }; + + afterEach(() => { + ProviderWorkspaceRegistry.setServices('codex', undefined); + ProviderWorkspaceRegistry.setServices('claude', undefined); + ProviderWorkspaceRegistry.setServices('opencode', undefined); + }); + + it('should pass provider catalog config to initializeTabUI for Codex tab', async () => { + ProviderWorkspaceRegistry.setServices('codex', { commandCatalog: mockCatalog as any }); + + const manager = createManager({ + tabFactory: () => createMockTabData({ id: 'tab-1', providerId: 'codex' }), + }); + + await manager.createTab(); + + const options = mockInitializeTabUI.mock.calls[0][2]; + const catalogConfig = options.getProviderCatalogConfig(); + + expect(catalogConfig).not.toBeNull(); + expect(catalogConfig.config.triggerChars).toEqual(['/', '$']); + expect(catalogConfig.config.skillPrefix).toBe('$'); + }); + + it('should provide scan-backed entries for Codex without runtime', async () => { + ProviderWorkspaceRegistry.setServices('codex', { commandCatalog: mockCatalog as any }); + + const manager = createManager({ + tabFactory: () => createMockTabData({ id: 'tab-1', providerId: 'codex' }), + }); + + await manager.createTab(); + + const options = mockInitializeTabUI.mock.calls[0][2]; + const catalogConfig = options.getProviderCatalogConfig(); + const entries = await catalogConfig.getEntries(); + + expect(entries).toHaveLength(1); + expect(entries[0].name).toBe('analyze'); + expect(entries[0].displayPrefix).toBe('$'); + }); + + it('should resolve the blank-tab catalog from draftModel instead of stale providerId', async () => { + const claudeCatalog = { + listDropdownEntries: jest.fn().mockResolvedValue([ + { + id: 'claude-command-test', providerId: 'claude', kind: 'command', + name: 'claude-only', description: 'Claude command', content: '', + scope: 'vault', source: 'user', isEditable: true, isDeletable: true, + displayPrefix: '/', insertPrefix: '/', + }, + ]), + listVaultEntries: jest.fn().mockResolvedValue([]), + saveVaultEntry: jest.fn(), + deleteVaultEntry: jest.fn(), + setRuntimeCommands: jest.fn(), + getDropdownConfig: jest.fn().mockReturnValue({ + triggerChars: ['/'], + builtInPrefix: '/', + skillPrefix: '/', + commandPrefix: '/', + }), + refresh: jest.fn(), + }; + ProviderWorkspaceRegistry.setServices('claude', { commandCatalog: claudeCatalog as any }); + ProviderWorkspaceRegistry.setServices('codex', { commandCatalog: mockCatalog as any }); + + const manager = createManager({ + tabFactory: () => createMockTabData({ + id: 'tab-1', + lifecycleState: 'blank', + draftModel: TEST_CODEX_MODEL, + providerId: 'claude', + }), + }); + + await manager.createTab(); + + const options = mockInitializeTabUI.mock.calls[0][2]; + const catalogConfig = options.getProviderCatalogConfig(); + const entries = await catalogConfig.getEntries(); + + expect(catalogConfig).not.toBeNull(); + expect(catalogConfig.config.skillPrefix).toBe('$'); + expect(entries).toHaveLength(1); + expect(entries[0].providerId).toBe('codex'); + expect(mockCatalog.listDropdownEntries).toHaveBeenCalledWith({ includeBuiltIns: false }); + expect(claudeCatalog.listDropdownEntries).not.toHaveBeenCalled(); + }); + + it('should refresh Claude runtime commands before listing catalog entries', async () => { + const supportedCommands = [{ id: 'sdk:commit', name: 'commit', content: '', source: 'sdk' }]; + const readyService = { + providerId: 'claude', + isReady: jest.fn().mockReturnValue(true), + getSupportedCommands: jest.fn().mockResolvedValue(supportedCommands), + }; + const claudeCatalog = { + listDropdownEntries: jest.fn().mockResolvedValue([]), + listVaultEntries: jest.fn().mockResolvedValue([]), + saveVaultEntry: jest.fn(), + deleteVaultEntry: jest.fn(), + setRuntimeCommands: jest.fn(), + getDropdownConfig: jest.fn().mockReturnValue({ + triggerChars: ['/'], + builtInPrefix: '/', + skillPrefix: '/', + commandPrefix: '/', + }), + refresh: jest.fn(), + }; + ProviderWorkspaceRegistry.setServices('claude', { commandCatalog: claudeCatalog as any }); + + const manager = createManager({ + tabFactory: () => createMockTabData({ + id: 'tab-1', + providerId: 'claude', + service: readyService, + }), + }); + + await manager.createTab(); + + const options = mockInitializeTabUI.mock.calls[0][2]; + const catalogConfig = options.getProviderCatalogConfig(); + await catalogConfig.getEntries(); + + expect(readyService.getSupportedCommands).toHaveBeenCalledTimes(1); + expect(claudeCatalog.setRuntimeCommands).toHaveBeenCalledWith(supportedCommands); + expect(claudeCatalog.listDropdownEntries).toHaveBeenCalledWith({ includeBuiltIns: false }); + }); + + it('should clear Claude runtime commands when revalidation returns no commands', async () => { + const readyService = { + providerId: 'claude', + isReady: jest.fn().mockReturnValue(true), + getSupportedCommands: jest.fn().mockResolvedValue([]), + }; + const claudeCatalog = { + listDropdownEntries: jest.fn().mockResolvedValue([]), + listVaultEntries: jest.fn().mockResolvedValue([]), + saveVaultEntry: jest.fn(), + deleteVaultEntry: jest.fn(), + setRuntimeCommands: jest.fn(), + getDropdownConfig: jest.fn().mockReturnValue({ + triggerChars: ['/'], + builtInPrefix: '/', + skillPrefix: '/', + commandPrefix: '/', + }), + refresh: jest.fn(), + }; + ProviderWorkspaceRegistry.setServices('claude', { commandCatalog: claudeCatalog as any }); + + const manager = createManager({ + tabFactory: () => createMockTabData({ + id: 'tab-1', + providerId: 'claude', + service: readyService, + }), + }); + + const tab = await manager.createTab(); + + await expect(manager.getSdkCommands(tab!.id)).resolves.toEqual([]); + expect(claudeCatalog.setRuntimeCommands).toHaveBeenCalledWith([]); + }); + + it('starts blank-tab provider warmup in the background from the provider-change callback', async () => { + const manager = createManager(); + const tab = await manager.createTab(); + const options = mockInitializeTabUI.mock.calls[0][2]; + + let releaseWarmup!: () => void; + const prewarmSpy = jest.spyOn(manager as any, 'prewarmProviderTab').mockImplementation( + () => new Promise((resolve) => { + releaseWarmup = resolve; + }), + ); + + let settled = false; + const callbackPromise = Promise.resolve(options.onProviderChanged('opencode')).then(() => { + settled = true; + }); + + await Promise.resolve(); + + expect(prewarmSpy).toHaveBeenCalledWith(tab); + await callbackPromise; + expect(settled).toBe(true); + + releaseWarmup(); + }); + + it('should return null catalog config when provider has no catalog', async () => { + // No catalog assigned to registry for 'claude' + + const manager = createManager({ + tabFactory: () => createMockTabData({ id: 'tab-1', providerId: 'claude' }), + }); + + await manager.createTab(); + + const options = mockInitializeTabUI.mock.calls[0][2]; + const catalogConfig = options.getProviderCatalogConfig(); + + expect(catalogConfig).toBeNull(); + }); +}); + +describe('TabManager - Cleanup', () => { + let manager: TabManager; + + beforeEach(async () => { + manager = createManager(); + await manager.createTab(); + await manager.createTab(); + }); + + describe('destroy', () => { + it('should destroy all tabs', async () => { + await manager.destroy(); + + expect(mockDestroyTab).toHaveBeenCalledTimes(2); + expect(manager.getTabCount()).toBe(0); + }); + + it('should save all conversations before destroying', async () => { + const tabs = manager.getAllTabs(); + const saveFns = tabs.map(tab => tab.controllers.conversationController?.save); + + await manager.destroy(); + + saveFns.forEach(save => { + expect(save).toHaveBeenCalled(); + }); + }); + + it('should clear active tab ID', async () => { + expect(manager.getActiveTabId()).not.toBeNull(); + + await manager.destroy(); + + expect(manager.getActiveTabId()).toBeNull(); + }); + }); +}); + +describe('TabManager - Callback Wiring', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('ChatState callbacks during tab creation', () => { + it('should wire onStreamingChanged callback to TabManager callbacks', async () => { + const onTabStreamingChanged = jest.fn(); + const callbacks: TabManagerCallbacks = { onTabStreamingChanged }; + + let capturedCallbacks: any; + mockCreateTab.mockImplementation((opts: any) => { + capturedCallbacks = opts; + return createMockTabData({ id: 'test-tab' }); + }); + + const manager = new TabManager(createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView(), callbacks); + await manager.createTab(); + + // Trigger the onStreamingChanged callback + capturedCallbacks.onStreamingChanged(true); + + expect(onTabStreamingChanged).toHaveBeenCalledWith('test-tab', true); + }); + + it('should wire onTitleChanged callback to TabManager callbacks', async () => { + const onTabTitleChanged = jest.fn(); + const callbacks: TabManagerCallbacks = { onTabTitleChanged }; + + let capturedCallbacks: any; + mockCreateTab.mockImplementation((opts: any) => { + capturedCallbacks = opts; + return createMockTabData({ id: 'test-tab' }); + }); + + const manager = new TabManager(createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView(), callbacks); + await manager.createTab(); + + capturedCallbacks.onTitleChanged('New Title'); + + expect(onTabTitleChanged).toHaveBeenCalledWith('test-tab', 'New Title'); + }); + + it('should wire onAttentionChanged callback to TabManager callbacks', async () => { + const onTabAttentionChanged = jest.fn(); + const callbacks: TabManagerCallbacks = { onTabAttentionChanged }; + + let capturedCallbacks: any; + mockCreateTab.mockImplementation((opts: any) => { + capturedCallbacks = opts; + return createMockTabData({ id: 'test-tab' }); + }); + + const manager = new TabManager(createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView(), callbacks); + await manager.createTab(); + + capturedCallbacks.onAttentionChanged(true); + + expect(onTabAttentionChanged).toHaveBeenCalledWith('test-tab', true); + }); + + it('should wire onConversationIdChanged callback to sync tab conversationId', async () => { + const onTabConversationChanged = jest.fn(); + const callbacks: TabManagerCallbacks = { onTabConversationChanged }; + + let capturedCallbacks: any; + const tabData = createMockTabData({ id: 'test-tab', conversationId: null }); + mockCreateTab.mockImplementation((opts: any) => { + capturedCallbacks = opts; + return tabData; + }); + + const manager = new TabManager(createMockPlugin(), createMockMcpManager(), createMockEl(), createMockView(), callbacks); + await manager.createTab(); + + // Trigger the onConversationIdChanged callback (simulating conversation creation) + capturedCallbacks.onConversationIdChanged('new-conv-id'); + + // Tab's conversationId should be synced + expect(tabData.conversationId).toBe('new-conv-id'); + expect(onTabConversationChanged).toHaveBeenCalledWith('test-tab', 'new-conv-id'); + }); + }); +}); + +describe('TabManager - openConversation Current Tab Path', () => { + let manager: TabManager; + let plugin: any; + + beforeEach(async () => { + plugin = createMockPlugin(); + manager = createManager({ plugin }); + await manager.createTab(); + }); + + it('should open conversation in current tab when preferNewTab is false', async () => { + const activeTab = manager.getActiveTab(); + const switchTo = jest.fn().mockResolvedValue(undefined); + activeTab!.controllers.conversationController = { switchTo } as any; + + plugin.getConversationById.mockResolvedValue({ id: 'conv-to-open' }); + + await manager.openConversation('conv-to-open', false); + + expect(switchTo).toHaveBeenCalledWith('conv-to-open'); + }); + + it('should open conversation in current tab by default (preferNewTab defaults to false)', async () => { + const activeTab = manager.getActiveTab(); + const switchTo = jest.fn().mockResolvedValue(undefined); + activeTab!.controllers.conversationController = { switchTo } as any; + + plugin.getConversationById.mockResolvedValue({ id: 'conv-default' }); + + await manager.openConversation('conv-default'); + + expect(switchTo).toHaveBeenCalledWith('conv-default'); + }); + + it('should not modify tab.conversationId directly (waits for callback)', async () => { + const activeTab = manager.getActiveTab(); + const switchTo = jest.fn().mockResolvedValue(undefined); + activeTab!.controllers.conversationController = { switchTo } as any; + activeTab!.conversationId = null; + + plugin.getConversationById.mockResolvedValue({ id: 'conv-123' }); + + await manager.openConversation('conv-123', false); + + // conversationId should NOT be set by openConversation - it's synced via callback + expect(activeTab!.conversationId).toBeNull(); + }); + + it('should not open in current tab if at max tabs and preferNewTab is true', async () => { + for (let i = 0; i < DEFAULT_MAX_TABS - 1; i++) { + await manager.createTab(); + } + expect(manager.getTabCount()).toBe(DEFAULT_MAX_TABS); + + const activeTab = manager.getActiveTab(); + const switchTo = jest.fn().mockResolvedValue(undefined); + activeTab!.controllers.conversationController = { switchTo } as any; + + plugin.getConversationById.mockResolvedValue({ id: 'conv-max' }); + + // preferNewTab=true but at max, so should open in current tab + await manager.openConversation('conv-max', true); + + expect(switchTo).toHaveBeenCalledWith('conv-max'); + }); +}); + +describe('TabManager - Service Initialization Errors', () => { + it('should restore state without pre-warming any tabs', async () => { + mockCreateTab.mockReturnValue( + createMockTabData({ id: 'test-tab', serviceInitialized: false }) + ); + + const manager = new TabManager( + createMockPlugin(), + createMockMcpManager(), + createMockEl(), + createMockView() + ); + + const persistedState: PersistedTabManagerState = { + openTabs: [{ tabId: 'restored-tab', conversationId: null }], + activeTabId: 'restored-tab', + }; + + await manager.restoreState(persistedState); + + // No pre-warm: all restored tabs stay cold until send + expect(mockInitializeTabService).not.toHaveBeenCalled(); + }); +}); + +describe('TabManager - Concurrent Switch Guard', () => { + it('should execute the latest switch requested while another switch is pending', async () => { + const callbacks: TabManagerCallbacks = { + onTabSwitched: jest.fn(), + }; + const manager = createManager({ callbacks }); + + const tab1 = await manager.createTab(); + const tab2 = await manager.createTab(); + + // Set up tab-1 to trigger the async conversationController.switchTo path + // so that switchToTab hangs mid-execution with isSwitchingTab = true + let resolveSwitchTo!: () => void; + const hangingPromise = new Promise(resolve => { + resolveSwitchTo = resolve; + }); + tab1!.conversationId = 'conv-1'; + tab1!.state.messages = []; + tab1!.controllers.conversationController!.switchTo = jest.fn().mockReturnValue(hangingPromise); + + jest.clearAllMocks(); + + // Start first switch to tab-1 (will hang on conversationController.switchTo) + const firstSwitch = manager.switchToTab(tab1!.id); + + // While the first switch is in progress, retain the newer target. + await manager.switchToTab(tab2!.id); + + expect(mockDeactivateTab).toHaveBeenCalledTimes(1); + expect(mockActivateTab).toHaveBeenCalledTimes(1); + + // Resolve the hanging first switch + resolveSwitchTo(); + await firstSwitch; + + expect(callbacks.onTabSwitched).toHaveBeenCalledTimes(2); + expect(manager.getActiveTabId()).toBe(tab2!.id); + }); +}); + +describe('TabManager - closeTab Edge Cases', () => { + it('should return false for non-existent tab', async () => { + const manager = createManager(); + await manager.createTab(); + + const result = await manager.closeTab('non-existent-tab'); + expect(result).toBe(false); + }); + + it('should not close last empty tab (preserves warm service)', async () => { + const manager = createManager({ + tabFactory: () => createMockTabData({ id: 'only-tab' }), + }); + await manager.createTab(); + + const result = await manager.closeTab('only-tab'); + expect(result).toBe(false); + expect(manager.getTabCount()).toBe(1); + }); + + it('should create new blank tab (stays cold) when closing the last tab with conversation', async () => { + const callbacks: TabManagerCallbacks = { + onTabCreated: jest.fn(), + onTabClosed: jest.fn(), + }; + + const manager = createManager({ + callbacks, + tabFactory: (n) => createMockTabData({ + id: `tab-${n}`, + conversationId: n === 1 ? 'conv-existing' : null, + }), + }); + await manager.createTab(); + + jest.clearAllMocks(); + + // Close the only tab (has conversationId so it bypasses the last-empty-tab guard) + const result = await manager.closeTab('tab-1'); + + expect(result).toBe(true); + expect(manager.getTabCount()).toBe(1); // New tab was created + expect(mockCreateTab).toHaveBeenCalled(); + // No pre-warm: replacement blank tabs stay cold until send + expect(mockInitializeTabService).not.toHaveBeenCalled(); + expect(callbacks.onTabClosed).toHaveBeenCalledWith('tab-1'); + }); +}); + +describe('TabManager - forkToNewTab', () => { + it('should propagate currentNote from context to forked conversation', async () => { + const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1', providerId: 'claude' }); + const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); + + const plugin = createMockPlugin({ + createConversation: mockCreateConversation, + updateConversation: mockUpdateConversation, + }); + + const manager = createManager({ plugin }); + await manager.createTab(); + + await manager.forkToNewTab({ + messages: [ + { id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }, + { id: 'msg-2', role: 'assistant', content: 'hi', timestamp: 2 }, + ] as any, + sourceSessionId: 'session-1', + resumeAt: 'assistant-uuid-1', + currentNote: 'notes/test.md', + }); + + expect(mockUpdateConversation).toHaveBeenCalledWith('fork-conv-1', expect.objectContaining({ + currentNote: 'notes/test.md', + })); + }); + + it('should not set currentNote when context has none', async () => { + const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-2', providerId: 'claude' }); + const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); + + const plugin = createMockPlugin({ + createConversation: mockCreateConversation, + updateConversation: mockUpdateConversation, + }); + + const manager = createManager({ plugin }); + await manager.createTab(); + + await manager.forkToNewTab({ + messages: [ + { id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }, + { id: 'msg-2', role: 'assistant', content: 'hi', timestamp: 2 }, + ] as any, + sourceSessionId: 'session-1', + resumeAt: 'assistant-uuid-1', + }); + + const updateCall = mockUpdateConversation.mock.calls[0][1]; + expect(updateCall.currentNote).toBeUndefined(); + }); +}); + +describe('TabManager - forkInCurrentTab', () => { + it('should create fork conversation and switch active tab to it', async () => { + const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1', providerId: 'claude' }); + const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); + const mockSwitchTo = jest.fn().mockResolvedValue(undefined); + + const plugin = createMockPlugin({ + createConversation: mockCreateConversation, + updateConversation: mockUpdateConversation, + }); + + let tabCounter = 0; + mockCreateTab.mockImplementation(() => { + tabCounter++; + return createMockTabData({ + id: `tab-${tabCounter}`, + controllers: { + conversationController: { + save: jest.fn().mockResolvedValue(undefined), + switchTo: mockSwitchTo, + initializeWelcome: jest.fn(), + }, + inputController: { handleApprovalRequest: jest.fn() }, + }, + }); + }); + + const manager = new TabManager( + plugin, + createMockMcpManager(), + createMockEl(), + createMockView() + ); + await manager.createTab(); + + const success = await manager.forkInCurrentTab({ + messages: [{ id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }] as any, + sourceSessionId: 'session-1', + resumeAt: 'assistant-uuid-1', + currentNote: 'notes/test.md', + sourceTitle: 'My Chat', + forkAtUserMessage: 1, + }); + + expect(success).toBe(true); + expect(mockCreateConversation).toHaveBeenCalled(); + expect(mockUpdateConversation).toHaveBeenCalledWith('fork-conv-1', expect.objectContaining({ + providerState: { forkSource: { sessionId: 'session-1', resumeAt: 'assistant-uuid-1' } }, + currentNote: 'notes/test.md', + })); + expect(mockSwitchTo).toHaveBeenCalledWith('fork-conv-1'); + }); + + it('should return false when no active tab exists', async () => { + const plugin = createMockPlugin({ + createConversation: jest.fn().mockResolvedValue({ id: 'fork-conv-2', providerId: 'claude' }), + updateConversation: jest.fn().mockResolvedValue(undefined), + }); + + const manager = createManager({ plugin }); + // Don't create any tabs + + const success = await manager.forkInCurrentTab({ + messages: [] as any, + sourceSessionId: 'session-1', + resumeAt: 'assistant-uuid-1', + }); + + expect(success).toBe(false); + }); + + it('should not check tab count limit', async () => { + const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-3', providerId: 'claude' }); + const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); + const mockSwitchTo = jest.fn().mockResolvedValue(undefined); + + const plugin = createMockPlugin({ + createConversation: mockCreateConversation, + updateConversation: mockUpdateConversation, + settings: { maxTabs: 3 }, + }); + + let tabCounter = 0; + mockCreateTab.mockImplementation(() => { + tabCounter++; + return createMockTabData({ + id: `tab-${tabCounter}`, + controllers: { + conversationController: { + save: jest.fn().mockResolvedValue(undefined), + switchTo: mockSwitchTo, + initializeWelcome: jest.fn(), + }, + inputController: { handleApprovalRequest: jest.fn() }, + }, + }); + }); + + const manager = new TabManager( + plugin, + createMockMcpManager(), + createMockEl(), + createMockView() + ); + + // Fill all tabs to max + await manager.createTab(); + await manager.createTab(); + await manager.createTab(); + + // forkInCurrentTab should still work even at max tabs + const success = await manager.forkInCurrentTab({ + messages: [{ id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }] as any, + sourceSessionId: 'session-1', + resumeAt: 'assistant-uuid-1', + }); + + expect(success).toBe(true); + expect(mockSwitchTo).toHaveBeenCalled(); + }); +}); + +describe('TabManager - switchToTab Session Sync', () => { + it('should sync service session for already-loaded tab with conversation', async () => { + jest.clearAllMocks(); + + const mockSyncConversationState = jest.fn(); + const mockService = { + syncConversationState: mockSyncConversationState, + cleanup: jest.fn(), + ensureReady: jest.fn().mockResolvedValue(true), + onReadyStateChange: jest.fn(() => () => {}), + isReady: jest.fn().mockReturnValue(true), + }; + + let tabCounter = 0; + mockCreateTab.mockImplementation(() => { + tabCounter++; + const tab = createMockTabData({ + id: `tab-${tabCounter}`, + conversationId: tabCounter === 2 ? 'conv-loaded' : null, + service: tabCounter === 2 ? mockService : null, + serviceInitialized: tabCounter === 2, + }); + // For tab-2, simulate already having messages loaded + if (tabCounter === 2) { + tab.state.messages = [{ id: 'msg-1', role: 'user', content: 'test' }] as any; + } + return tab; + }); + + const plugin = createMockPlugin(); + plugin.getConversationSync = jest.fn().mockReturnValue({ + id: 'conv-loaded', + messages: [{ id: 'msg-1', role: 'user', content: 'test' }], + sessionId: 'session-xyz', + externalContextPaths: ['/some/path'], + }); + + const manager = new TabManager( + plugin, + createMockMcpManager(), + createMockEl(), + createMockView() + ); + + await manager.createTab(); // tab-1, active + await manager.createTab(); // tab-2, auto-switches and triggers session sync + + // Should have synced the service session during auto-switch to tab-2 + expect(mockSyncConversationState).toHaveBeenCalledWith( + expect.objectContaining({ id: 'conv-loaded', sessionId: 'session-xyz' }), + ['/some/path'], + ); + }); + + it('should use persistentExternalContextPaths when conversation has no messages', async () => { + jest.clearAllMocks(); + + const mockSyncConversationState = jest.fn(); + const mockService = { + syncConversationState: mockSyncConversationState, + }; + + let tabCounter = 0; + mockCreateTab.mockImplementation(() => { + tabCounter++; + const tab = createMockTabData({ + id: `tab-${tabCounter}`, + conversationId: tabCounter === 2 ? 'conv-empty' : null, + service: tabCounter === 2 ? mockService : null, + serviceInitialized: tabCounter === 2, + }); + // Tab has local messages but the persisted conversation does not + if (tabCounter === 2) { + tab.state.messages = [{ id: 'msg-1', role: 'user', content: 'test' }] as any; + } + return tab; + }); + + const plugin = createMockPlugin({ + settings: { + maxTabs: DEFAULT_MAX_TABS, + persistentExternalContextPaths: ['/persistent/path'], + }, + }); + plugin.getConversationSync = jest.fn().mockReturnValue({ + id: 'conv-empty', + messages: [], + sessionId: 'session-abc', + externalContextPaths: [], + }); + + const manager = new TabManager( + plugin, + createMockMcpManager(), + createMockEl(), + createMockView() + ); + + await manager.createTab(); // tab-1 + await manager.createTab(); // tab-2, auto-switches and triggers session sync + + // conversation.messages is empty, so should fall back to persistentExternalContextPaths + expect(mockSyncConversationState).toHaveBeenCalledWith( + expect.objectContaining({ id: 'conv-empty', sessionId: 'session-abc' }), + ['/persistent/path'], + ); + }); + + it('should not sync service session for an already-loaded streaming tab', async () => { + jest.clearAllMocks(); + + const mockSyncConversationState = jest.fn(); + const mockService = { + syncConversationState: mockSyncConversationState, + }; + + let tabCounter = 0; + mockCreateTab.mockImplementation(() => { + tabCounter++; + + if (tabCounter === 1) { + return createMockTabData({ id: 'tab-1' }); + } + + return createMockTabData({ + id: 'tab-2', + conversationId: 'conv-streaming', + service: mockService, + serviceInitialized: true, + state: { + isStreaming: true, + messages: [{ id: 'msg-1', role: 'user', content: 'test' }], + }, + }); + }); + + const plugin = createMockPlugin(); + plugin.getConversationSync = jest.fn().mockReturnValue({ + id: 'conv-streaming', + messages: [{ id: 'msg-1', role: 'user', content: 'test' }], + sessionId: 'session-stream', + externalContextPaths: ['/some/path'], + }); + + const manager = new TabManager( + plugin, + createMockMcpManager(), + createMockEl(), + createMockView() + ); + + await manager.createTab(); + const backgroundStreamingTab = await manager.createTab(undefined, undefined, { activate: false }); + + jest.clearAllMocks(); + + await manager.switchToTab(backgroundStreamingTab!.id); + + expect(plugin.getConversationSync).not.toHaveBeenCalled(); + expect(mockSyncConversationState).not.toHaveBeenCalled(); + }); + + it('should not sync service session when local conversation state is pending save', async () => { + jest.clearAllMocks(); + + const mockSyncConversationState = jest.fn(); + const mockService = { + syncConversationState: mockSyncConversationState, + }; + + let tabCounter = 0; + mockCreateTab.mockImplementation(() => { + tabCounter++; + + if (tabCounter === 1) { + return createMockTabData({ id: 'tab-1' }); + } + + return createMockTabData({ + id: 'tab-2', + conversationId: 'conv-pending-save', + service: mockService, + serviceInitialized: true, + state: { + hasPendingConversationSave: true, + messages: [{ id: 'msg-1', role: 'user', content: 'test' }], + }, + }); + }); + + const plugin = createMockPlugin(); + plugin.getConversationSync = jest.fn().mockReturnValue({ + id: 'conv-pending-save', + messages: [], + sessionId: null, + externalContextPaths: [], + }); + + const manager = new TabManager( + plugin, + createMockMcpManager(), + createMockEl(), + createMockView() + ); + + await manager.createTab(); + const pendingSaveTab = await manager.createTab(undefined, undefined, { activate: false }); + + jest.clearAllMocks(); + + await manager.switchToTab(pendingSaveTab!.id); + + expect(plugin.getConversationSync).not.toHaveBeenCalled(); + expect(mockSyncConversationState).not.toHaveBeenCalled(); + }); + + it('should initialize welcome for new tab without conversation', async () => { + jest.clearAllMocks(); + + const mockInitializeWelcome = jest.fn(); + let tabCounter = 0; + mockCreateTab.mockImplementation(() => { + tabCounter++; + const tab = createMockTabData({ id: `tab-${tabCounter}` }); + tab.controllers.conversationController = { + ...tab.controllers.conversationController, + initializeWelcome: mockInitializeWelcome, + }; + return tab; + }); + + const manager = new TabManager( + createMockPlugin(), + createMockMcpManager(), + createMockEl(), + createMockView() + ); + + await manager.createTab(); // tab-1 + await manager.createTab(); // tab-2 (now active) + + // Switch to tab-1 first so we can switch back to tab-2 + await manager.switchToTab('tab-1'); + mockInitializeWelcome.mockClear(); + + // Switch to tab-2 (no conversationId, no messages -> should call initializeWelcome) + await manager.switchToTab('tab-2'); + + expect(mockInitializeWelcome).toHaveBeenCalled(); + }); +}); + +describe('TabManager - handleForkRequest (modal dispatch)', () => { + it('should fork to new tab when user selects "new-tab"', async () => { + mockChooseForkTarget.mockResolvedValue('new-tab'); + + const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1', providerId: 'claude' }); + const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); + + const plugin = createMockPlugin({ + createConversation: mockCreateConversation, + updateConversation: mockUpdateConversation, + }); + + let capturedForkCallback: any; + mockInitializeTabControllers.mockImplementation( + (_tab: any, _plugin: any, _view: any, forkCb: any) => { + capturedForkCallback = forkCb; + } + ); + + const manager = createManager({ plugin }); + await manager.createTab(); + + // Invoke the fork callback that was passed to initializeTabControllers + await capturedForkCallback({ + messages: [{ id: 'msg-1', role: 'user', content: 'hello', timestamp: 1 }], + sourceSessionId: 'session-1', + resumeAt: 'asst-uuid-1', + sourceTitle: 'Test Chat', + forkAtUserMessage: 1, + }); + + expect(mockChooseForkTarget).toHaveBeenCalled(); + expect(mockCreateConversation).toHaveBeenCalled(); + expect(mockUpdateConversation).toHaveBeenCalled(); + }); + + it('should fork in current tab when user selects "current-tab"', async () => { + mockChooseForkTarget.mockResolvedValue('current-tab'); + + const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-2', providerId: 'claude' }); + const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); + const mockSwitchTo = jest.fn().mockResolvedValue(undefined); + + const plugin = createMockPlugin({ + createConversation: mockCreateConversation, + updateConversation: mockUpdateConversation, + }); + + let capturedForkCallback: any; + let tabCounter = 0; + mockCreateTab.mockImplementation(() => { + tabCounter++; + return createMockTabData({ + id: `tab-${tabCounter}`, + controllers: { + conversationController: { + save: jest.fn().mockResolvedValue(undefined), + switchTo: mockSwitchTo, + initializeWelcome: jest.fn(), + }, + inputController: { handleApprovalRequest: jest.fn() }, + }, + }); + }); + mockInitializeTabControllers.mockImplementation( + (_tab: any, _plugin: any, _view: any, forkCb: any) => { + capturedForkCallback = forkCb; + } + ); + + const manager = new TabManager( + plugin, + createMockMcpManager(), + createMockEl(), + createMockView() + ); + await manager.createTab(); + + await capturedForkCallback({ + messages: [], + sourceSessionId: 'session-1', + resumeAt: 'asst-uuid-1', + }); + + expect(mockChooseForkTarget).toHaveBeenCalled(); + expect(mockSwitchTo).toHaveBeenCalledWith('fork-conv-2'); + }); + + it('should do nothing when user cancels modal', async () => { + mockChooseForkTarget.mockResolvedValue(null); + + const mockCreateConversation = jest.fn(); + const plugin = createMockPlugin({ createConversation: mockCreateConversation }); + + let capturedForkCallback: any; + mockInitializeTabControllers.mockImplementation( + (_tab: any, _plugin: any, _view: any, forkCb: any) => { + capturedForkCallback = forkCb; + } + ); + + const manager = createManager({ plugin }); + await manager.createTab(); + + await capturedForkCallback({ + messages: [], + sourceSessionId: 'session-1', + resumeAt: 'asst-uuid-1', + }); + + expect(mockChooseForkTarget).toHaveBeenCalled(); + expect(mockCreateConversation).not.toHaveBeenCalled(); + }); +}); + +describe('TabManager - forkToNewTab at max tabs', () => { + it('should return null when at max tabs', async () => { + jest.clearAllMocks(); + + const plugin = createMockPlugin(); + // MIN_TABS is 3, so maxTabs must be >= 3 to avoid clamping + plugin.settings.maxTabs = 3; + plugin.createConversation = jest.fn().mockResolvedValue({ id: 'fork-conv', providerId: 'claude' }); + plugin.updateConversation = jest.fn().mockResolvedValue(undefined); + + let tabCounter = 0; + mockCreateTab.mockImplementation(() => { + tabCounter++; + return createMockTabData({ id: `tab-${tabCounter}` }); + }); + + const manager = new TabManager( + plugin, + createMockMcpManager(), + createMockEl(), + createMockView() + ); + + await manager.createTab(); + await manager.createTab(); + await manager.createTab(); + expect(manager.getTabCount()).toBe(3); + + const result = await manager.forkToNewTab({ + messages: [], + sourceSessionId: 'session-1', + resumeAt: 'asst-uuid', + }); + + expect(result).toBeNull(); + }); +}); + +describe('TabManager - createForkConversation', () => { + it('should set forkSource with sessionId and resumeAt', async () => { + const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1', providerId: 'claude' }); + const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); + + const plugin = createMockPlugin({ + createConversation: mockCreateConversation, + updateConversation: mockUpdateConversation, + }); + + const manager = createManager({ plugin }); + await manager.createTab(); + + await manager.forkToNewTab({ + messages: [], + sourceSessionId: 'session-abc', + resumeAt: 'asst-uuid-xyz', + }); + + expect(mockUpdateConversation).toHaveBeenCalledWith('fork-conv-1', expect.objectContaining({ + providerState: { forkSource: { sessionId: 'session-abc', resumeAt: 'asst-uuid-xyz' } }, + })); + }); + + it('should create the fork conversation with the source provider', async () => { + const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-codex', providerId: 'codex' }); + const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); + + const plugin = createMockPlugin({ + createConversation: mockCreateConversation, + updateConversation: mockUpdateConversation, + }); + + const manager = createManager({ plugin }); + await manager.createTab(); + + await manager.forkToNewTab({ + messages: [], + providerId: 'codex', + sourceSessionId: 'session-codex', + resumeAt: 'asst-codex', + }); + + expect(mockCreateConversation).toHaveBeenCalledWith({ providerId: 'codex' }); + }); + + it('should not set title when sourceTitle is undefined', async () => { + const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv-1', providerId: 'claude' }); + const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); + + const plugin = createMockPlugin({ + createConversation: mockCreateConversation, + updateConversation: mockUpdateConversation, + }); + + const manager = createManager({ plugin }); + await manager.createTab(); + + await manager.forkToNewTab({ + messages: [], + sourceSessionId: 'session-1', + resumeAt: 'asst-uuid-1', + // no sourceTitle + }); + + const updateCall = mockUpdateConversation.mock.calls[0][1]; + expect(updateCall.title).toBeUndefined(); + }); +}); + +describe('TabManager - buildForkTitle', () => { + function setupTitleTest(existingTitles: string[] = []) { + const mockCreateConversation = jest.fn().mockResolvedValue({ id: 'fork-conv', providerId: 'claude' }); + const mockUpdateConversation = jest.fn().mockResolvedValue(undefined); + + const plugin = createMockPlugin({ + createConversation: mockCreateConversation, + updateConversation: mockUpdateConversation, + getConversationList: jest.fn().mockReturnValue( + existingTitles.map((t, i) => ({ id: `conv-${i}`, title: t })) + ), + }); + + return { plugin, mockUpdateConversation }; + } + + it('should format title as "Fork: {source} (#{num})"', async () => { + const { plugin, mockUpdateConversation } = setupTitleTest(); + const manager = createManager({ plugin }); + await manager.createTab(); + + await manager.forkToNewTab({ + messages: [], + sourceSessionId: 'session-1', + resumeAt: 'asst-uuid-1', + sourceTitle: 'My Chat', + forkAtUserMessage: 3, + }); + + const updateCall = mockUpdateConversation.mock.calls[0][1]; + expect(updateCall.title).toBe('Fork: My Chat (#3)'); + }); + + it('should format title without message number when not provided', async () => { + const { plugin, mockUpdateConversation } = setupTitleTest(); + const manager = createManager({ plugin }); + await manager.createTab(); + + await manager.forkToNewTab({ + messages: [], + sourceSessionId: 'session-1', + resumeAt: 'asst-uuid-1', + sourceTitle: 'My Chat', + }); + + const updateCall = mockUpdateConversation.mock.calls[0][1]; + expect(updateCall.title).toBe('Fork: My Chat'); + }); + + it('should truncate long source titles', async () => { + const { plugin, mockUpdateConversation } = setupTitleTest(); + const manager = createManager({ plugin }); + await manager.createTab(); + + const longTitle = 'A'.repeat(100); + await manager.forkToNewTab({ + messages: [], + sourceSessionId: 'session-1', + resumeAt: 'asst-uuid-1', + sourceTitle: longTitle, + forkAtUserMessage: 1, + }); + + const updateCall = mockUpdateConversation.mock.calls[0][1]; + expect(updateCall.title.length).toBeLessThanOrEqual(50); + expect(updateCall.title).toContain('…'); + expect(updateCall.title).toContain('Fork: '); + expect(updateCall.title).toContain('(#1)'); + }); + + it('should deduplicate title when same fork title exists', async () => { + const { plugin, mockUpdateConversation } = setupTitleTest(['Fork: My Chat (#1)']); + const manager = createManager({ plugin }); + await manager.createTab(); + + await manager.forkToNewTab({ + messages: [], + sourceSessionId: 'session-1', + resumeAt: 'asst-uuid-1', + sourceTitle: 'My Chat', + forkAtUserMessage: 1, + }); + + const updateCall = mockUpdateConversation.mock.calls[0][1]; + expect(updateCall.title).toBe('Fork: My Chat (#1) 2'); + }); + + it('should find next available dedup number', async () => { + const { plugin, mockUpdateConversation } = setupTitleTest([ + 'Fork: My Chat (#1)', + 'Fork: My Chat (#1) 2', + 'Fork: My Chat (#1) 3', + ]); + const manager = createManager({ plugin }); + await manager.createTab(); + + await manager.forkToNewTab({ + messages: [], + sourceSessionId: 'session-1', + resumeAt: 'asst-uuid-1', + sourceTitle: 'My Chat', + forkAtUserMessage: 1, + }); + + const updateCall = mockUpdateConversation.mock.calls[0][1]; + expect(updateCall.title).toBe('Fork: My Chat (#1) 4'); + }); +}); diff --git a/tests/unit/features/chat/tabs/index.test.ts b/tests/unit/features/chat/tabs/index.test.ts new file mode 100644 index 0000000..6f38ab9 --- /dev/null +++ b/tests/unit/features/chat/tabs/index.test.ts @@ -0,0 +1,11 @@ +import { createTab } from '@/features/chat/tabs/Tab'; +import { TabBar } from '@/features/chat/tabs/TabBar'; +import { TabManager } from '@/features/chat/tabs/TabManager'; + +describe('features/chat/tabs index', () => { + it('re-exports runtime symbols', () => { + expect(createTab).toBeDefined(); + expect(TabBar).toBeDefined(); + expect(TabManager).toBeDefined(); + }); +}); diff --git a/tests/unit/features/chat/ui/BangBashModeManager.test.ts b/tests/unit/features/chat/ui/BangBashModeManager.test.ts new file mode 100644 index 0000000..74a1d01 --- /dev/null +++ b/tests/unit/features/chat/ui/BangBashModeManager.test.ts @@ -0,0 +1,321 @@ +import { BangBashModeManager } from '@/features/chat/ui/BangBashModeManager'; + +function createWrapper() { + return { + addClass: jest.fn(), + removeClass: jest.fn(), + } as any; +} + +function createKeyEvent(key: string, options: { shiftKey?: boolean } = {}) { + return { + key, + shiftKey: options.shiftKey ?? false, + isComposing: false, + preventDefault: jest.fn(), + } as any; +} + +describe('BangBashModeManager', () => { + it('should enter bash mode on ! keystroke when input is empty', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + const e = createKeyEvent('!'); + const handled = manager.handleTriggerKey(e); + + expect(handled).toBe(true); + expect(e.preventDefault).toHaveBeenCalled(); + expect(manager.isActive()).toBe(true); + expect(wrapper.addClass).toHaveBeenCalledWith('claudian-input-bang-bash-mode'); + }); + + it('should NOT enter bash mode on ! keystroke when input has content', () => { + const wrapper = createWrapper(); + const inputEl = { value: 'hello', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + const e = createKeyEvent('!'); + const handled = manager.handleTriggerKey(e); + + expect(handled).toBe(false); + expect(e.preventDefault).not.toHaveBeenCalled(); + expect(manager.isActive()).toBe(false); + expect(wrapper.addClass).not.toHaveBeenCalled(); + }); + + it('should NOT enter bash mode when already active', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('!')); + expect(manager.isActive()).toBe(true); + + // Try triggering again while active + inputEl.value = ''; + const e = createKeyEvent('!'); + const handled = manager.handleTriggerKey(e); + + expect(handled).toBe(false); + }); + + it('should stay in bash mode when input is cleared (exit via Escape)', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('!')); + expect(manager.isActive()).toBe(true); + + inputEl.value = ''; + manager.handleInputChange(); + + expect(manager.isActive()).toBe(true); + expect(wrapper.removeClass).not.toHaveBeenCalled(); + }); + + it('should submit command on Enter and trim whitespace', async () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('!')); + + inputEl.value = ' ls -la '; + manager.handleInputChange(); + + const e = createKeyEvent('Enter'); + const handled = manager.handleKeydown(e); + + expect(handled).toBe(true); + expect(e.preventDefault).toHaveBeenCalled(); + + // Wait for async submit + await new Promise(resolve => setTimeout(resolve, 0)); + expect(callbacks.onSubmit).toHaveBeenCalledWith('ls -la'); + }); + + it('should handle Enter when command is empty (no submit)', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('!')); + + inputEl.value = ' '; + manager.handleInputChange(); + + const e = createKeyEvent('Enter'); + const handled = manager.handleKeydown(e); + + expect(handled).toBe(true); + expect(e.preventDefault).toHaveBeenCalled(); + expect(callbacks.onSubmit).not.toHaveBeenCalled(); + }); + + it('should cancel on Escape and clear input', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('!')); + expect(manager.isActive()).toBe(true); + + inputEl.value = 'hello'; + manager.handleInputChange(); + + const e = createKeyEvent('Escape'); + const handled = manager.handleKeydown(e); + + expect(handled).toBe(true); + expect(e.preventDefault).toHaveBeenCalled(); + expect(inputEl.value).toBe(''); + expect(manager.isActive()).toBe(false); + }); + + it('should return false for non-Enter/Escape keys when active', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('!')); + expect(manager.isActive()).toBe(true); + + inputEl.value = 'some text'; + manager.handleInputChange(); + + const e = createKeyEvent('a'); + const handled = manager.handleKeydown(e); + + expect(handled).toBe(false); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + it('should return raw command text via getRawCommand', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('!')); + + inputEl.value = 'npm test'; + manager.handleInputChange(); + + expect(manager.getRawCommand()).toBe('npm test'); + }); + + it('should clear input, exit mode and reset input height on clear()', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const resetInputHeight = jest.fn(); + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + resetInputHeight, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('!')); + expect(manager.isActive()).toBe(true); + + inputEl.value = 'some command'; + manager.handleInputChange(); + + manager.clear(); + + expect(inputEl.value).toBe(''); + expect(manager.isActive()).toBe(false); + expect(resetInputHeight).toHaveBeenCalled(); + }); + + it('should remove bash mode class and restore placeholder on destroy()', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('!')); + expect(manager.isActive()).toBe(true); + + manager.destroy(); + + expect(wrapper.removeClass).toHaveBeenCalledWith('claudian-input-bang-bash-mode'); + expect(inputEl.placeholder).toBe('Ask...'); + }); + + it('should not enter mode when wrapper is null', () => { + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => null, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + const e = createKeyEvent('!'); + const handled = manager.handleTriggerKey(e); + + expect(handled).toBe(false); + expect(manager.isActive()).toBe(false); + }); + + it('should prevent double-submit when Enter is pressed rapidly', async () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + let resolveSubmit: () => void; + const submitPromise = new Promise((resolve) => { resolveSubmit = resolve; }); + const callbacks = { + onSubmit: jest.fn().mockReturnValue(submitPromise), + getInputWrapper: () => wrapper, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('!')); + + inputEl.value = 'ls -la'; + manager.handleInputChange(); + + // First Enter + manager.handleKeydown(createKeyEvent('Enter')); + await new Promise(resolve => setTimeout(resolve, 0)); + + // Re-enter mode and try to submit again while first is still running + manager.handleTriggerKey(createKeyEvent('!')); + inputEl.value = 'echo second'; + manager.handleInputChange(); + manager.handleKeydown(createKeyEvent('Enter')); + await new Promise(resolve => setTimeout(resolve, 0)); + + // Only the first submit should have been called + expect(callbacks.onSubmit).toHaveBeenCalledTimes(1); + expect(callbacks.onSubmit).toHaveBeenCalledWith('ls -la'); + + // Resolve the first submit + resolveSubmit!(); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + + it('should not produce unhandled rejection when onSubmit throws', async () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockRejectedValue(new Error('boom')), + getInputWrapper: () => wrapper, + }; + + const manager = new BangBashModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('!')); + + inputEl.value = 'bad-command'; + manager.handleInputChange(); + + manager.handleKeydown(createKeyEvent('Enter')); + + // Wait for async submit to complete + await new Promise(resolve => setTimeout(resolve, 0)); + + // Should not throw, error is caught internally + expect(callbacks.onSubmit).toHaveBeenCalledWith('bad-command'); + expect(manager.isActive()).toBe(false); + }); +}); diff --git a/tests/unit/features/chat/ui/ComposerContextTray.test.ts b/tests/unit/features/chat/ui/ComposerContextTray.test.ts new file mode 100644 index 0000000..2bfab9e --- /dev/null +++ b/tests/unit/features/chat/ui/ComposerContextTray.test.ts @@ -0,0 +1,156 @@ +import { createMockEl } from '@test/helpers/mockElement'; + +import { ComposerContextTray } from '@/features/chat/ui/ComposerContextTray'; + +jest.mock('obsidian', () => ({ + setIcon: jest.fn(), +})); + +describe('ComposerContextTray', () => { + it('owns empty-state visibility and renders slots in semantic order', () => { + const containerEl = createMockEl(); + const tray = new ComposerContextTray(containerEl as unknown as HTMLElement); + + expect(containerEl.hasClass('has-content')).toBe(false); + + tray.setItems('images', [{ + id: 'image-1', + kind: 'image', + label: 'Image', + onRemove: jest.fn(), + }]); + tray.setItems('editor-selection', [{ + id: 'editor-selection', + kind: 'selection', + label: '3 lines · Draft.md', + icon: 'text-select', + onRemove: jest.fn(), + }]); + tray.setItems('current-note', [{ + id: 'current-note', + kind: 'note', + label: 'Draft.md', + icon: 'file-text', + onRemove: jest.fn(), + }]); + + expect(containerEl.hasClass('has-content')).toBe(true); + expect(containerEl.querySelectorAll('.claudian-context-chip').map((item: any) => item.dataset.contextSlot)).toEqual([ + 'current-note', + 'editor-selection', + 'images', + ]); + }); + + it('uses separate keyboard-focusable controls for activation and removal', () => { + const containerEl = createMockEl(); + const onActivate = jest.fn(); + const onRemove = jest.fn(); + const tray = new ComposerContextTray(containerEl as unknown as HTMLElement); + + tray.setItems('current-note', [{ + id: 'current-note', + kind: 'note', + label: 'Architecture.md', + icon: 'file-text', + title: 'notes/Architecture.md', + onActivate, + onRemove, + }]); + + const mainButton = containerEl.querySelector('.claudian-context-chip-main'); + const removeButton = containerEl.querySelector('.claudian-context-chip-remove'); + + expect(mainButton?.tagName).toBe('BUTTON'); + expect(removeButton?.tagName).toBe('BUTTON'); + expect(mainButton?.getAttribute('title')).toBe('notes/Architecture.md'); + + mainButton?.click(); + removeButton?.click(); + + expect(onActivate).toHaveBeenCalledTimes(1); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('collapses content after the first visual row and exposes the hidden count', () => { + const containerEl = createMockEl(); + const tray = new ComposerContextTray(containerEl as unknown as HTMLElement); + + tray.setItems('images', Array.from({ length: 4 }, (_, index) => ({ + id: `image-${index}`, + kind: 'image' as const, + label: `image-${index}.png`, + onRemove: jest.fn(), + }))); + + const chips = containerEl.querySelectorAll('.claudian-context-chip'); + [0, 0, 38, 76].forEach((offsetTop, index) => { + Object.defineProperty(chips[index], 'offsetTop', { configurable: true, value: offsetTop }); + }); + + tray.refreshLayout(); + + expect(chips[2].hasClass('claudian-context-chip--overflow-hidden')).toBe(true); + expect(chips[3].hasClass('claudian-context-chip--overflow-hidden')).toBe(true); + const moreButton = containerEl.querySelector('.claudian-context-more'); + expect(moreButton?.textContent).toBe('+2 more'); + + moreButton?.click(); + + expect(containerEl.hasClass('claudian-context-row--expanded')).toBe(true); + expect(chips.every((chip: any) => !chip.hasClass('claudian-context-chip--overflow-hidden'))).toBe(true); + expect(moreButton?.textContent).toBe('Show less'); + }); + + it('does not collapse vertically centered items that share one flex row', () => { + const containerEl = createMockEl(); + const tray = new ComposerContextTray(containerEl as unknown as HTMLElement); + + tray.setItems('current-note', [{ + id: 'note', + kind: 'note', + label: 'Note.md', + onRemove: jest.fn(), + }]); + tray.setItems('editor-selection', [{ + id: 'selection', + kind: 'selection', + label: '1 line selected', + onRemove: jest.fn(), + }]); + tray.setItems('images', [{ + id: 'image', + kind: 'image', + label: 'Image', + onRemove: jest.fn(), + }]); + + const chips = containerEl.querySelectorAll('.claudian-context-chip'); + [[4, 24], [0, 32], [4, 24]].forEach(([offsetTop, offsetHeight], index) => { + Object.defineProperties(chips[index], { + offsetTop: { configurable: true, value: offsetTop }, + offsetHeight: { configurable: true, value: offsetHeight }, + }); + }); + + tray.refreshLayout(); + + expect(containerEl.querySelector('.claudian-context-more')?.hasClass('claudian-hidden')).toBe(true); + }); + + it('removes the tray when the final owner clears its items', () => { + const containerEl = createMockEl(); + const tray = new ComposerContextTray(containerEl as unknown as HTMLElement); + + tray.setItems('canvas-selection', [{ + id: 'canvas-selection', + kind: 'selection', + label: '2 nodes · Board.canvas', + onRemove: jest.fn(), + }]); + tray.clearItems('canvas-selection'); + + expect(containerEl.hasClass('has-content')).toBe(false); + expect(containerEl.children).toHaveLength(0); + }); +}); diff --git a/tests/unit/features/chat/ui/ExternalContextSelector.test.ts b/tests/unit/features/chat/ui/ExternalContextSelector.test.ts new file mode 100644 index 0000000..7b7516e --- /dev/null +++ b/tests/unit/features/chat/ui/ExternalContextSelector.test.ts @@ -0,0 +1,555 @@ +import { createMockEl } from '@test/helpers/mockElement'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { ExternalContextSelector } from '@/features/chat/ui/InputToolbar'; + +// Mock obsidian +jest.mock('obsidian', () => ({ + Notice: jest.fn(), + setIcon: jest.fn(), +})); + +// Mock fs +jest.mock('fs'); + +// Mock callbacks +function createMockCallbacks() { + return { + onModelChange: jest.fn(), + onModeChange: jest.fn().mockResolvedValue(undefined), + onThinkingBudgetChange: jest.fn(), + onEffortLevelChange: jest.fn().mockResolvedValue(undefined), + onServiceTierChange: jest.fn().mockResolvedValue(undefined), + onPermissionModeChange: jest.fn(), + getSettings: jest.fn().mockReturnValue({ + model: 'haiku', + thinkingBudget: 'off', + effortLevel: 'high', + serviceTier: 'default', + permissionMode: 'yolo', + }), + getEnvironmentVariables: jest.fn().mockReturnValue(''), + getUIConfig: jest.fn().mockReturnValue({ + getModelOptions: jest.fn().mockReturnValue([ + { value: 'sonnet', label: 'Sonnet' }, + { value: 'opus', label: 'Opus' }, + ]), + isAdaptiveReasoningModel: jest.fn().mockReturnValue(true), + getReasoningOptions: jest.fn().mockReturnValue([ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Med' }, + { value: 'high', label: 'High' }, + ]), + getDefaultReasoningValue: jest.fn().mockReturnValue('high'), + getContextWindowSize: jest.fn().mockReturnValue(200000), + isDefaultModel: jest.fn().mockReturnValue(true), + applyModelDefaults: jest.fn(), + normalizeModelVariant: jest.fn((model: string) => model), + }), + getCapabilities: jest.fn().mockReturnValue({ + providerId: 'claude', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: true, + supportsFork: true, + supportsProviderCommands: true, + reasoningControl: 'effort', + }), + }; +} + +describe('ExternalContextSelector', () => { + let parentEl: any; + let selector: ExternalContextSelector; + let callbacks: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + // By default, all paths are valid (exist on filesystem) + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); + parentEl = createMockEl(); + callbacks = createMockCallbacks(); + selector = new ExternalContextSelector(parentEl, callbacks); + }); + + describe('Persistent Paths Management', () => { + it('should initialize with empty persistent paths', () => { + expect(selector.getPersistentPaths()).toEqual([]); + }); + + it('should set persistent paths from settings', () => { + selector.setPersistentPaths(['/path/a', '/path/b']); + + expect(selector.getPersistentPaths()).toEqual(['/path/a', '/path/b']); + }); + + it('should merge persistent paths into external contexts when setting', () => { + selector.setPersistentPaths(['/path/a', '/path/b']); + + // After setting persistent paths, they should be in external contexts + expect(selector.getExternalContexts()).toContain('/path/a'); + expect(selector.getExternalContexts()).toContain('/path/b'); + }); + + it('should toggle persistence on - add path to persistent paths', () => { + const onPersistenceChange = jest.fn(); + selector.setOnPersistenceChange(onPersistenceChange); + + // First set some external context paths + selector.setExternalContexts(['/path/a']); + + // Toggle persistence on for path/a + selector.togglePersistence('/path/a'); + + expect(selector.getPersistentPaths()).toContain('/path/a'); + expect(onPersistenceChange).toHaveBeenCalledWith(['/path/a']); + }); + + it('should toggle persistence off - remove path from persistent paths', () => { + const onPersistenceChange = jest.fn(); + selector.setOnPersistenceChange(onPersistenceChange); + + // Set up with a persistent path + selector.setPersistentPaths(['/path/a']); + + // Toggle persistence off + selector.togglePersistence('/path/a'); + + expect(selector.getPersistentPaths()).not.toContain('/path/a'); + expect(onPersistenceChange).toHaveBeenCalledWith([]); + }); + + it('should handle multiple persistent paths', () => { + const onPersistenceChange = jest.fn(); + selector.setOnPersistenceChange(onPersistenceChange); + + selector.setPersistentPaths(['/path/a']); + selector.togglePersistence('/path/b'); + + expect(selector.getPersistentPaths()).toContain('/path/a'); + expect(selector.getPersistentPaths()).toContain('/path/b'); + expect(onPersistenceChange).toHaveBeenCalledWith( + expect.arrayContaining(['/path/a', '/path/b']) + ); + }); + }); + + describe('clearExternalContexts', () => { + it('should reset to persistent paths when called without parameter', () => { + selector.setPersistentPaths(['/persistent/path']); + selector.setExternalContexts(['/session/path', '/persistent/path']); + + selector.clearExternalContexts(); + + expect(selector.getExternalContexts()).toEqual(['/persistent/path']); + }); + + it('should use provided paths when called with parameter', () => { + selector.setPersistentPaths(['/old/path']); + + selector.clearExternalContexts(['/new/path/a', '/new/path/b']); + + expect(selector.getExternalContexts()).toEqual(['/new/path/a', '/new/path/b']); + expect(selector.getPersistentPaths()).toEqual(['/new/path/a', '/new/path/b']); + }); + + it('should update persistentPaths when called with parameter', () => { + selector.setPersistentPaths(['/old/path']); + + selector.clearExternalContexts(['/new/path']); + + // Local persistentPaths should be updated + expect(selector.getPersistentPaths()).toEqual(['/new/path']); + }); + }); + + describe('setExternalContexts', () => { + it('should set exact paths without merging persistent paths', () => { + selector.setPersistentPaths(['/persistent/path']); + + selector.setExternalContexts(['/session/path']); + + // Should only have the session path, not merged with persistent + expect(selector.getExternalContexts()).toEqual(['/session/path']); + }); + + it('should not modify persistent paths', () => { + selector.setPersistentPaths(['/persistent/path']); + + selector.setExternalContexts(['/session/path']); + + // Persistent paths should remain unchanged + expect(selector.getPersistentPaths()).toEqual(['/persistent/path']); + }); + + it('should handle empty array', () => { + selector.setPersistentPaths(['/persistent/path']); + selector.setExternalContexts(['/session/path']); + + selector.setExternalContexts([]); + + expect(selector.getExternalContexts()).toEqual([]); + expect(selector.getPersistentPaths()).toEqual(['/persistent/path']); + }); + }); + + describe('addExternalContext', () => { + it('should reject empty input', () => { + const onChange = jest.fn(); + selector.setOnChange(onChange); + + const result = selector.addExternalContext(''); + + expect(result).toEqual({ + success: false, + error: 'No path provided. Usage: /add-dir /absolute/path', + }); + expect(selector.getExternalContexts()).toEqual([]); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should reject whitespace-only input', () => { + const onChange = jest.fn(); + selector.setOnChange(onChange); + + const result = selector.addExternalContext(' '); + + expect(result).toEqual({ + success: false, + error: 'No path provided. Usage: /add-dir /absolute/path', + }); + expect(selector.getExternalContexts()).toEqual([]); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should reject relative paths', () => { + const onChange = jest.fn(); + selector.setOnChange(onChange); + + const result = selector.addExternalContext('relative/path'); + + expect(result).toEqual({ + success: false, + error: 'Path must be absolute. Usage: /add-dir /absolute/path', + }); + expect(selector.getExternalContexts()).toEqual([]); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('should add absolute paths and call onChange', () => { + const onChange = jest.fn(); + selector.setOnChange(onChange); + const absolutePath = path.resolve('external', 'ctx'); + + const result = selector.addExternalContext(absolutePath); + + expect(result).toEqual({ success: true, normalizedPath: absolutePath }); + expect(selector.getExternalContexts()).toEqual([absolutePath]); + expect(onChange).toHaveBeenCalledWith([absolutePath]); + }); + + it('should reject non-existent paths with specific error', () => { + (fs.statSync as jest.Mock).mockImplementation(() => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + }); + + const absolutePath = path.resolve('non', 'existent'); + const result = selector.addExternalContext(absolutePath); + + expect(result.success).toBe(false); + expect(result).toMatchObject({ error: expect.stringContaining('Path does not exist') }); + }); + + it('should reject paths with permission denied error', () => { + (fs.statSync as jest.Mock).mockImplementation(() => { + const error = new Error('EACCES') as NodeJS.ErrnoException; + error.code = 'EACCES'; + throw error; + }); + + const absolutePath = path.resolve('no', 'access'); + const result = selector.addExternalContext(absolutePath); + + expect(result.success).toBe(false); + expect(result).toMatchObject({ error: expect.stringContaining('Permission denied') }); + }); + + it('should reject paths that exist but are not directories', () => { + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => false }); + + const absolutePath = path.resolve('some', 'file.txt'); + const result = selector.addExternalContext(absolutePath); + + expect(result.success).toBe(false); + expect(result).toMatchObject({ error: expect.stringContaining('Path exists but is not a directory') }); + }); + + it('should accept double-quoted absolute paths', () => { + const absolutePath = path.resolve('external', 'dir with spaces'); + + const result = selector.addExternalContext(`"${absolutePath}"`); + + expect(result.success).toBe(true); + expect(selector.getExternalContexts()).toEqual([absolutePath]); + }); + + it('should accept single-quoted absolute paths', () => { + const absolutePath = path.resolve('external', 'dir with spaces'); + + const result = selector.addExternalContext(`'${absolutePath}'`); + + expect(result.success).toBe(true); + expect(selector.getExternalContexts()).toEqual([absolutePath]); + }); + + it('should expand home paths', () => { + const homeDir = os.homedir(); + + const result = selector.addExternalContext('~'); + + expect(result).toEqual({ success: true, normalizedPath: homeDir }); + expect(selector.getExternalContexts()).toEqual([homeDir]); + }); + + it('should reject duplicate paths', () => { + const absolutePath = path.resolve('external', 'ctx'); + + selector.addExternalContext(absolutePath); + const result = selector.addExternalContext(absolutePath); + + expect(result).toEqual({ + success: false, + error: 'This folder is already added as an external context.', + }); + expect(selector.getExternalContexts()).toEqual([absolutePath]); + }); + + it('should reject nested paths (child inside parent)', () => { + const parentPath = path.resolve('external'); + const childPath = path.join(parentPath, 'child'); + + selector.addExternalContext(parentPath); + const result = selector.addExternalContext(childPath); + + expect(result.success).toBe(false); + expect(result).toMatchObject({ error: expect.stringContaining('inside existing path') }); + expect(selector.getExternalContexts()).toEqual([parentPath]); + }); + + it('should reject parent paths that would contain existing child', () => { + const parentPath = path.resolve('external'); + const childPath = path.join(parentPath, 'child'); + + // Add child first, then try to add parent + selector.addExternalContext(childPath); + const result = selector.addExternalContext(parentPath); + + expect(result.success).toBe(false); + expect(result).toMatchObject({ error: expect.stringContaining('contains existing path') }); + expect(selector.getExternalContexts()).toEqual([childPath]); + }); + }); + + describe('Callbacks', () => { + it('should call onChange when paths are removed via removePath', () => { + const onChange = jest.fn(); + selector.setOnChange(onChange); + + selector.setExternalContexts(['/path/a', '/path/b']); + selector.removePath('/path/a'); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(['/path/b']); + }); + + it('should call onPersistenceChange when persistence is toggled', () => { + const onPersistenceChange = jest.fn(); + selector.setOnPersistenceChange(onPersistenceChange); + + selector.setExternalContexts(['/path/a']); + selector.togglePersistence('/path/a'); + + expect(onPersistenceChange).toHaveBeenCalledTimes(1); + expect(onPersistenceChange).toHaveBeenCalledWith(['/path/a']); + }); + }) + + describe('removePath', () => { + it('should remove path from external contexts', () => { + selector.setExternalContexts(['/path/a', '/path/b']); + + selector.removePath('/path/a'); + + expect(selector.getExternalContexts()).toEqual(['/path/b']); + }); + + it('should remove path from persistent paths if it was persistent', () => { + const onPersistenceChange = jest.fn(); + selector.setOnPersistenceChange(onPersistenceChange); + + selector.setPersistentPaths(['/path/a', '/path/b']); + + selector.removePath('/path/a'); + + expect(selector.getPersistentPaths()).toEqual(['/path/b']); + expect(selector.getExternalContexts()).toEqual(['/path/b']); + expect(onPersistenceChange).toHaveBeenCalledWith(['/path/b']); + }); + + it('should not call onPersistenceChange when removing non-persistent path', () => { + const onPersistenceChange = jest.fn(); + selector.setOnPersistenceChange(onPersistenceChange); + + selector.setPersistentPaths(['/path/a']); + selector.setExternalContexts(['/path/a', '/path/b']); + + // Clear mock calls from setPersistentPaths + onPersistenceChange.mockClear(); + + selector.removePath('/path/b'); + + expect(selector.getExternalContexts()).toEqual(['/path/a']); + expect(onPersistenceChange).not.toHaveBeenCalled(); + }); + + it('should call onChange callback when removing path', () => { + const onChange = jest.fn(); + selector.setOnChange(onChange); + + selector.setExternalContexts(['/path/a', '/path/b']); + selector.removePath('/path/a'); + + expect(onChange).toHaveBeenCalledWith(['/path/b']); + }); + }); + + describe('Edge Cases', () => { + it('should handle duplicate paths in setPersistentPaths', () => { + // All paths are valid for this test + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); + + selector.setPersistentPaths(['/path/a', '/path/a', '/path/b']); + + // Set uses deduplication + const paths = selector.getPersistentPaths(); + expect(paths.filter(p => p === '/path/a').length).toBe(1); + }); + + it('should handle toggling same path multiple times', () => { + // All paths are valid for this test + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); + + selector.setExternalContexts(['/path/a']); + + // Toggle on + selector.togglePersistence('/path/a'); + expect(selector.getPersistentPaths()).toContain('/path/a'); + + // Toggle off + selector.togglePersistence('/path/a'); + expect(selector.getPersistentPaths()).not.toContain('/path/a'); + + // Toggle on again + selector.togglePersistence('/path/a'); + expect(selector.getPersistentPaths()).toContain('/path/a'); + }); + + it('should preserve persistent paths across setExternalContexts calls', () => { + // All paths are valid for this test + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); + + selector.setPersistentPaths(['/persistent/path']); + + selector.setExternalContexts(['/session1']); + selector.setExternalContexts(['/session2']); + selector.setExternalContexts([]); + + // Persistent paths should remain unchanged + expect(selector.getPersistentPaths()).toEqual(['/persistent/path']); + }); + }); + + describe('shortenPath', () => { + it('should not shorten paths outside home directory', () => { + const homeDir = os.homedir(); + const outsidePath = path.join(path.parse(homeDir).root, 'tmp'); + + const result = (selector as any).shortenPath(outsidePath); + + expect(result).toBe(outsidePath); + }); + + it('should shorten paths inside home directory', () => { + const homeDir = os.homedir(); + const insidePath = path.join(homeDir, 'project'); + + const result = (selector as any).shortenPath(insidePath); + + const normalizedHome = homeDir.replace(/\\/g, '/'); + const normalizedInside = insidePath.replace(/\\/g, '/'); + const expected = '~' + normalizedInside.slice(normalizedHome.length); + expect(result).toBe(expected); + }); + }); + + describe('Path Validation', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should filter out invalid paths on setPersistentPaths (app load)', () => { + const onPersistenceChange = jest.fn(); + selector.setOnPersistenceChange(onPersistenceChange); + + // Mock: /valid/path exists, /invalid/path does not + (fs.statSync as jest.Mock).mockImplementation((p: string) => { + if (p === '/valid/path') { + return { isDirectory: () => true }; + } + throw new Error('ENOENT'); + }); + + selector.setPersistentPaths(['/valid/path', '/invalid/path']); + + // Should only have the valid path + expect(selector.getPersistentPaths()).toEqual(['/valid/path']); + expect(selector.getExternalContexts()).toEqual(['/valid/path']); + + // Should save the updated list since invalid paths were removed + expect(onPersistenceChange).toHaveBeenCalledWith(['/valid/path']); + }); + + it('should not call onPersistenceChange when all paths are valid', () => { + const onPersistenceChange = jest.fn(); + selector.setOnPersistenceChange(onPersistenceChange); + + (fs.statSync as jest.Mock).mockReturnValue({ isDirectory: () => true }); + + selector.setPersistentPaths(['/path/a', '/path/b']); + + // All paths valid, no need to save + expect(onPersistenceChange).not.toHaveBeenCalled(); + expect(selector.getPersistentPaths()).toEqual(['/path/a', '/path/b']); + }); + + it('should handle all paths being invalid', () => { + const onPersistenceChange = jest.fn(); + selector.setOnPersistenceChange(onPersistenceChange); + + (fs.statSync as jest.Mock).mockImplementation(() => { + throw new Error('ENOENT'); + }); + + selector.setPersistentPaths(['/invalid/a', '/invalid/b']); + + expect(selector.getPersistentPaths()).toEqual([]); + expect(selector.getExternalContexts()).toEqual([]); + expect(onPersistenceChange).toHaveBeenCalledWith([]); + }); + }); +}); diff --git a/tests/unit/features/chat/ui/FileContextManager.test.ts b/tests/unit/features/chat/ui/FileContextManager.test.ts new file mode 100644 index 0000000..050cff3 --- /dev/null +++ b/tests/unit/features/chat/ui/FileContextManager.test.ts @@ -0,0 +1,876 @@ +import { createMockEl, type MockElement } from '@test/helpers/mockElement'; +import { TFile } from 'obsidian'; + +import type { FileContextCallbacks } from '@/features/chat/ui/FileContext'; +import { FileContextManager } from '@/features/chat/ui/FileContext'; +import { VaultFolderCache } from '@/shared/mention/VaultMentionCache'; +import type { ExternalContextFile } from '@/utils/externalContextScanner'; + +jest.mock('obsidian', () => { + const actual = jest.requireActual('obsidian'); + return { + ...actual, + setIcon: jest.fn(), + Notice: jest.fn(), + }; +}); + +function createMockTFile(filePath: string): TFile { + const file = new (TFile as any)(filePath) as TFile; + (file as any).stat = { mtime: Date.now(), ctime: Date.now(), size: 0 }; + return file; +} + +let mockVaultPath = '/vault'; +jest.mock('@/utils/path', () => { + const actual = jest.requireActual('@/utils/path'); + return { + ...actual, + getVaultPath: jest.fn(() => mockVaultPath), + isPathWithinVault: jest.fn((candidatePath: string, vaultPath: string) => { + if (!candidatePath) return false; + if (!candidatePath.startsWith('/')) return true; + return candidatePath.startsWith(vaultPath); + }), + }; +}); + +const mockScanPaths = jest.fn(() => []); +jest.mock('@/utils/externalContextScanner', () => ({ + externalContextScanner: { + scanPaths: (paths: string[]) => mockScanPaths(paths), + }, +})); + + +function findByClass(root: MockElement, className: string): MockElement | undefined { + if (root.hasClass(className)) return root; + for (const child of root.children) { + const found = findByClass(child, className); + if (found) return found; + } + return undefined; +} + +function findAllByClass(root: MockElement, className: string): MockElement[] { + const results: MockElement[] = []; + const walk = (node: MockElement) => { + if (node.hasClass(className)) { + results.push(node); + } + node.children.forEach(walk); + }; + walk(root); + return results; +} + +function createMockApp(options: { + files?: string[]; + activeFilePath?: string | null; + fileCacheByPath?: Map; +} = {}) { + const { files = [], activeFilePath = null, fileCacheByPath = new Map() } = options; + const fileMap = new Map(); + files.forEach((filePath) => { + fileMap.set(filePath, createMockTFile(filePath)); + }); + + return { + vault: { + on: jest.fn(() => ({ id: 'event-ref' })), + offref: jest.fn(), + getAbstractFileByPath: jest.fn((filePath: string) => fileMap.get(filePath) || null), + getAllLoadedFiles: jest.fn(() => Array.from(fileMap.values())), + getFiles: jest.fn(() => Array.from(fileMap.values())), + }, + workspace: { + getActiveFile: jest.fn(() => { + if (!activeFilePath) return null; + return fileMap.get(activeFilePath) || createMockTFile(activeFilePath); + }), + getLeaf: jest.fn(() => ({ + openFile: jest.fn().mockResolvedValue(undefined), + })), + }, + metadataCache: { + getFileCache: jest.fn((file: TFile) => fileCacheByPath.get(file.path) || null), + }, + } as any; +} + +function createMockCallbacks(options: { + externalContexts?: string[]; + excludedTags?: string[]; +} = {}): FileContextCallbacks { + const { externalContexts = [], excludedTags = [] } = options; + return { + getExcludedTags: jest.fn(() => excludedTags), + getExternalContexts: jest.fn(() => externalContexts), + }; +} + +describe('FileContextManager', () => { + let containerEl: MockElement; + let inputEl: HTMLTextAreaElement; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + mockVaultPath = '/vault'; + mockScanPaths.mockReturnValue([]); + containerEl = createMockEl(); + inputEl = { + value: '', + selectionStart: 0, + selectionEnd: 0, + focus: jest.fn(), + } as unknown as HTMLTextAreaElement; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('tracks current note send state per session', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, + containerEl as any, + inputEl, + createMockCallbacks() + ); + + manager.setCurrentNote('notes/alpha.md'); + expect(manager.shouldSendCurrentNote()).toBe(true); + manager.markCurrentNoteSent(); + expect(manager.shouldSendCurrentNote()).toBe(false); + + manager.resetForLoadedConversation(true); + manager.setCurrentNote('notes/alpha.md'); + expect(manager.shouldSendCurrentNote()).toBe(false); + + manager.resetForLoadedConversation(false); + manager.setCurrentNote('notes/beta.md'); + expect(manager.shouldSendCurrentNote()).toBe(true); + + manager.destroy(); + }); + + it('should NOT resend current note when loading conversation with existing messages', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, + containerEl as any, + inputEl, + createMockCallbacks() + ); + + // When loading a conversation that already has messages, the current note + // should be marked as already sent to avoid re-sending context + manager.resetForLoadedConversation(true); + manager.setCurrentNote('notes/restored.md'); + expect(manager.shouldSendCurrentNote()).toBe(false); + + manager.destroy(); + }); + + it('should send current note when loading empty conversation', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, + containerEl as any, + inputEl, + createMockCallbacks() + ); + + // When loading a conversation with no messages, the current note + // should be sent with the first message + manager.resetForLoadedConversation(false); + manager.setCurrentNote('notes/new.md'); + expect(manager.shouldSendCurrentNote()).toBe(true); + + manager.destroy(); + }); + + it('renders current note chip and removes on click', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, + containerEl as any, + inputEl, + createMockCallbacks() + ); + + manager.setCurrentNote('notes/chip.md'); + + const chip = findByClass(containerEl, 'claudian-context-chip--note'); + expect(chip).toBeDefined(); + expect(containerEl.hasClass('has-content')).toBe(true); + + const removeEl = findByClass(containerEl, 'claudian-context-chip-remove'); + expect(removeEl).toBeDefined(); + + removeEl!.click(); + + expect(manager.getCurrentNotePath()).toBeNull(); + expect(containerEl.hasClass('has-content')).toBe(false); + + manager.destroy(); + }); + + it('auto-attaches active file unless excluded by tag', () => { + const fileCacheByPath = new Map([ + ['notes/private.md', { frontmatter: { tags: ['private'] } }], + ]); + const app = createMockApp({ + files: ['notes/private.md', 'notes/public.md'], + activeFilePath: 'notes/private.md', + fileCacheByPath, + }); + + const manager = new FileContextManager( + app, + containerEl as any, + inputEl, + createMockCallbacks({ excludedTags: ['private'] }) + ); + + manager.autoAttachActiveFile(); + expect(manager.getCurrentNotePath()).toBeNull(); + + app.workspace.getActiveFile = jest.fn(() => createMockTFile('notes/public.md')); + manager.autoAttachActiveFile(); + expect(manager.getCurrentNotePath()).toBe('notes/public.md'); + + manager.destroy(); + }); + + it('shows vault-relative path in @ dropdown and inserts full path on selection', () => { + const app = createMockApp({ + files: ['clipping/file.md'], + }); + const manager = new FileContextManager( + app, + containerEl as any, + inputEl, + createMockCallbacks() + ); + + inputEl.value = '@file'; + inputEl.selectionStart = 5; + inputEl.selectionEnd = 5; + manager.handleInputChange(); + jest.advanceTimersByTime(200); + + const pathEl = findByClass(containerEl, 'claudian-mention-path'); + expect(pathEl?.textContent).toBe('clipping/file.md'); + + manager.handleMentionKeydown({ key: 'Enter', preventDefault: jest.fn() } as any); + + // Now inserts full vault-relative path (WYSIWYG) + expect(inputEl.value).toBe('@clipping/file.md '); + const attached = manager.getAttachedFiles(); + expect(attached.has('clipping/file.md')).toBe(true); + + manager.destroy(); + }); + + it('wires getCachedVaultFolders through VaultFolderCache.getFolders', () => { + const folder = { name: 'src', path: 'src' } as any; + const getFoldersSpy = jest + .spyOn(VaultFolderCache.prototype, 'getFolders') + .mockReturnValue([folder]); + const app = createMockApp(); + const manager = new FileContextManager( + app, + containerEl as any, + inputEl, + createMockCallbacks() + ); + + inputEl.value = '@src'; + inputEl.selectionStart = 4; + inputEl.selectionEnd = 4; + manager.handleInputChange(); + jest.advanceTimersByTime(200); + + expect(getFoldersSpy).toHaveBeenCalled(); + const folderLabel = findByClass(containerEl, 'claudian-mention-name-folder'); + expect(folderLabel?.textContent).toBe('@src/'); + + manager.destroy(); + getFoldersSpy.mockRestore(); + }); + + it('filters context files and attaches absolute path', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, + containerEl as any, + inputEl, + createMockCallbacks({ externalContexts: ['/external'] }) + ); + + const contextFiles: ExternalContextFile[] = [ + { + path: '/external/src/app.md', + name: 'app.md', + relativePath: 'src/app.md', + contextRoot: '/external', + mtime: 1000, + }, + ]; + mockScanPaths.mockReturnValue(contextFiles); + + inputEl.value = '@external/app'; + inputEl.selectionStart = 13; + inputEl.selectionEnd = 13; + manager.handleInputChange(); + jest.advanceTimersByTime(200); + + const nameEls = findAllByClass(containerEl, 'claudian-mention-name-context'); + expect(nameEls[0]?.textContent).toBe('src/app.md'); + + manager.handleMentionKeydown({ key: 'Enter', preventDefault: jest.fn() } as any); + + // Display shows friendly name, but state stores mapping to absolute path + expect(inputEl.value).toBe('@external/src/app.md '); + const attached = manager.getAttachedFiles(); + expect(attached.has('/external/src/app.md')).toBe(true); + // Check transformation works + const transformed = manager.transformContextMentions('@external/src/app.md'); + expect(transformed).toBe('/external/src/app.md'); + + manager.destroy(); + }); + + it('transforms pasted external context mention to absolute path without dropdown selection', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, + containerEl as any, + inputEl, + createMockCallbacks({ externalContexts: ['/external'] }) + ); + + const contextFiles: ExternalContextFile[] = [ + { + path: '/external/src/app.md', + name: 'app.md', + relativePath: 'src/app.md', + contextRoot: '/external', + mtime: 1000, + }, + ]; + mockScanPaths.mockReturnValue(contextFiles); + + const transformed = manager.transformContextMentions('Please review @external/src/app.md before merging.'); + expect(transformed).toBe('Please review /external/src/app.md before merging.'); + + manager.destroy(); + }); + + it('transforms pasted external context mention with spaces in path', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, + containerEl as any, + inputEl, + createMockCallbacks({ externalContexts: ['/external'] }) + ); + + const contextFiles: ExternalContextFile[] = [ + { + path: '/external/src/my file.md', + name: 'my file.md', + relativePath: 'src/my file.md', + contextRoot: '/external', + mtime: 1000, + }, + ]; + mockScanPaths.mockReturnValue(contextFiles); + + const transformed = manager.transformContextMentions('Please review @external/src/my file.md before merging.'); + expect(transformed).toBe('Please review /external/src/my file.md before merging.'); + + manager.destroy(); + }); + + it('keeps trailing punctuation when transforming pasted external context mention', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, + containerEl as any, + inputEl, + createMockCallbacks({ externalContexts: ['/external'] }) + ); + + const contextFiles: ExternalContextFile[] = [ + { + path: '/external/src/app.md', + name: 'app.md', + relativePath: 'src/app.md', + contextRoot: '/external', + mtime: 1000, + }, + ]; + mockScanPaths.mockReturnValue(contextFiles); + + const transformed = manager.transformContextMentions('Check @external/src/app.md, then continue.'); + expect(transformed).toBe('Check /external/src/app.md, then continue.'); + + manager.destroy(); + }); + + it('resolves pasted mention using disambiguated external context display name', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, + containerEl as any, + inputEl, + createMockCallbacks({ + externalContexts: ['/work/a/external', '/work/b/external'], + }) + ); + + mockScanPaths.mockImplementation((paths: string[]) => { + const contextRoot = paths[0]; + if (contextRoot === '/work/a/external') { + return [ + { + path: '/work/a/external/src/app.md', + name: 'app.md', + relativePath: 'src/app.md', + contextRoot: '/work/a/external', + mtime: 1000, + }, + ]; + } + + if (contextRoot === '/work/b/external') { + return [ + { + path: '/work/b/external/src/app.md', + name: 'app.md', + relativePath: 'src/app.md', + contextRoot: '/work/b/external', + mtime: 1000, + }, + ]; + } + + return []; + }); + + const transformed = manager.transformContextMentions('Use @a/external/src/app.md from workspace A'); + expect(transformed).toBe('Use /work/a/external/src/app.md from workspace A'); + + manager.destroy(); + }); + + describe('session lifecycle', () => { + it('should report session not started initially', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + expect(manager.isSessionStarted()).toBe(false); + manager.destroy(); + }); + + it('should report session started after startSession', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + manager.startSession(); + expect(manager.isSessionStarted()).toBe(true); + manager.destroy(); + }); + + it('should reset state for new conversation', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + manager.setCurrentNote('notes/test.md'); + manager.startSession(); + + manager.resetForNewConversation(); + expect(manager.getCurrentNotePath()).toBeNull(); + expect(manager.isSessionStarted()).toBe(false); + manager.destroy(); + }); + }); + + describe('handleFileOpen', () => { + it('should update current note when session not started', () => { + const app = createMockApp({ files: ['notes/new.md'] }); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + + const file = createMockTFile('notes/new.md'); + manager.handleFileOpen(file); + expect(manager.getCurrentNotePath()).toBe('notes/new.md'); + manager.destroy(); + }); + + it('should clear attachments when opening a new file before session starts', () => { + const app = createMockApp({ files: ['notes/a.md', 'notes/b.md'] }); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + + manager.setCurrentNote('notes/a.md'); + const fileB = createMockTFile('notes/b.md'); + manager.handleFileOpen(fileB); + expect(manager.getCurrentNotePath()).toBe('notes/b.md'); + manager.destroy(); + }); + + it('should not update current note when session is started', () => { + const app = createMockApp({ files: ['notes/a.md'] }); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + + manager.setCurrentNote('notes/a.md'); + manager.startSession(); + + const fileB = createMockTFile('notes/b.md'); + manager.handleFileOpen(fileB); + // Should NOT update because session is started + expect(manager.getCurrentNotePath()).toBe('notes/a.md'); + manager.destroy(); + }); + + it('should not attach file with excluded tag', () => { + const fileCacheByPath = new Map([ + ['notes/secret.md', { frontmatter: { tags: ['private'] } }], + ]); + const app = createMockApp({ files: ['notes/secret.md'], fileCacheByPath }); + const manager = new FileContextManager( + app, containerEl as any, inputEl, + createMockCallbacks({ excludedTags: ['private'] }) + ); + + const file = createMockTFile('notes/secret.md'); + manager.handleFileOpen(file); + expect(manager.getCurrentNotePath()).toBeNull(); + manager.destroy(); + }); + }); + + describe('file rename handling', () => { + it('should update current note path when file is renamed', () => { + const app = createMockApp({ files: ['notes/old.md', 'notes/new.md'] }); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + + manager.setCurrentNote('notes/old.md'); + expect(manager.getCurrentNotePath()).toBe('notes/old.md'); + + // Simulate vault rename event + const renameHandler = (app.vault.on as jest.Mock).mock.calls + .find((c: any[]) => c[0] === 'rename')?.[1]; + expect(renameHandler).toBeDefined(); + + renameHandler(createMockTFile('notes/new.md'), 'notes/old.md'); + expect(manager.getCurrentNotePath()).toBe('notes/new.md'); + manager.destroy(); + }); + + it('should update attached files when renamed', () => { + const app = createMockApp({ files: ['notes/old.md'] }); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + + manager.setCurrentNote('notes/old.md'); + + const renameHandler = (app.vault.on as jest.Mock).mock.calls + .find((c: any[]) => c[0] === 'rename')?.[1]; + + renameHandler(createMockTFile('notes/new.md'), 'notes/old.md'); + expect(manager.getAttachedFiles().has('notes/new.md')).toBe(true); + expect(manager.getAttachedFiles().has('notes/old.md')).toBe(false); + manager.destroy(); + }); + + it('should not update if renamed file is not attached', () => { + const app = createMockApp({ files: ['notes/a.md', 'notes/unrelated.md'] }); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + + manager.setCurrentNote('notes/a.md'); + + const renameHandler = (app.vault.on as jest.Mock).mock.calls + .find((c: any[]) => c[0] === 'rename')?.[1]; + + renameHandler(createMockTFile('notes/renamed.md'), 'notes/unrelated.md'); + // Current note should remain unchanged + expect(manager.getCurrentNotePath()).toBe('notes/a.md'); + manager.destroy(); + }); + }); + + describe('file delete handling', () => { + it('should clear current note when file is deleted', () => { + const app = createMockApp({ files: ['notes/doomed.md'] }); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + + manager.setCurrentNote('notes/doomed.md'); + + const deleteHandler = (app.vault.on as jest.Mock).mock.calls + .find((c: any[]) => c[0] === 'delete')?.[1]; + expect(deleteHandler).toBeDefined(); + + deleteHandler(createMockTFile('notes/doomed.md')); + expect(manager.getCurrentNotePath()).toBeNull(); + manager.destroy(); + }); + + it('should remove deleted file from attached files', () => { + const app = createMockApp({ files: ['notes/a.md'] }); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + + manager.setCurrentNote('notes/a.md'); + expect(manager.getAttachedFiles().has('notes/a.md')).toBe(true); + + const deleteHandler = (app.vault.on as jest.Mock).mock.calls + .find((c: any[]) => c[0] === 'delete')?.[1]; + + deleteHandler(createMockTFile('notes/a.md')); + expect(manager.getAttachedFiles().has('notes/a.md')).toBe(false); + manager.destroy(); + }); + + it('should not update if deleted file is not attached', () => { + const app = createMockApp({ files: ['notes/a.md', 'notes/other.md'] }); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + + manager.setCurrentNote('notes/a.md'); + + const deleteHandler = (app.vault.on as jest.Mock).mock.calls + .find((c: any[]) => c[0] === 'delete')?.[1]; + + deleteHandler(createMockTFile('notes/other.md')); + expect(manager.getCurrentNotePath()).toBe('notes/a.md'); + manager.destroy(); + }); + }); + + describe('hasExcludedTag edge cases', () => { + it('should exclude file with inline tags (not just frontmatter)', () => { + const fileCacheByPath = new Map([ + ['notes/tagged.md', { + tags: [{ tag: '#system', position: { start: { line: 5, col: 0 }, end: { line: 5, col: 7 } } }], + }], + ]); + const app = createMockApp({ + files: ['notes/tagged.md'], + activeFilePath: 'notes/tagged.md', + fileCacheByPath, + }); + + const manager = new FileContextManager( + app, containerEl as any, inputEl, + createMockCallbacks({ excludedTags: ['system'] }) + ); + + manager.autoAttachActiveFile(); + expect(manager.getCurrentNotePath()).toBeNull(); + manager.destroy(); + }); + + it('should exclude file with string frontmatter tag (not array)', () => { + const fileCacheByPath = new Map([ + ['notes/single-tag.md', { frontmatter: { tags: 'private' } }], + ]); + const app = createMockApp({ + files: ['notes/single-tag.md'], + activeFilePath: 'notes/single-tag.md', + fileCacheByPath, + }); + + const manager = new FileContextManager( + app, containerEl as any, inputEl, + createMockCallbacks({ excludedTags: ['private'] }) + ); + + manager.autoAttachActiveFile(); + expect(manager.getCurrentNotePath()).toBeNull(); + manager.destroy(); + }); + + it('should handle tags with # prefix in cache', () => { + const fileCacheByPath = new Map([ + ['notes/hash-tag.md', { frontmatter: { tags: ['#draft'] } }], + ]); + const app = createMockApp({ + files: ['notes/hash-tag.md'], + activeFilePath: 'notes/hash-tag.md', + fileCacheByPath, + }); + + const manager = new FileContextManager( + app, containerEl as any, inputEl, + createMockCallbacks({ excludedTags: ['draft'] }) + ); + + manager.autoAttachActiveFile(); + expect(manager.getCurrentNotePath()).toBeNull(); + manager.destroy(); + }); + }); + + describe('cache dirty marking', () => { + it('should not throw when marking file cache dirty', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + expect(() => manager.markFileCacheDirty()).not.toThrow(); + manager.destroy(); + }); + + it('should not throw when marking folder cache dirty', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + expect(() => manager.markFolderCacheDirty()).not.toThrow(); + manager.destroy(); + }); + }); + + describe('MCP and agent support', () => { + it('should expose getMentionedMcpServers', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + const servers = manager.getMentionedMcpServers(); + expect(servers).toBeInstanceOf(Set); + expect(servers.size).toBe(0); + manager.destroy(); + }); + + it('should clear MCP mentions', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + // Should not throw + manager.clearMcpMentions(); + expect(manager.getMentionedMcpServers().size).toBe(0); + manager.destroy(); + }); + + it('should set onMcpMentionChange callback without error', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + const callback = jest.fn(); + expect(() => manager.setOnMcpMentionChange(callback)).not.toThrow(); + manager.destroy(); + }); + + it('should setMcpManager without error', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + expect(() => manager.setMcpManager(null)).not.toThrow(); + manager.destroy(); + }); + + it('should setAgentService without error', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + expect(() => manager.setAgentService(null)).not.toThrow(); + manager.destroy(); + }); + + it('should preScanExternalContexts without error', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + expect(() => manager.preScanExternalContexts()).not.toThrow(); + manager.destroy(); + }); + }); + + describe('mention dropdown delegation', () => { + it('should report isMentionDropdownVisible as false initially', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + expect(manager.isMentionDropdownVisible()).toBe(false); + manager.destroy(); + }); + + it('should hideMentionDropdown without error', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + expect(() => manager.hideMentionDropdown()).not.toThrow(); + manager.destroy(); + }); + + it('should containsElement return false for unrelated node', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + const unrelatedNode = createMockEl() as unknown as Node; + expect(manager.containsElement(unrelatedNode)).toBe(false); + manager.destroy(); + }); + }); + + describe('destroy', () => { + it('should clean up event listeners', () => { + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + + manager.destroy(); + expect(app.vault.offref).toHaveBeenCalledTimes(2); + }); + }); + + describe('onOpenFile callback', () => { + it('should show Notice when file not found in vault', async () => { + const { Notice: NoticeMock } = jest.requireMock('obsidian'); + const app = createMockApp(); + const manager = new FileContextManager( + app, containerEl as any, inputEl, createMockCallbacks() + ); + + const chipsView = (manager as any).chipsView; + const openCallback = chipsView.callbacks.onOpenFile; + expect(openCallback).toBeDefined(); + + await openCallback('notes/missing.md'); + expect(NoticeMock).toHaveBeenCalledWith(expect.stringContaining('Could not open file')); + manager.destroy(); + }); + }); +}); diff --git a/tests/unit/features/chat/ui/ImageContext.test.ts b/tests/unit/features/chat/ui/ImageContext.test.ts new file mode 100644 index 0000000..5ed1cc7 --- /dev/null +++ b/tests/unit/features/chat/ui/ImageContext.test.ts @@ -0,0 +1,765 @@ +import { createMockEl } from '@test/helpers/mockElement'; +import { Notice } from 'obsidian'; + +import type { ImageAttachment } from '@/core/types'; +import { ImageContextManager } from '@/features/chat/ui/ImageContext'; + +jest.mock('obsidian', () => ({ + Notice: jest.fn(), +})); + +// Mock document.createElementNS for SVG elements created in setupDragAndDrop +const mockSvgElement = () => { + const el = createMockEl('svg'); + el.appendChild = jest.fn(); + return el; +}; + +beforeAll(() => { + if (typeof globalThis.document === 'undefined') { + (globalThis as any).document = {}; + } + (globalThis.document as any).createElementNS = jest.fn(() => mockSvgElement()); +}); + +function createMockCallbacks() { + return { + onImagesChanged: jest.fn(), + }; +} + +function createContainerWithInputWrapper(): { container: any; inputWrapper: any } { + const container = createMockEl(); + const inputWrapper = container.createDiv({ cls: 'claudian-input-wrapper' }); + return { container, inputWrapper }; +} + +function createMockTextArea(): any { + const el = createMockEl('textarea'); + el.value = ''; + return el; +} + +function createImageAttachment(overrides: Partial = {}): ImageAttachment { + return { + id: 'img-test-1', + name: 'test.png', + mediaType: 'image/png', + data: 'dGVzdA==', + size: 1024, + source: 'paste', + ...overrides, + }; +} + +describe('ImageContextManager', () => { + let container: any; + let inputEl: any; + let callbacks: ReturnType; + let manager: ImageContextManager; + + beforeEach(() => { + jest.clearAllMocks(); + const { container: c } = createContainerWithInputWrapper(); + container = c; + inputEl = createMockTextArea(); + callbacks = createMockCallbacks(); + manager = new ImageContextManager(container, inputEl, callbacks); + }); + + describe('initial state', () => { + it('should start with no images', () => { + expect(manager.hasImages()).toBe(false); + expect(manager.getAttachedImages()).toEqual([]); + }); + }); + + describe('getAttachedImages', () => { + it('should return empty array when no images attached', () => { + expect(manager.getAttachedImages()).toEqual([]); + }); + + it('should return all attached images after setImages', () => { + const images = [ + createImageAttachment({ id: 'img-1', name: 'a.png' }), + createImageAttachment({ id: 'img-2', name: 'b.jpg' }), + ]; + manager.setImages(images); + + const result = manager.getAttachedImages(); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('img-1'); + expect(result[1].id).toBe('img-2'); + }); + }); + + describe('hasImages', () => { + it('should return false when no images', () => { + expect(manager.hasImages()).toBe(false); + }); + + it('should return true after setting images', () => { + manager.setImages([createImageAttachment()]); + expect(manager.hasImages()).toBe(true); + }); + + it('should return false after clearing images', () => { + manager.setImages([createImageAttachment()]); + manager.clearImages(); + expect(manager.hasImages()).toBe(false); + }); + }); + + describe('clearImages', () => { + it('should remove all images', () => { + manager.setImages([ + createImageAttachment({ id: 'img-1' }), + createImageAttachment({ id: 'img-2' }), + ]); + expect(manager.hasImages()).toBe(true); + + manager.clearImages(); + expect(manager.hasImages()).toBe(false); + expect(manager.getAttachedImages()).toEqual([]); + }); + + it('should invoke onImagesChanged callback', () => { + manager.setImages([createImageAttachment()]); + callbacks.onImagesChanged.mockClear(); + + manager.clearImages(); + expect(callbacks.onImagesChanged).toHaveBeenCalledTimes(1); + }); + }); + + describe('setImages', () => { + it('should replace existing images', () => { + manager.setImages([createImageAttachment({ id: 'old' })]); + + const newImages = [ + createImageAttachment({ id: 'new-1', name: 'new1.png' }), + createImageAttachment({ id: 'new-2', name: 'new2.jpg' }), + ]; + manager.setImages(newImages); + + const result = manager.getAttachedImages(); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('new-1'); + expect(result[1].id).toBe('new-2'); + }); + + it('should invoke onImagesChanged callback', () => { + manager.setImages([createImageAttachment()]); + expect(callbacks.onImagesChanged).toHaveBeenCalledTimes(1); + }); + + it('should handle empty array', () => { + manager.setImages([createImageAttachment()]); + manager.setImages([]); + + expect(manager.hasImages()).toBe(false); + expect(manager.getAttachedImages()).toEqual([]); + }); + + it('should deduplicate by id (last wins)', () => { + const images = [ + createImageAttachment({ id: 'same', name: 'first.png' }), + createImageAttachment({ id: 'same', name: 'second.png' }), + ]; + manager.setImages(images); + + const result = manager.getAttachedImages(); + expect(result).toHaveLength(1); + expect(result[0].name).toBe('second.png'); + }); + }); + + describe('constructor with previewContainerEl', () => { + it('should use previewContainerEl when provided', () => { + const previewContainer = createMockEl(); + const { container: c } = createContainerWithInputWrapper(); + const input = createMockTextArea(); + const cb = createMockCallbacks(); + + const mgr = new ImageContextManager(c, input, cb, previewContainer); + expect(mgr).toBeDefined(); + const trayEl = previewContainer.querySelector('.claudian-context-row'); + expect(trayEl).not.toBeNull(); + }); + + it('should preserve existing preview container content', () => { + const previewContainer = createMockEl(); + const existingContent = previewContainer.createDiv({ cls: 'existing-preview-content' }); + + const { container: c } = createContainerWithInputWrapper(); + const input = createMockTextArea(); + const cb = createMockCallbacks(); + + new ImageContextManager(c, input, cb, previewContainer); + expect(previewContainer.children).toContain(existingContent); + expect(previewContainer.querySelector('.claudian-context-row')).not.toBeNull(); + }); + }); +}); + +// Test private helper methods via their observable effects. +// We access privates through any cast, matching the project's pattern. +describe('ImageContextManager - Private Helpers', () => { + let manager: any; + + beforeEach(() => { + jest.clearAllMocks(); + const { container } = createContainerWithInputWrapper(); + const inputEl = createMockTextArea(); + const callbacks = createMockCallbacks(); + manager = new ImageContextManager(container, inputEl, callbacks); + }); + + describe('formatSize', () => { + it('should format bytes', () => { + expect(manager['formatSize'](500)).toBe('500 B'); + }); + + it('should format kilobytes', () => { + expect(manager['formatSize'](2048)).toBe('2.0 KB'); + }); + + it('should format megabytes', () => { + expect(manager['formatSize'](5 * 1024 * 1024)).toBe('5.0 MB'); + }); + + it('should format fractional KB', () => { + expect(manager['formatSize'](1536)).toBe('1.5 KB'); + }); + + it('should format 0 bytes', () => { + expect(manager['formatSize'](0)).toBe('0 B'); + }); + }); + + describe('getMediaType', () => { + it('should return correct media type for .jpg', () => { + expect(manager['getMediaType']('photo.jpg')).toBe('image/jpeg'); + }); + + it('should return correct media type for .jpeg', () => { + expect(manager['getMediaType']('photo.jpeg')).toBe('image/jpeg'); + }); + + it('should return correct media type for .png', () => { + expect(manager['getMediaType']('image.png')).toBe('image/png'); + }); + + it('should return correct media type for .gif', () => { + expect(manager['getMediaType']('animation.gif')).toBe('image/gif'); + }); + + it('should return correct media type for .webp', () => { + expect(manager['getMediaType']('photo.webp')).toBe('image/webp'); + }); + + it('should return null for unsupported extension', () => { + expect(manager['getMediaType']('document.pdf')).toBeNull(); + }); + + it('should return null for no extension', () => { + expect(manager['getMediaType']('noextension')).toBeNull(); + }); + + it('should handle uppercase extensions', () => { + expect(manager['getMediaType']('PHOTO.JPG')).toBe('image/jpeg'); + }); + + it('should handle mixed case extensions', () => { + expect(manager['getMediaType']('image.Png')).toBe('image/png'); + }); + }); + + describe('isImageFile', () => { + it('should return true for valid image file', () => { + const file = { type: 'image/png', name: 'test.png' } as File; + expect(manager['isImageFile'](file)).toBe(true); + }); + + it('should return false for non-image file', () => { + const file = { type: 'application/pdf', name: 'doc.pdf' } as File; + expect(manager['isImageFile'](file)).toBe(false); + }); + + it('should return false for image type but unsupported extension', () => { + const file = { type: 'image/bmp', name: 'test.bmp' } as File; + expect(manager['isImageFile'](file)).toBe(false); + }); + }); + + describe('generateId', () => { + it('should generate unique IDs', () => { + const id1 = manager['generateId'](); + const id2 = manager['generateId'](); + expect(id1).not.toBe(id2); + }); + + it('should start with img- prefix', () => { + const id = manager['generateId'](); + expect(id.startsWith('img-')).toBe(true); + }); + }); + + describe('notifyImageError', () => { + it('should create a Notice with the message', () => { + manager['notifyImageError']('Test error'); + expect(Notice).toHaveBeenCalledWith('Test error'); + }); + + it('should append file not found for ENOENT error', () => { + const error = new Error('ENOENT: no such file or directory'); + manager['notifyImageError']('Failed to load image.', error); + expect(Notice).toHaveBeenCalledWith('Failed to load image. (File not found)'); + }); + + it('should append permission denied for EACCES error', () => { + const error = new Error('EACCES: permission denied'); + manager['notifyImageError']('Failed to load image.', error); + expect(Notice).toHaveBeenCalledWith('Failed to load image. (Permission denied)'); + }); + + it('should use original message for non-Error objects', () => { + manager['notifyImageError']('Test error', 'not an error object'); + expect(Notice).toHaveBeenCalledWith('Test error'); + }); + + it('should use original message for errors without recognized patterns', () => { + const error = new Error('Some other error'); + manager['notifyImageError']('Test error', error); + expect(Notice).toHaveBeenCalledWith('Test error'); + }); + }); + + describe('addImageFromFile', () => { + it('should reject files exceeding size limit', async () => { + const file = { + name: 'huge.png', + type: 'image/png', + size: 6 * 1024 * 1024, // 6MB > 5MB limit + arrayBuffer: jest.fn(), + } as unknown as File; + + const result = await manager['addImageFromFile'](file, 'paste'); + expect(result).toBe(false); + expect(Notice).toHaveBeenCalledWith(expect.stringContaining('limit')); + }); + + it('should reject files with unsupported media type', async () => { + const file = { + name: 'test.bmp', + type: '', + size: 1024, + arrayBuffer: jest.fn(), + } as unknown as File; + + const result = await manager['addImageFromFile'](file, 'drop'); + expect(result).toBe(false); + expect(Notice).toHaveBeenCalledWith('Unsupported image type.'); + }); + + it('should add valid image file and invoke callback', async () => { + const mockBuffer = new ArrayBuffer(4); + const file = { + name: 'test.png', + type: 'image/png', + size: 1024, + arrayBuffer: jest.fn().mockResolvedValue(mockBuffer), + } as unknown as File; + + const callbacks = createMockCallbacks(); + const { container } = createContainerWithInputWrapper(); + const inputEl = createMockTextArea(); + const mgr: any = new ImageContextManager(container, inputEl, callbacks); + + const result = await mgr['addImageFromFile'](file, 'paste'); + expect(result).toBe(true); + expect(mgr.hasImages()).toBe(true); + expect(callbacks.onImagesChanged).toHaveBeenCalled(); + + const images = mgr.getAttachedImages(); + expect(images).toHaveLength(1); + expect(images[0].name).toBe('test.png'); + expect(images[0].mediaType).toBe('image/png'); + expect(images[0].size).toBe(1024); + expect(images[0].source).toBe('paste'); + }); + + it('should handle arrayBuffer failure gracefully', async () => { + const file = { + name: 'test.png', + type: 'image/png', + size: 1024, + arrayBuffer: jest.fn().mockRejectedValue(new Error('Read failed')), + } as unknown as File; + + const result = await manager['addImageFromFile'](file, 'drop'); + expect(result).toBe(false); + expect(Notice).toHaveBeenCalledWith('Failed to attach image.'); + }); + + it('should generate default name when file has no name', async () => { + const mockBuffer = new ArrayBuffer(4); + const file = { + name: '', + type: 'image/png', + size: 512, + arrayBuffer: jest.fn().mockResolvedValue(mockBuffer), + } as unknown as File; + + const callbacks = createMockCallbacks(); + const { container } = createContainerWithInputWrapper(); + const inputEl = createMockTextArea(); + const mgr: any = new ImageContextManager(container, inputEl, callbacks); + + await mgr['addImageFromFile'](file, 'paste'); + const images = mgr.getAttachedImages(); + expect(images[0].name).toMatch(/^image-\d+\.png$/); + }); + + it('should use file.type as fallback media type when getMediaType returns null', async () => { + const mockBuffer = new ArrayBuffer(4); + // File with .svg extension (not in IMAGE_EXTENSIONS), but valid image/* type + const file = { + name: 'icon.svg', + type: 'image/svg+xml', + size: 512, + arrayBuffer: jest.fn().mockResolvedValue(mockBuffer), + } as unknown as File; + + // The getMediaType for .svg returns null, so file.type is used as fallback + const callbacks = createMockCallbacks(); + const { container } = createContainerWithInputWrapper(); + const inputEl = createMockTextArea(); + const mgr: any = new ImageContextManager(container, inputEl, callbacks); + + const result = await mgr['addImageFromFile'](file, 'paste'); + expect(result).toBe(true); + + const images = mgr.getAttachedImages(); + expect(images[0].mediaType).toBe('image/svg+xml'); + }); + }); + + describe('Drag and Drop handlers', () => { + it('handleDragEnter should show overlay when dragging files', () => { + const event = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + dataTransfer: { types: ['Files'] }, + }; + + manager['handleDragEnter'](event as any); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + expect(manager['dropOverlay']?.hasClass('visible')).toBe(true); + }); + + it('handleDragEnter should not show overlay when not dragging files', () => { + const event = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + dataTransfer: { types: ['text/plain'] }, + }; + + manager['handleDragEnter'](event as any); + + expect(manager['dropOverlay']?.hasClass('visible')).toBeFalsy(); + }); + + it('handleDragOver should prevent default', () => { + const event = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + }; + + manager['handleDragOver'](event as any); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('handleDragLeave should hide overlay when cursor leaves input wrapper', () => { + // Show overlay first + manager['dropOverlay']?.addClass('visible'); + + const event = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + clientX: -1, // Outside bounds + clientY: -1, + }; + + manager['handleDragLeave'](event as any); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(manager['dropOverlay']?.hasClass('visible')).toBe(false); + }); + + it('handleDrop should hide overlay and process image files', async () => { + manager['dropOverlay']?.addClass('visible'); + const addImageSpy = jest.spyOn(manager as any, 'addImageFromFile').mockResolvedValue(true); + + const mockFile = { type: 'image/png', name: 'test.png', size: 1024 }; + const event = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + dataTransfer: { files: { length: 1, 0: mockFile, [Symbol.iterator]: function* () { yield mockFile; } } }, + }; + + await manager['handleDrop'](event as any); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(manager['dropOverlay']?.hasClass('visible')).toBe(false); + expect(addImageSpy).toHaveBeenCalledWith(mockFile, 'drop'); + + addImageSpy.mockRestore(); + }); + + it('handleDrop should skip non-image files', async () => { + const addImageSpy = jest.spyOn(manager as any, 'addImageFromFile').mockResolvedValue(true); + jest.spyOn(manager as any, 'isImageFile').mockReturnValue(false); + + const mockFile = { type: 'application/pdf', name: 'doc.pdf', size: 1024 }; + const event = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + dataTransfer: { files: { length: 1, 0: mockFile } }, + }; + + await manager['handleDrop'](event as any); + + expect(addImageSpy).not.toHaveBeenCalled(); + addImageSpy.mockRestore(); + }); + + it('handleDrop should handle no files gracefully', async () => { + const event = { + preventDefault: jest.fn(), + stopPropagation: jest.fn(), + dataTransfer: { files: undefined }, + }; + + await manager['handleDrop'](event as any); + // Should not throw and still call preventDefault + expect(event.preventDefault).toHaveBeenCalled(); + }); + }); + + describe('Paste handler', () => { + it('setupPasteHandler should register paste event on inputEl', () => { + const input = manager['inputEl']; + expect(input.getEventListenerCount('paste')).toBe(1); + }); + + it('paste handler should process image items', async () => { + const addImageSpy = jest.spyOn(manager as any, 'addImageFromFile').mockResolvedValue(true); + const mockFile = { name: 'pasted.png', type: 'image/png', size: 1024 }; + const pasteEvent = { + type: 'paste', + preventDefault: jest.fn(), + clipboardData: { + items: { + length: 1, + 0: { + type: 'image/png', + getAsFile: () => mockFile, + }, + }, + }, + }; + + manager['inputEl'].dispatchEvent(pasteEvent); + // Wait for async paste handler + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(pasteEvent.preventDefault).toHaveBeenCalled(); + expect(addImageSpy).toHaveBeenCalledWith(mockFile, 'paste'); + addImageSpy.mockRestore(); + }); + + it('paste handler should ignore non-image items', async () => { + const addImageSpy = jest.spyOn(manager as any, 'addImageFromFile').mockResolvedValue(true); + const pasteEvent = { + type: 'paste', + preventDefault: jest.fn(), + clipboardData: { + items: { + length: 1, + 0: { + type: 'text/plain', + getAsFile: () => null, + }, + }, + }, + }; + + manager['inputEl'].dispatchEvent(pasteEvent); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(pasteEvent.preventDefault).not.toHaveBeenCalled(); + expect(addImageSpy).not.toHaveBeenCalled(); + addImageSpy.mockRestore(); + }); + + it('paste handler should handle null clipboardData', async () => { + const addImageSpy = jest.spyOn(manager as any, 'addImageFromFile').mockResolvedValue(true); + const pasteEvent = { + type: 'paste', + preventDefault: jest.fn(), + clipboardData: null, + }; + + manager['inputEl'].dispatchEvent(pasteEvent); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(addImageSpy).not.toHaveBeenCalled(); + addImageSpy.mockRestore(); + }); + }); + + describe('Image context rendering', () => { + it('updateImagePreview should hide preview when no images', () => { + manager['updateImagePreview'](); + expect(manager['contextTray']['containerEl'].hasClass('has-content')).toBe(false); + }); + + it('updateImagePreview should show preview when images exist', () => { + manager.setImages([createImageAttachment()]); + expect(manager['contextTray']['containerEl'].hasClass('has-content')).toBe(true); + }); + + it('renders a compact image pill without a thumbnail preview', () => { + manager.setImages([createImageAttachment({ id: 'img-1', name: 'photo.png', size: 2048 })]); + + const trayEl = manager['contextTray']['containerEl']; + const chipEl = trayEl.querySelector('.claudian-context-chip--image'); + expect(chipEl).not.toBeNull(); + + const thumbEl = chipEl.querySelector('.claudian-context-chip-thumbnail'); + expect(thumbEl).toBeNull(); + + const labelEl = chipEl.querySelector('.claudian-context-chip-label'); + expect(labelEl?.textContent).toBe('Image'); + + const removeEl = chipEl.querySelector('.claudian-context-chip-remove'); + expect(removeEl).not.toBeNull(); + }); + + it('numbers image pills in attachment order when more than one image is attached', () => { + manager.setImages([ + createImageAttachment({ id: 'img-1', name: 'first.png' }), + createImageAttachment({ id: 'img-2', name: 'second.png' }), + ]); + + const trayEl = manager['contextTray']['containerEl']; + const labels = trayEl.querySelectorAll('.claudian-context-chip-label'); + + expect(labels.map((label: any) => label.textContent)).toEqual(['Image 1', 'Image 2']); + }); + + it('remove button should delete the image and update preview', () => { + const cb = createMockCallbacks(); + const { container } = createContainerWithInputWrapper(); + const input = createMockTextArea(); + const mgr: any = new ImageContextManager(container, input, cb); + + mgr.setImages([ + createImageAttachment({ id: 'img-1', name: 'a.png' }), + createImageAttachment({ id: 'img-2', name: 'b.png' }), + ]); + expect(mgr.getAttachedImages()).toHaveLength(2); + + cb.onImagesChanged.mockClear(); + + const trayEl = mgr['contextTray']['containerEl']; + const firstChip = trayEl.querySelector('.claudian-context-chip--image'); + const removeEl = firstChip.querySelector('.claudian-context-chip-remove'); + removeEl.dispatchEvent({ type: 'click', stopPropagation: jest.fn() }); + + expect(mgr.getAttachedImages()).toHaveLength(1); + expect(mgr.getAttachedImages()[0].id).toBe('img-2'); + expect(cb.onImagesChanged).toHaveBeenCalled(); + }); + }); + + describe('showFullImage', () => { + let origDocument: typeof globalThis.document; + let overlayEl: any; + let mockBody: any; + let addEventSpy: jest.Mock; + let removeEventSpy: jest.Mock; + + beforeEach(() => { + overlayEl = createMockEl(); + addEventSpy = jest.fn(); + removeEventSpy = jest.fn(); + mockBody = { createDiv: jest.fn().mockReturnValue(overlayEl) }; + origDocument = globalThis.document; + (globalThis as any).document = { + body: mockBody, + addEventListener: addEventSpy, + removeEventListener: removeEventSpy, + createElementNS: jest.fn(() => mockSvgElement()), + }; + }); + + afterEach(() => { + (globalThis as any).document = origDocument; + }); + + it('should create modal overlay with image', () => { + const image = createImageAttachment({ name: 'test.png', mediaType: 'image/png', data: 'abc123' }); + manager['showFullImage'](image); + + expect(mockBody.createDiv).toHaveBeenCalledWith({ cls: 'claudian-image-modal-overlay' }); + }); + + it('should register Escape key handler and close button', () => { + const image = createImageAttachment(); + manager['showFullImage'](image); + + expect(addEventSpy).toHaveBeenCalledWith('keydown', expect.any(Function)); + + const escHandler = addEventSpy.mock.calls[0][1]; + escHandler({ key: 'Escape' }); + + expect(removeEventSpy).toHaveBeenCalledWith('keydown', escHandler); + }); + + it('should close modal when clicking on overlay background', () => { + const image = createImageAttachment(); + manager['showFullImage'](image); + + const clickHandler = overlayEl._eventListeners.get('click')?.[0]; + expect(clickHandler).toBeDefined(); + + clickHandler({ target: overlayEl }); + + expect(removeEventSpy).toHaveBeenCalled(); + }); + }); + + describe('fileToBase64', () => { + it('should convert file to base64 string', async () => { + const textEncoder = new TextEncoder(); + const bytes = textEncoder.encode('hello'); + const mockBuffer = bytes.buffer; + const file = { + arrayBuffer: jest.fn().mockResolvedValue(mockBuffer), + } as unknown as File; + + const result = await manager['fileToBase64'](file); + expect(typeof result).toBe('string'); + expect(result.length).toBeGreaterThan(0); + // Verify it's valid base64 + const decoded = Buffer.from(result, 'base64').toString(); + expect(decoded).toBe('hello'); + }); + }); +}); diff --git a/tests/unit/features/chat/ui/InputToolbar.test.ts b/tests/unit/features/chat/ui/InputToolbar.test.ts new file mode 100644 index 0000000..086e3cb --- /dev/null +++ b/tests/unit/features/chat/ui/InputToolbar.test.ts @@ -0,0 +1,1139 @@ +import { + TEST_CODEX_MODEL, + TEST_CODEX_MODEL_LABEL, +} from '@test/helpers/codexModels'; +import { createMockEl } from '@test/helpers/mockElement'; + +import type { UsageInfo } from '@/core/types'; +import { + ContextUsageMeter, + createInputToolbar, + McpServerSelector, + ModelSelector, + ModeSelector, + PermissionToggle, + ServiceTierToggle, + ThinkingBudgetSelector, +} from '@/features/chat/ui/InputToolbar'; + +jest.mock('obsidian', () => ({ + Notice: jest.fn(), + setIcon: jest.fn(), +})); + +function makeUsage(overrides: Partial = {}): UsageInfo { + return { + inputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + contextWindow: 200000, + contextTokens: 0, + percentage: 0, + ...overrides, + }; +} + +const DEFAULT_MODELS = [ + { value: 'haiku', label: 'Haiku', description: 'Fast and efficient' }, + { value: 'sonnet', label: 'Sonnet', description: 'Balanced performance' }, + { value: 'sonnet[1m]', label: 'Sonnet 1M', description: 'Balanced performance (1M context window)' }, + { value: 'opus', label: 'Opus', description: 'Most capable' }, + { value: 'opus[1m]', label: 'Opus 1M', description: 'Most capable (1M context window)' }, +]; + +const EFFORT_OPTIONS = [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Med' }, + { value: 'high', label: 'High' }, + { value: 'max', label: 'Max' }, +]; + +const BUDGET_OPTIONS = [ + { value: 'off', label: 'Off', tokens: 0 }, + { value: 'low', label: 'Low', tokens: 4000 }, + { value: 'medium', label: 'Med', tokens: 8000 }, + { value: 'high', label: 'High', tokens: 16000 }, + { value: 'xhigh', label: 'Ultra', tokens: 32000 }, +]; + +const DEFAULT_MODEL_VALUES = new Set(DEFAULT_MODELS.map(m => m.value)); + +function filterVisibleModels( + models: typeof DEFAULT_MODELS, + enableOpus1M: boolean, + enableSonnet1M: boolean, +) { + return models.filter((model) => { + if (model.value === 'opus' || model.value === 'opus[1m]') { + return enableOpus1M ? model.value === 'opus[1m]' : model.value === 'opus'; + } + if (model.value === 'sonnet' || model.value === 'sonnet[1m]') { + return enableSonnet1M ? model.value === 'sonnet[1m]' : model.value === 'sonnet'; + } + return true; + }); +} + +function createMockUIConfig() { + return { + getModelOptions: jest.fn().mockImplementation((settings: { + enableOpus1M?: boolean; + enableSonnet1M?: boolean; + environmentVariables?: string; + }) => { + // Mimic real behavior: env-based custom models bypass 1M filtering + if (settings.environmentVariables) { + const match = settings.environmentVariables.match(/ANTHROPIC_MODEL=(\S+)/); + if (match) { + const value = match[1]; + const label = value.includes('/') + ? value.split('/').pop() || value + : value.replace(/-/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase()); + return [{ value, label }]; + } + } + return filterVisibleModels( + DEFAULT_MODELS, + settings.enableOpus1M ?? false, + settings.enableSonnet1M ?? false, + ); + }), + isAdaptiveReasoningModel: jest.fn().mockImplementation((model: string) => { + if (DEFAULT_MODEL_VALUES.has(model)) return true; + return /claude-(haiku|sonnet|opus)-/.test(model); + }), + getReasoningOptions: jest.fn().mockImplementation((model: string) => { + if (DEFAULT_MODEL_VALUES.has(model) || /claude-(haiku|sonnet|opus)-/.test(model)) { + return EFFORT_OPTIONS; + } + return BUDGET_OPTIONS; + }), + getDefaultReasoningValue: jest.fn().mockReturnValue('high'), + getContextWindowSize: jest.fn().mockReturnValue(200000), + isDefaultModel: jest.fn().mockImplementation((model: string) => + DEFAULT_MODELS.some(m => m.value === model) + ), + applyModelDefaults: jest.fn(), + normalizeModelVariant: jest.fn((model: string) => model), + getPermissionModeToggle: jest.fn().mockReturnValue({ + inactiveValue: 'normal', + inactiveLabel: 'Safe', + activeValue: 'yolo', + activeLabel: 'YOLO', + planValue: 'plan', + planLabel: 'PLAN', + }), + getServiceTierToggle: jest.fn().mockImplementation((settings: Record) => + settings.model === TEST_CODEX_MODEL + ? { + inactiveValue: 'default', + inactiveLabel: 'Standard', + activeValue: 'fast', + activeLabel: 'Fast', + description: '1.5x speed, 2x credits', + } + : null + ), + getModeSelector: jest.fn().mockImplementation((settings: Record) => ({ + activeValue: 'build', + label: 'Mode', + options: [ + { value: 'build', label: 'Build', description: 'Default editing agent' }, + { value: 'plan', label: 'Plan', description: 'Planning-first agent' }, + ], + value: typeof settings.selectedMode === 'string' && settings.selectedMode + ? settings.selectedMode + : 'build', + })), + }; +} + +function createMockCallbacks(overrides: Record = {}) { + return { + onModelChange: jest.fn().mockResolvedValue(undefined), + onModeChange: jest.fn().mockResolvedValue(undefined), + onThinkingBudgetChange: jest.fn().mockResolvedValue(undefined), + onEffortLevelChange: jest.fn().mockResolvedValue(undefined), + onServiceTierChange: jest.fn().mockResolvedValue(undefined), + onPermissionModeChange: jest.fn().mockResolvedValue(undefined), + getSettings: jest.fn().mockReturnValue({ + model: 'sonnet', + thinkingBudget: 'low', + effortLevel: 'high', + serviceTier: 'default', + permissionMode: 'normal', + selectedMode: 'build', + enableOpus1M: false, + enableSonnet1M: false, + }), + getEnvironmentVariables: jest.fn().mockReturnValue(''), + getUIConfig: jest.fn().mockReturnValue(createMockUIConfig()), + getCapabilities: jest.fn().mockReturnValue({ + providerId: 'claude', + supportsPersistentRuntime: true, + supportsNativeHistory: true, + supportsPlanMode: true, + supportsRewind: true, + supportsFork: true, + supportsProviderCommands: true, + reasoningControl: 'effort', + }), + ...overrides, + }; +} + +describe('ModelSelector', () => { + let parentEl: any; + let callbacks: ReturnType; + let selector: ModelSelector; + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl(); + callbacks = createMockCallbacks(); + selector = new ModelSelector(parentEl, callbacks); + }); + + it('should create a container with model-selector class', () => { + const container = parentEl.querySelector('.claudian-model-selector'); + expect(container).not.toBeNull(); + }); + + it('should display current model label', () => { + // Default model is 'sonnet' which maps to 'Sonnet' + const btn = parentEl.querySelector('.claudian-model-btn'); + expect(btn).not.toBeNull(); + const label = btn?.querySelector('.claudian-model-label'); + expect(label).not.toBeNull(); + expect(label?.textContent).toBe('Sonnet'); + }); + + it('should display first model when current model not found', () => { + callbacks.getSettings.mockReturnValue({ + model: 'nonexistent', + thinkingBudget: 'low', + serviceTier: 'default', + permissionMode: 'normal', + enableOpus1M: false, + enableSonnet1M: false, + }); + selector.updateDisplay(); + const label = parentEl.querySelector('.claudian-model-label'); + expect(label?.textContent).toBe('Haiku'); + }); + + it('should render model options in reverse order', () => { + const dropdown = parentEl.querySelector('.claudian-model-dropdown'); + expect(dropdown).not.toBeNull(); + // DEFAULT_CLAUDE_MODELS is [haiku, sonnet, opus] -> reversed is [opus, sonnet, haiku] + const options = dropdown?.children || []; + expect(options.length).toBe(3); + // Text is in child span, check first child's textContent + expect(options[0]?.children[0]?.textContent).toBe('Opus'); + expect(options[1]?.children[0]?.textContent).toBe('Sonnet'); + expect(options[2]?.children[0]?.textContent).toBe('Haiku'); + }); + + it('should mark current model as selected', () => { + const dropdown = parentEl.querySelector('.claudian-model-dropdown'); + const options = dropdown?.children || []; + // Sonnet is current (index 1 in reversed order) + const sonnetOption = options.find((o: any) => o.children[0]?.textContent === 'Sonnet'); + expect(sonnetOption?.hasClass('selected')).toBe(true); + }); + + it('should call onModelChange when option clicked', async () => { + const dropdown = parentEl.querySelector('.claudian-model-dropdown'); + const options = dropdown?.children || []; + const opusOption = options.find((o: any) => o.children[0]?.textContent === 'Opus'); + + await opusOption?.dispatchEvent('click', { stopPropagation: () => {} }); + expect(callbacks.onModelChange).toHaveBeenCalledWith('opus'); + }); + + it('should always show brand color on model button', () => { + const btn = parentEl.querySelector('.claudian-model-btn'); + expect(btn).toBeTruthy(); + expect(btn?.hasClass('ready')).toBe(false); + }); + + it('should use custom models from environment variables', () => { + callbacks.getEnvironmentVariables.mockReturnValue( + 'CLAUDE_CODE_USE_BEDROCK=1\nANTHROPIC_MODEL=us.anthropic.claude-sonnet-4-20250514-v1:0' + ); + callbacks.getSettings.mockReturnValue({ + model: 'us.anthropic.claude-sonnet-4-20250514-v1:0', + thinkingBudget: 'low', + permissionMode: 'normal', + enableOpus1M: false, + enableSonnet1M: false, + }); + selector.renderOptions(); + selector.updateDisplay(); + // Custom models should be available in dropdown + const label = parentEl.querySelector('.claudian-model-label'); + expect(label?.textContent).toBeDefined(); + }); + + it('should not filter custom env models when 1M toggles are enabled', () => { + callbacks.getEnvironmentVariables.mockReturnValue( + 'ANTHROPIC_MODEL=opus' + ); + callbacks.getSettings.mockReturnValue({ + model: 'opus', + thinkingBudget: 'low', + permissionMode: 'normal', + enableOpus1M: true, + enableSonnet1M: true, + }); + + selector.renderOptions(); + selector.updateDisplay(); + + const label = parentEl.querySelector('.claudian-model-label'); + expect(label?.textContent).toBe('Opus'); + }); + + it('should render group separators when models have group field', () => { + const groupedModels = [ + { value: 'opus', label: 'Opus', group: 'Claude' }, + { value: 'sonnet', label: 'Sonnet', group: 'Claude' }, + { value: TEST_CODEX_MODEL, label: TEST_CODEX_MODEL_LABEL, group: 'Codex' }, + ]; + const uiConfig = createMockUIConfig(); + uiConfig.getModelOptions.mockReturnValue(groupedModels); + callbacks.getUIConfig.mockReturnValue(uiConfig); + callbacks.getSettings.mockReturnValue({ + model: 'sonnet', + thinkingBudget: 'low', + effortLevel: 'high', + serviceTier: 'default', + permissionMode: 'normal', + }); + + selector.renderOptions(); + + const dropdown = parentEl.querySelector('.claudian-model-dropdown'); + const children = dropdown?.children || []; + // Reversed: [Codex group, built-in Codex model, Claude group, Sonnet, Opus] + const groups = children.filter((c: any) => c.hasClass('claudian-model-group')); + expect(groups.length).toBe(2); + expect(groups[0]?.textContent).toBe('Codex'); + expect(groups[1]?.textContent).toBe('Claude'); + }); + + it('should not render group separators when models have no group field', () => { + selector.renderOptions(); + + const dropdown = parentEl.querySelector('.claudian-model-dropdown'); + const children = dropdown?.children || []; + const groups = children.filter((c: any) => c.hasClass('claudian-model-group')); + expect(groups.length).toBe(0); + }); + + it('should show 1M variants instead of standard variants when enabled', () => { + callbacks.getSettings.mockReturnValue({ + model: 'opus[1m]', + thinkingBudget: 'medium', + serviceTier: 'default', + permissionMode: 'normal', + enableOpus1M: true, + enableSonnet1M: true, + }); + + selector.renderOptions(); + selector.updateDisplay(); + + const dropdown = parentEl.querySelector('.claudian-model-dropdown'); + const options = dropdown?.children || []; + expect(options.find((o: any) => o.children[0]?.textContent === 'Opus 1M')).toBeDefined(); + expect(options.find((o: any) => o.children[0]?.textContent === 'Sonnet 1M')).toBeDefined(); + expect(options.find((o: any) => o.children[0]?.textContent === 'Opus')).toBeUndefined(); + expect(options.find((o: any) => o.children[0]?.textContent === 'Sonnet')).toBeUndefined(); + expect(parentEl.querySelector('.claudian-model-label')?.textContent).toBe('Opus 1M'); + }); +}); + +describe('ModeSelector', () => { + let parentEl: any; + let callbacks: ReturnType; + let selector: ModeSelector; + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl(); + callbacks = createMockCallbacks(); + selector = new ModeSelector(parentEl, callbacks); + }); + + it('should create a container with mode-selector class', () => { + const container = parentEl.querySelector('.claudian-mode-selector'); + expect(container).not.toBeNull(); + }); + + it('should display the current mode label', () => { + const label = parentEl.querySelector('.claudian-mode-label'); + expect(label?.textContent).toBe('Build'); + }); + + it('should call onModeChange when the toggle is clicked', async () => { + const toggle = parentEl.querySelector('.claudian-toggle-switch'); + await toggle?.dispatchEvent('click'); + + expect(callbacks.onModeChange).toHaveBeenCalledWith('plan'); + }); + + it('should show the active style when the configured active mode is selected', () => { + callbacks.getSettings.mockReturnValue({ + model: 'sonnet', + thinkingBudget: 'low', + effortLevel: 'high', + serviceTier: 'default', + permissionMode: 'normal', + selectedMode: 'build', + enableOpus1M: false, + enableSonnet1M: false, + }); + + const parentEl2 = createMockEl(); + new ModeSelector(parentEl2, callbacks); + + const label = parentEl2.querySelector('.claudian-mode-label'); + const toggle = parentEl2.querySelector('.claudian-toggle-switch'); + expect(label?.textContent).toBe('Build'); + expect(label?.hasClass('active')).toBe(true); + expect(toggle?.hasClass('active')).toBe(true); + }); + + it('should show the inactive style when the configured inactive mode is selected', () => { + callbacks.getSettings.mockReturnValue({ + model: 'sonnet', + thinkingBudget: 'low', + effortLevel: 'high', + serviceTier: 'default', + permissionMode: 'normal', + selectedMode: 'plan', + enableOpus1M: false, + enableSonnet1M: false, + }); + + const parentEl2 = createMockEl(); + new ModeSelector(parentEl2, callbacks); + + const label = parentEl2.querySelector('.claudian-mode-label'); + const toggle = parentEl2.querySelector('.claudian-toggle-switch'); + expect(label?.textContent).toBe('Plan'); + expect(label?.hasClass('active')).toBe(false); + expect(toggle?.hasClass('active')).toBe(false); + }); + + it('should hide when the provider exposes no mode selector', () => { + const uiConfig = createMockUIConfig(); + uiConfig.getModeSelector.mockReturnValue(null); + callbacks.getUIConfig.mockReturnValue(uiConfig); + + selector.updateDisplay(); + + const container = parentEl.querySelector('.claudian-mode-selector'); + expect(container?.style?.display).toBe('none'); + }); +}); + +describe('ThinkingBudgetSelector', () => { + let parentEl: any; + let callbacks: ReturnType; + let selector: ThinkingBudgetSelector; + + describe('adaptive mode (Claude models)', () => { + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl(); + callbacks = createMockCallbacks(); + selector = new ThinkingBudgetSelector(parentEl, callbacks); + }); + + it('should create a container with thinking-selector class', () => { + const container = parentEl.querySelector('.claudian-thinking-selector'); + expect(container).not.toBeNull(); + }); + + it('should show effort selector for Claude models', () => { + const effort = parentEl.querySelector('.claudian-thinking-effort'); + expect(effort).not.toBeNull(); + expect(effort?.style?.display).not.toBe('none'); + }); + + it('should hide budget selector for Claude models', () => { + const budget = parentEl.querySelector('.claudian-thinking-budget'); + expect(budget?.style?.display).toBe('none'); + }); + + it('should display current effort level for Claude models', () => { + const current = parentEl.querySelector('.claudian-thinking-current'); + expect(current?.textContent).toBe('High'); + }); + }); + + describe('legacy mode (custom models)', () => { + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl(); + callbacks = createMockCallbacks({ + getSettings: jest.fn().mockReturnValue({ + model: 'custom-model', + thinkingBudget: 'low', + effortLevel: 'high', + serviceTier: 'default', + permissionMode: 'normal', + enableOpus1M: false, + enableSonnet1M: false, + }), + }); + selector = new ThinkingBudgetSelector(parentEl, callbacks); + }); + + it('should hide effort selector for custom models', () => { + const effort = parentEl.querySelector('.claudian-thinking-effort'); + expect(effort?.style?.display).toBe('none'); + }); + + it('should show budget selector for custom models', () => { + const budget = parentEl.querySelector('.claudian-thinking-budget'); + expect(budget?.style?.display).not.toBe('none'); + }); + + it('should display current budget label', () => { + const current = parentEl.querySelector('.claudian-thinking-current'); + expect(current?.textContent).toBe('Low'); + }); + + it('should display Off when budget is off', () => { + callbacks.getSettings.mockReturnValue({ + model: 'custom-model', + thinkingBudget: 'off', + serviceTier: 'default', + permissionMode: 'normal', + enableOpus1M: false, + enableSonnet1M: false, + }); + selector.updateDisplay(); + const current = parentEl.querySelector('.claudian-thinking-current'); + expect(current?.textContent).toBe('Off'); + }); + + it('should render budget options in reverse order', () => { + const options = parentEl.querySelector('.claudian-thinking-options'); + expect(options).not.toBeNull(); + // THINKING_BUDGETS reversed: [xhigh, high, medium, low, off] + const gears = options?.children || []; + expect(gears.length).toBe(5); + expect(gears[0]?.textContent).toBe('Ultra'); + expect(gears[4]?.textContent).toBe('Off'); + }); + + it('should mark current budget as selected', () => { + const options = parentEl.querySelector('.claudian-thinking-options'); + const gears = options?.children || []; + const lowGear = gears.find((g: any) => g.textContent === 'Low'); + expect(lowGear?.hasClass('selected')).toBe(true); + }); + + it('should call onThinkingBudgetChange when gear clicked', async () => { + const options = parentEl.querySelector('.claudian-thinking-options'); + const gears = options?.children || []; + const highGear = gears.find((g: any) => g.textContent === 'High'); + + await highGear?.dispatchEvent('click', { stopPropagation: () => {} }); + expect(callbacks.onThinkingBudgetChange).toHaveBeenCalledWith('high'); + }); + + it('should set title with token count for non-off budgets', () => { + const options = parentEl.querySelector('.claudian-thinking-options'); + const gears = options?.children || []; + const highGear = gears.find((g: any) => g.textContent === 'High'); + expect(highGear?.getAttribute('title')).toContain('16,000 tokens'); + }); + + it('should set title as Disabled for off budget', () => { + const options = parentEl.querySelector('.claudian-thinking-options'); + const gears = options?.children || []; + const offGear = gears.find((g: any) => g.textContent === 'Off'); + expect(offGear?.getAttribute('title')).toBe('Disabled'); + }); + }); +}); + +describe('PermissionToggle', () => { + let parentEl: any; + let callbacks: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl(); + callbacks = createMockCallbacks(); + new PermissionToggle(parentEl, callbacks); + }); + + it('should create a container with permission-toggle class', () => { + const container = parentEl.querySelector('.claudian-permission-toggle'); + expect(container).not.toBeNull(); + }); + + it('should display Safe label when in normal mode', () => { + const label = parentEl.querySelector('.claudian-permission-label'); + expect(label?.textContent).toBe('Safe'); + }); + + it('should display YOLO label when in yolo mode', () => { + callbacks.getSettings.mockReturnValue({ + model: 'sonnet', + thinkingBudget: 'low', + serviceTier: 'default', + permissionMode: 'yolo', + enableOpus1M: false, + enableSonnet1M: false, + }); + const parentEl2 = createMockEl(); + new PermissionToggle(parentEl2, callbacks); + + const label = parentEl2.querySelector('.claudian-permission-label'); + expect(label?.textContent).toBe('YOLO'); + }); + + it('should show PLAN label and hide toggle in plan mode', () => { + callbacks.getSettings.mockReturnValue({ + model: 'sonnet', + thinkingBudget: 'low', + serviceTier: 'default', + permissionMode: 'plan', + enableOpus1M: false, + enableSonnet1M: false, + }); + const parentEl2 = createMockEl(); + new PermissionToggle(parentEl2, callbacks); + + const label = parentEl2.querySelector('.claudian-permission-label'); + expect(label?.textContent).toBe('PLAN'); + expect(label?.hasClass('plan-active')).toBe(true); + + const toggle = parentEl2.querySelector('.claudian-toggle-switch'); + expect(toggle?.style.display).toBe('none'); + }); + + it('should add active class when in yolo mode', () => { + callbacks.getSettings.mockReturnValue({ + model: 'sonnet', + thinkingBudget: 'low', + serviceTier: 'default', + permissionMode: 'yolo', + }); + const parentEl2 = createMockEl(); + new PermissionToggle(parentEl2, callbacks); + + const toggle = parentEl2.querySelector('.claudian-toggle-switch'); + expect(toggle?.hasClass('active')).toBe(true); + }); + + it('should not have active class in normal mode', () => { + const toggle = parentEl.querySelector('.claudian-toggle-switch'); + expect(toggle?.hasClass('active')).toBe(false); + }); + + it('should toggle from normal to yolo on click', async () => { + const toggle = parentEl.querySelector('.claudian-toggle-switch'); + await toggle?.dispatchEvent('click'); + expect(callbacks.onPermissionModeChange).toHaveBeenCalledWith('yolo'); + }); + + it('should toggle from yolo to normal on click', async () => { + callbacks.getSettings.mockReturnValue({ + model: 'sonnet', + thinkingBudget: 'low', + permissionMode: 'yolo', + }); + const parentEl2 = createMockEl(); + new PermissionToggle(parentEl2, callbacks); + + const toggle = parentEl2.querySelector('.claudian-toggle-switch'); + await toggle?.dispatchEvent('click'); + expect(callbacks.onPermissionModeChange).toHaveBeenCalledWith('normal'); + }); + + it('should hide the control when provider exposes no permission toggle UI', () => { + callbacks.getUIConfig.mockReturnValue({ + ...createMockUIConfig(), + getPermissionModeToggle: jest.fn().mockReturnValue(null), + }); + const parentEl2 = createMockEl(); + new PermissionToggle(parentEl2, callbacks); + + const container = parentEl2.querySelector('.claudian-permission-toggle'); + expect(container?.style.display).toBe('none'); + }); + + it('should hide the control when visibility is disabled explicitly', () => { + const parentEl2 = createMockEl(); + const toggle = new PermissionToggle(parentEl2, callbacks); + + toggle.setVisible(false); + + const container = parentEl2.querySelector('.claudian-permission-toggle'); + expect(container?.style.display).toBe('none'); + }); +}); + +describe('ServiceTierToggle', () => { + let parentEl: any; + let callbacks: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl(); + const uiConfig = createMockUIConfig(); + uiConfig.getServiceTierToggle.mockReturnValue({ + inactiveValue: 'default', + inactiveLabel: 'Standard', + activeValue: 'fast', + activeLabel: 'Fast', + description: '1.5x speed, 2x credits', + }); + callbacks = createMockCallbacks({ + getUIConfig: jest.fn().mockReturnValue(uiConfig), + getSettings: jest.fn().mockReturnValue({ + model: TEST_CODEX_MODEL, + thinkingBudget: 'off', + effortLevel: 'medium', + serviceTier: 'default', + permissionMode: 'normal', + }), + }); + new ServiceTierToggle(parentEl, callbacks); + }); + + it('shows the control when the provider exposes service tier options', () => { + const container = parentEl.querySelector('.claudian-service-tier-toggle'); + expect(container).not.toBeNull(); + expect(container?.hasClass('claudian-hidden')).toBe(false); + }); + + it('renders the icon button in the inactive state when fast mode is off', () => { + const button = parentEl.querySelector('.claudian-service-tier-button'); + const icon = parentEl.querySelector('.claudian-service-tier-icon'); + const container = parentEl.querySelector('.claudian-service-tier-toggle'); + expect(button?.hasClass('active')).toBe(false); + expect(icon).not.toBeNull(); + expect(container?.getAttribute('title')).toBe('Toggle on/off fast mode'); + }); + + it('renders the icon button in the active state when fast mode is on', () => { + callbacks.getSettings.mockReturnValue({ + model: TEST_CODEX_MODEL, + thinkingBudget: 'off', + effortLevel: 'medium', + serviceTier: 'fast', + permissionMode: 'normal', + }); + const parentEl2 = createMockEl(); + new ServiceTierToggle(parentEl2, callbacks); + + const button = parentEl2.querySelector('.claudian-service-tier-button'); + const container = parentEl2.querySelector('.claudian-service-tier-toggle'); + expect(button?.hasClass('active')).toBe(true); + expect(container?.getAttribute('title')).toBe('Toggle on/off fast mode'); + }); + + it('toggles from Standard to Fast on click', async () => { + const button = parentEl.querySelector('.claudian-service-tier-button'); + await button?.dispatchEvent('click'); + expect(callbacks.onServiceTierChange).toHaveBeenCalledWith('fast'); + }); + + it('toggles from Fast to Standard on click', async () => { + callbacks.getSettings.mockReturnValue({ + model: TEST_CODEX_MODEL, + thinkingBudget: 'off', + effortLevel: 'medium', + serviceTier: 'fast', + permissionMode: 'normal', + }); + const parentEl2 = createMockEl(); + new ServiceTierToggle(parentEl2, callbacks); + + const button = parentEl2.querySelector('.claudian-service-tier-button'); + await button?.dispatchEvent('click'); + expect(callbacks.onServiceTierChange).toHaveBeenCalledWith('default'); + }); + + it('hides the control when the provider exposes no service tier UI', () => { + callbacks.getUIConfig.mockReturnValue({ + ...createMockUIConfig(), + getServiceTierToggle: jest.fn().mockReturnValue(null), + }); + const parentEl2 = createMockEl(); + new ServiceTierToggle(parentEl2, callbacks); + + const container = parentEl2.querySelector('.claudian-service-tier-toggle'); + expect(container?.style.display).toBe('none'); + }); +}); + +describe('McpServerSelector', () => { + let parentEl: any; + let selector: McpServerSelector; + + function createMockMcpManager(servers: { name: string; enabled: boolean; contextSaving?: boolean }[] = []) { + return { + getServers: jest.fn().mockReturnValue( + servers.map(s => ({ + name: s.name, + enabled: s.enabled, + contextSaving: s.contextSaving ?? false, + })) + ), + } as any; + } + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl(); + selector = new McpServerSelector(parentEl); + }); + + it('should create container with mcp-selector class', () => { + const container = parentEl.querySelector('.claudian-mcp-selector'); + expect(container).not.toBeNull(); + }); + + it('should return empty set of enabled servers initially', () => { + expect(selector.getEnabledServers().size).toBe(0); + }); + + it('should hide container when no servers configured', () => { + selector.setMcpManager(createMockMcpManager([])); + const container = parentEl.querySelector('.claudian-mcp-selector'); + expect(container?.style.display).toBe('none'); + }); + + it('should show container when servers are configured', () => { + selector.setMcpManager(createMockMcpManager([{ name: 'test', enabled: true }])); + const container = parentEl.querySelector('.claudian-mcp-selector'); + expect(container?.hasClass('claudian-hidden')).toBe(false); + }); + + it('should show empty message when all servers are disabled', () => { + selector.setMcpManager(createMockMcpManager([{ name: 'test', enabled: false }])); + const empty = parentEl.querySelector('.claudian-mcp-selector-empty'); + expect(empty?.textContent).toBe('All MCP servers disabled'); + }); + + it('should show no servers message when no servers configured', () => { + selector.setMcpManager(createMockMcpManager([])); + const empty = parentEl.querySelector('.claudian-mcp-selector-empty'); + expect(empty?.textContent).toBe('No MCP servers configured'); + }); + + it('should add mentioned servers', () => { + selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); + selector.addMentionedServers(new Set(['server1'])); + expect(selector.getEnabledServers().has('server1')).toBe(true); + }); + + it('should not re-render when adding already enabled servers', () => { + selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); + selector.addMentionedServers(new Set(['server1'])); + const enabledBefore = selector.getEnabledServers(); + + selector.addMentionedServers(new Set(['server1'])); + expect(selector.getEnabledServers()).toEqual(enabledBefore); + }); + + it('should clear all enabled servers', () => { + selector.setMcpManager(createMockMcpManager([ + { name: 'server1', enabled: true }, + { name: 'server2', enabled: true }, + ])); + selector.addMentionedServers(new Set(['server1', 'server2'])); + expect(selector.getEnabledServers().size).toBe(2); + + selector.clearEnabled(); + expect(selector.getEnabledServers().size).toBe(0); + }); + + it('should set enabled servers from array', () => { + selector.setMcpManager(createMockMcpManager([ + { name: 'server1', enabled: true }, + { name: 'server2', enabled: true }, + ])); + selector.setEnabledServers(['server1', 'server2']); + expect(selector.getEnabledServers().size).toBe(2); + }); + + it('should prune enabled servers that no longer exist in manager', () => { + selector.setMcpManager(createMockMcpManager([ + { name: 'server1', enabled: true }, + { name: 'server2', enabled: true }, + ])); + selector.setEnabledServers(['server1', 'server2']); + + // Now update manager to only have server1 + selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); + expect(selector.getEnabledServers().has('server1')).toBe(true); + expect(selector.getEnabledServers().has('server2')).toBe(false); + }); + + it('should invoke onChange callback when pruning removes servers', () => { + const onChange = jest.fn(); + selector.setOnChange(onChange); + + selector.setMcpManager(createMockMcpManager([ + { name: 'server1', enabled: true }, + { name: 'server2', enabled: true }, + ])); + selector.setEnabledServers(['server1', 'server2']); + onChange.mockClear(); + + // Prune by removing server2 + selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); + expect(onChange).toHaveBeenCalled(); + }); + + it('should show badge when more than 1 server enabled', () => { + selector.setMcpManager(createMockMcpManager([ + { name: 'server1', enabled: true }, + { name: 'server2', enabled: true }, + ])); + selector.setEnabledServers(['server1', 'server2']); + selector.updateDisplay(); + + const badge = parentEl.querySelector('.claudian-mcp-selector-badge'); + expect(badge?.hasClass('visible')).toBe(true); + expect(badge?.textContent).toBe('2'); + }); + + it('should not show badge when only 1 server enabled', () => { + selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); + selector.setEnabledServers(['server1']); + selector.updateDisplay(); + + const badge = parentEl.querySelector('.claudian-mcp-selector-badge'); + expect(badge?.hasClass('visible')).toBe(false); + }); + + it('should add active class to icon when servers are enabled', () => { + selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); + selector.setEnabledServers(['server1']); + selector.updateDisplay(); + + const icon = parentEl.querySelector('.claudian-mcp-selector-icon'); + expect(icon?.hasClass('active')).toBe(true); + }); + + it('should remove active class from icon when no servers enabled', () => { + selector.setMcpManager(createMockMcpManager([{ name: 'server1', enabled: true }])); + selector.clearEnabled(); + selector.updateDisplay(); + + const icon = parentEl.querySelector('.claudian-mcp-selector-icon'); + expect(icon?.hasClass('active')).toBe(false); + }); + + it('should handle null mcpManager', () => { + selector.setMcpManager(null); + expect(selector.getEnabledServers().size).toBe(0); + }); +}); + +describe('ContextUsageMeter', () => { + let parentEl: any; + let meter: ContextUsageMeter; + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl(); + meter = new ContextUsageMeter(parentEl); + }); + + it('should create a container with context-meter class', () => { + const container = parentEl.querySelector('.claudian-context-meter'); + expect(container).not.toBeNull(); + }); + + it('should be hidden initially', () => { + const container = parentEl.querySelector('.claudian-context-meter'); + expect(container?.style.display).toBe('none'); + }); + + it('should remain hidden when update called with null', () => { + meter.update(null); + const container = parentEl.querySelector('.claudian-context-meter'); + expect(container?.style.display).toBe('none'); + }); + + it('should remain hidden when contextTokens is 0', () => { + meter.update(makeUsage({ contextTokens: 0, contextWindow: 200000, percentage: 0 })); + const container = parentEl.querySelector('.claudian-context-meter'); + expect(container?.style.display).toBe('none'); + }); + + it('should become visible when contextTokens > 0', () => { + meter.update(makeUsage({ contextTokens: 50000, contextWindow: 200000, percentage: 25 })); + const container = parentEl.querySelector('.claudian-context-meter'); + expect(container?.style.display).toBe('flex'); + }); + + it('should display percentage', () => { + meter.update(makeUsage({ contextTokens: 50000, contextWindow: 200000, percentage: 25 })); + const percent = parentEl.querySelector('.claudian-context-meter-percent'); + expect(percent?.textContent).toBe('25%'); + }); + + it('should add warning class when usage > 80%', () => { + meter.update(makeUsage({ contextTokens: 170000, contextWindow: 200000, percentage: 85 })); + const container = parentEl.querySelector('.claudian-context-meter'); + expect(container?.hasClass('warning')).toBe(true); + }); + + it('should remove warning class when usage drops below 80%', () => { + meter.update(makeUsage({ contextTokens: 170000, contextWindow: 200000, percentage: 85 })); + meter.update(makeUsage({ contextTokens: 50000, contextWindow: 200000, percentage: 25 })); + const container = parentEl.querySelector('.claudian-context-meter'); + expect(container?.hasClass('warning')).toBe(false); + }); + + it('should set tooltip with formatted token counts', () => { + meter.update(makeUsage({ contextTokens: 50000, contextWindow: 200000, percentage: 25 })); + const container = parentEl.querySelector('.claudian-context-meter'); + expect(container?.getAttribute('data-tooltip')).toBe('50k / 200k'); + }); + + it('should format small token counts without k suffix', () => { + meter.update(makeUsage({ contextTokens: 500, contextWindow: 200000, percentage: 0 })); + const container = parentEl.querySelector('.claudian-context-meter'); + expect(container?.getAttribute('data-tooltip')).toBe('500 / 200k'); + }); + + it('should add compact reminder to tooltip when usage > 80%', () => { + meter.update(makeUsage({ contextTokens: 170000, contextWindow: 200000, percentage: 85 })); + const container = parentEl.querySelector('.claudian-context-meter'); + expect(container?.getAttribute('data-tooltip')).toBe('170k / 200k (Approaching limit, run `/compact` to continue)'); + }); + + it('should not add compact reminder to tooltip when usage ≤ 80%', () => { + meter.update(makeUsage({ contextTokens: 160000, contextWindow: 200000, percentage: 80 })); + const container = parentEl.querySelector('.claudian-context-meter'); + expect(container?.getAttribute('data-tooltip')).toBe('160k / 200k'); + }); +}); + +describe('McpServerSelector - toggle and badges', () => { + let parentEl: any; + let selector: McpServerSelector; + + function createMockMcpManager(servers: { name: string; enabled: boolean; contextSaving?: boolean }[] = []) { + return { + getServers: jest.fn().mockReturnValue( + servers.map(s => ({ + name: s.name, + enabled: s.enabled, + contextSaving: s.contextSaving ?? false, + })) + ), + } as any; + } + + beforeEach(() => { + jest.clearAllMocks(); + parentEl = createMockEl(); + selector = new McpServerSelector(parentEl); + }); + + it('should render context-saving badge for servers with contextSaving', () => { + selector.setMcpManager(createMockMcpManager([ + { name: 'server1', enabled: true, contextSaving: true }, + ])); + + const csBadge = parentEl.querySelector('.claudian-mcp-selector-cs-badge'); + expect(csBadge).not.toBeNull(); + expect(csBadge?.textContent).toBe('@'); + }); + + it('should not render context-saving badge for servers without contextSaving', () => { + selector.setMcpManager(createMockMcpManager([ + { name: 'server1', enabled: true, contextSaving: false }, + ])); + + const csBadge = parentEl.querySelector('.claudian-mcp-selector-cs-badge'); + expect(csBadge).toBeNull(); + }); + + it('should toggle server on mousedown and update display', () => { + const onChange = jest.fn(); + selector.setOnChange(onChange); + + selector.setMcpManager(createMockMcpManager([ + { name: 'server1', enabled: true }, + ])); + + // Find the server item and trigger mousedown + const item = parentEl.querySelector('.claudian-mcp-selector-item'); + expect(item).not.toBeNull(); + + // Simulate mousedown to enable + const mousedownHandlers = item._eventListeners?.get('mousedown'); + expect(mousedownHandlers).toBeDefined(); + mousedownHandlers![0]({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); + + expect(selector.getEnabledServers().has('server1')).toBe(true); + expect(onChange).toHaveBeenCalled(); + + // Toggle again to disable + onChange.mockClear(); + mousedownHandlers![0]({ preventDefault: jest.fn(), stopPropagation: jest.fn() }); + + expect(selector.getEnabledServers().has('server1')).toBe(false); + expect(onChange).toHaveBeenCalled(); + }); + + it('should re-render dropdown on mouseenter', () => { + selector.setMcpManager(createMockMcpManager([ + { name: 'server1', enabled: true }, + ])); + + // Get container and trigger mouseenter + const container = parentEl.querySelector('.claudian-mcp-selector'); + const mouseenterHandlers = container?._eventListeners?.get('mouseenter'); + expect(mouseenterHandlers).toBeDefined(); + + // Should not throw + expect(() => mouseenterHandlers![0]()).not.toThrow(); + }); +}); + +describe('createInputToolbar', () => { + it('should return all toolbar components', () => { + const parentEl = createMockEl(); + const callbacks = createMockCallbacks(); + const toolbar = createInputToolbar(parentEl, callbacks); + + expect(toolbar.modelSelector).toBeInstanceOf(ModelSelector); + expect(toolbar.modeSelector).toBeInstanceOf(ModeSelector); + expect(toolbar.thinkingBudgetSelector).toBeInstanceOf(ThinkingBudgetSelector); + expect(toolbar.contextUsageMeter).toBeInstanceOf(ContextUsageMeter); + expect(toolbar.mcpServerSelector).toBeInstanceOf(McpServerSelector); + expect(toolbar.permissionToggle).toBeInstanceOf(PermissionToggle); + expect(toolbar.serviceTierToggle).toBeInstanceOf(ServiceTierToggle); + }); + + it('should place the mode selector after the permission toggle in toolbar order', () => { + const parentEl = createMockEl(); + const callbacks = createMockCallbacks(); + + createInputToolbar(parentEl, callbacks); + + const permissionIndex = parentEl.children.findIndex((child: any) => child.hasClass('claudian-permission-toggle')); + const modeIndex = parentEl.children.findIndex((child: any) => child.hasClass('claudian-mode-selector')); + expect(permissionIndex).toBeGreaterThanOrEqual(0); + expect(modeIndex).toBeGreaterThan(permissionIndex); + expect(modeIndex).toBe(parentEl.children.length - 1); + }); +}); diff --git a/tests/unit/features/chat/ui/InstructionModeManager.test.ts b/tests/unit/features/chat/ui/InstructionModeManager.test.ts new file mode 100644 index 0000000..7336a27 --- /dev/null +++ b/tests/unit/features/chat/ui/InstructionModeManager.test.ts @@ -0,0 +1,243 @@ +import { InstructionModeManager } from '@/features/chat/ui/InstructionModeManager'; + +function createWrapper() { + return { + addClass: jest.fn(), + removeClass: jest.fn(), + } as any; +} + +function createKeyEvent(key: string, options: { shiftKey?: boolean } = {}) { + return { + key, + shiftKey: options.shiftKey ?? false, + preventDefault: jest.fn(), + } as any; +} + +describe('InstructionModeManager', () => { + it('should enter instruction mode on # keystroke when input is empty', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new InstructionModeManager(inputEl, callbacks); + const e = createKeyEvent('#'); + const handled = manager.handleTriggerKey(e); + + expect(handled).toBe(true); + expect(e.preventDefault).toHaveBeenCalled(); + expect(manager.isActive()).toBe(true); + expect(inputEl.placeholder).toBe('# Save in custom system prompt'); + expect(wrapper.addClass).toHaveBeenCalledWith('claudian-input-instruction-mode'); + }); + + it('should NOT enter instruction mode on # keystroke when input has content', () => { + const wrapper = createWrapper(); + const inputEl = { value: 'hello', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new InstructionModeManager(inputEl, callbacks); + const e = createKeyEvent('#'); + const handled = manager.handleTriggerKey(e); + + expect(handled).toBe(false); + expect(e.preventDefault).not.toHaveBeenCalled(); + expect(manager.isActive()).toBe(false); + expect(wrapper.addClass).not.toHaveBeenCalled(); + }); + + it('should NOT enter instruction mode when pasting "# hello"', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new InstructionModeManager(inputEl, callbacks); + + // Simulate paste - no keystroke, just input change + inputEl.value = '# hello'; + manager.handleInputChange(); + + expect(manager.isActive()).toBe(false); + expect(wrapper.addClass).not.toHaveBeenCalled(); + }); + + it('should exit instruction mode when input is cleared', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new InstructionModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('#')); + expect(manager.isActive()).toBe(true); + + inputEl.value = ''; + manager.handleInputChange(); + + expect(manager.isActive()).toBe(false); + expect(inputEl.placeholder).toBe('Ask...'); + expect(wrapper.removeClass).toHaveBeenCalledWith('claudian-input-instruction-mode'); + }); + + it('should submit instruction on Enter (without Shift) and trim whitespace', async () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new InstructionModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('#')); + + inputEl.value = ' test '; + manager.handleInputChange(); + + const e = createKeyEvent('Enter'); + const handled = manager.handleKeydown(e); + + expect(handled).toBe(true); + expect(e.preventDefault).toHaveBeenCalled(); + expect(callbacks.onSubmit).toHaveBeenCalledWith('test'); + }); + + it('should not handle Enter when instruction is empty', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new InstructionModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('#')); + + inputEl.value = ' '; + manager.handleInputChange(); + + const e = createKeyEvent('Enter'); + const handled = manager.handleKeydown(e); + + expect(handled).toBe(false); + expect(e.preventDefault).not.toHaveBeenCalled(); + expect(callbacks.onSubmit).not.toHaveBeenCalled(); + }); + + it('should cancel on Escape and clear input', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new InstructionModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('#')); + expect(manager.isActive()).toBe(true); + + inputEl.value = 'hello'; + manager.handleInputChange(); + + const e = createKeyEvent('Escape'); + const handled = manager.handleKeydown(e); + + expect(handled).toBe(true); + expect(e.preventDefault).toHaveBeenCalled(); + expect(inputEl.value).toBe(''); + expect(manager.isActive()).toBe(false); + }); + + it('should return false for non-Enter/Escape keys when active', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new InstructionModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('#')); + expect(manager.isActive()).toBe(true); + + inputEl.value = 'some text'; + manager.handleInputChange(); + + const e = createKeyEvent('a'); + const handled = manager.handleKeydown(e); + + expect(handled).toBe(false); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + it('should return raw instruction text via getRawInstruction', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new InstructionModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('#')); + + inputEl.value = 'my instruction'; + manager.handleInputChange(); + + expect(manager.getRawInstruction()).toBe('my instruction'); + }); + + it('should clear input, exit mode and reset input height on clear()', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const resetInputHeight = jest.fn(); + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + resetInputHeight, + }; + + const manager = new InstructionModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('#')); + expect(manager.isActive()).toBe(true); + + inputEl.value = 'instruction text'; + manager.handleInputChange(); + + manager.clear(); + + expect(inputEl.value).toBe(''); + expect(manager.isActive()).toBe(false); + expect(resetInputHeight).toHaveBeenCalled(); + }); + + it('should remove instruction mode class and restore placeholder on destroy()', () => { + const wrapper = createWrapper(); + const inputEl = { value: '', placeholder: 'Ask...' } as any; + const callbacks = { + onSubmit: jest.fn().mockResolvedValue(undefined), + getInputWrapper: () => wrapper, + }; + + const manager = new InstructionModeManager(inputEl, callbacks); + manager.handleTriggerKey(createKeyEvent('#')); + expect(manager.isActive()).toBe(true); + expect(inputEl.placeholder).toBe('# Save in custom system prompt'); + + manager.destroy(); + + expect(wrapper.removeClass).toHaveBeenCalledWith('claudian-input-instruction-mode'); + expect(inputEl.placeholder).toBe('Ask...'); + }); +}); diff --git a/tests/unit/features/chat/ui/NavigationSidebar.test.ts b/tests/unit/features/chat/ui/NavigationSidebar.test.ts new file mode 100644 index 0000000..f637264 --- /dev/null +++ b/tests/unit/features/chat/ui/NavigationSidebar.test.ts @@ -0,0 +1,869 @@ +import { NavigationSidebar } from '@/features/chat/ui/NavigationSidebar'; + +// Mock obsidian +jest.mock('obsidian', () => ({ + setIcon: jest.fn((el: any, iconName: string) => { + el.setAttribute('data-icon', iconName); + }), +})); + +type Listener = (event: any) => void; + +class MockClassList { + private classes = new Set(); + + add(...items: string[]): void { + items.forEach((item) => this.classes.add(item)); + } + + remove(...items: string[]): void { + items.forEach((item) => this.classes.delete(item)); + } + + contains(item: string): boolean { + return this.classes.has(item); + } + + toggle(item: string, force?: boolean): void { + if (force === undefined) { + if (this.classes.has(item)) { + this.classes.delete(item); + } else { + this.classes.add(item); + } + return; + } + if (force) { + this.classes.add(item); + } else { + this.classes.delete(item); + } + } + + clear(): void { + this.classes.clear(); + } + + toArray(): string[] { + return Array.from(this.classes); + } +} + +class MockElement { + tagName: string; + classList = new MockClassList(); + style: Record = {}; + ownerDocument: { defaultView: Window | null }; + children: MockElement[] = []; + attributes: Record = {}; + dataset: Record = {}; + parent: MockElement | null = null; + textContent = ''; + private _scrollTop = 0; + private _scrollHeight = 500; + private _clientHeight = 500; + private listeners: Record = {}; + public scrollToCalls: Array<{ top: number; behavior: string }> = []; + + offsetTop = 0; + + constructor(tagName: string) { + this.tagName = tagName.toUpperCase(); + this.ownerDocument = { + defaultView: (globalThis as { window?: Window }).window ?? null, + }; + } + + set className(value: string) { + this.classList.clear(); + value.split(/\s+/).filter(Boolean).forEach((cls) => this.classList.add(cls)); + } + + get className(): string { + return this.classList.toArray().join(' '); + } + + get scrollHeight(): number { + return this._scrollHeight; + } + + set scrollHeight(value: number) { + this._scrollHeight = value; + } + + get clientHeight(): number { + return this._clientHeight; + } + + set clientHeight(value: number) { + this._clientHeight = value; + } + + get scrollTop(): number { + return this._scrollTop; + } + + set scrollTop(value: number) { + this._scrollTop = value; + } + + scrollTo(options: { top: number; behavior: string }): void { + this.scrollToCalls.push(options); + this._scrollTop = options.top; + } + + appendChild(child: MockElement): MockElement { + child.parent = this; + this.children.push(child); + return child; + } + + remove(): void { + if (!this.parent) return; + this.parent.children = this.parent.children.filter((child) => child !== this); + this.parent = null; + } + + setAttribute(name: string, value: string): void { + this.attributes[name] = value; + } + + getAttribute(name: string): string | null { + return this.attributes[name] ?? null; + } + + addEventListener(type: string, listener: Listener, _options?: any): void { + if (!this.listeners[type]) { + this.listeners[type] = []; + } + this.listeners[type].push(listener); + } + + removeEventListener(type: string, listener: Listener): void { + if (!this.listeners[type]) return; + this.listeners[type] = this.listeners[type].filter((l) => l !== listener); + } + + dispatchEvent(event: any): void { + const listeners = this.listeners[event.type] || []; + for (const listener of listeners) { + listener(event); + } + } + + click(): void { + this.dispatchEvent({ type: 'click', stopPropagation: jest.fn(), preventDefault: jest.fn() }); + } + + empty(): void { + this.children = []; + this.textContent = ''; + } + + createDiv(options?: { cls?: string; text?: string; attr?: Record }): MockElement { + const el = new MockElement('div'); + if (options?.cls) el.className = options.cls; + if (options?.text) el.textContent = options.text; + if (options?.attr) { + for (const [key, value] of Object.entries(options.attr)) { + el.setAttribute(key, value); + } + } + this.appendChild(el); + return el; + } + + querySelector(selector: string): MockElement | null { + return this.querySelectorAll(selector)[0] || null; + } + + querySelectorAll(selector: string): MockElement[] { + const matches: MockElement[] = []; + const selectors = selector.split(',').map((part) => part.trim()).filter(Boolean); + const matchesSelector = (el: MockElement, singleSelector: string): boolean => { + if (singleSelector.startsWith('.')) { + const className = singleSelector.slice(1); + return el.classList.contains(className); + } + const attributeMatch = singleSelector.match(/^\[([^=\]]+)="([^"]+)"\]$/); + if (attributeMatch) { + return el.getAttribute(attributeMatch[1]) === attributeMatch[2]; + } + return false; + }; + const traverse = (el: MockElement): void => { + if (selectors.some((singleSelector) => matchesSelector(el, singleSelector))) { + matches.push(el); + } + for (const child of el.children) { + traverse(child); + } + }; + traverse(this); + return matches; + } +} + +describe('NavigationSidebar', () => { + let parentEl: MockElement; + let messagesEl: MockElement; + let sidebar: NavigationSidebar; + let originalWindow: Window | undefined; + let originalMutationObserver: typeof MutationObserver | undefined; + let mutationCallback: MutationCallback | null; + + beforeEach(() => { + jest.useFakeTimers(); + originalWindow = (globalThis as { window?: Window }).window; + originalMutationObserver = (globalThis as { MutationObserver?: typeof MutationObserver }).MutationObserver; + mutationCallback = null; + Object.defineProperty(globalThis, 'window', { + value: { + requestAnimationFrame: (callback: FrameRequestCallback): number => + globalThis.setTimeout(() => callback(performance.now()), 16) as unknown as number, + cancelAnimationFrame: (handle: number): void => { + globalThis.clearTimeout(handle as unknown as ReturnType); + }, + setTimeout: (callback: () => void, timeout: number): number => + globalThis.setTimeout(callback, timeout) as unknown as number, + clearTimeout: (handle: number): void => { + globalThis.clearTimeout(handle as unknown as ReturnType); + }, + } as Window, + configurable: true, + }); + Object.defineProperty(globalThis, 'MutationObserver', { + value: class MockMutationObserver { + constructor(callback: MutationCallback) { + mutationCallback = callback; + } + + observe(): void {} + disconnect(): void {} + takeRecords(): MutationRecord[] { + return []; + } + } as unknown as typeof MutationObserver, + configurable: true, + }); + parentEl = new MockElement('div'); + messagesEl = new MockElement('div'); + parentEl.appendChild(messagesEl); + }); + + afterEach(() => { + sidebar?.destroy(); + if (originalWindow === undefined) { + delete (globalThis as { window?: Window }).window; + } else { + Object.defineProperty(globalThis, 'window', { + value: originalWindow, + configurable: true, + }); + } + if (originalMutationObserver === undefined) { + delete (globalThis as { MutationObserver?: typeof MutationObserver }).MutationObserver; + } else { + Object.defineProperty(globalThis, 'MutationObserver', { + value: originalMutationObserver, + configurable: true, + }); + } + jest.useRealTimers(); + }); + + describe('initialization', () => { + it('should create container with correct class', () => { + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const container = parentEl.querySelector('.claudian-nav-sidebar'); + expect(container).not.toBeNull(); + }); + + it('should create five navigation buttons', () => { + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const container = parentEl.querySelector('.claudian-nav-sidebar'); + expect(container).not.toBeNull(); + expect(container!.children.length).toBe(5); + }); + + it('should set correct aria-labels on buttons', () => { + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const container = parentEl.querySelector('.claudian-nav-sidebar'); + const buttons = container!.children; + + expect(buttons[0].getAttribute('aria-label')).toBe('Scroll to top'); + expect(buttons[1].getAttribute('aria-label')).toBe('Previous message'); + expect(buttons[2].getAttribute('aria-label')).toBe('Conversation directory'); + expect(buttons[3].getAttribute('aria-label')).toBe('Next message'); + expect(buttons[4].getAttribute('aria-label')).toBe('Scroll to bottom'); + }); + + it('should set correct icons on buttons', () => { + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const container = parentEl.querySelector('.claudian-nav-sidebar'); + const buttons = container!.children; + + expect(buttons[0].getAttribute('data-icon')).toBe('chevrons-up'); + expect(buttons[1].getAttribute('data-icon')).toBe('chevron-up'); + expect(buttons[2].getAttribute('data-icon')).toBe('list-tree'); + expect(buttons[3].getAttribute('data-icon')).toBe('chevron-down'); + expect(buttons[4].getAttribute('data-icon')).toBe('chevrons-down'); + }); + }); + + describe('visibility', () => { + it('should be hidden when content does not overflow', () => { + messagesEl.scrollHeight = 500; + messagesEl.clientHeight = 500; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const container = parentEl.querySelector('.claudian-nav-sidebar'); + expect(container!.classList.contains('visible')).toBe(false); + }); + + it('should be visible when content overflows', () => { + messagesEl.scrollHeight = 1000; + messagesEl.clientHeight = 500; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const container = parentEl.querySelector('.claudian-nav-sidebar'); + expect(container!.classList.contains('visible')).toBe(true); + }); + + it('should update visibility when updateVisibility is called', () => { + messagesEl.scrollHeight = 500; + messagesEl.clientHeight = 500; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const container = parentEl.querySelector('.claudian-nav-sidebar'); + expect(container!.classList.contains('visible')).toBe(false); + + // Simulate content growth + messagesEl.scrollHeight = 1000; + sidebar.updateVisibility(); + jest.advanceTimersByTime(16); + + expect(container!.classList.contains('visible')).toBe(true); + }); + + it('should batch visibility updates until the next animation frame', () => { + messagesEl.scrollHeight = 500; + messagesEl.clientHeight = 500; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const container = parentEl.querySelector('.claudian-nav-sidebar'); + messagesEl.scrollHeight = 1000; + sidebar.updateVisibility(); + sidebar.updateVisibility(); + + expect(container!.classList.contains('visible')).toBe(false); + + jest.advanceTimersByTime(16); + + expect(container!.classList.contains('visible')).toBe(true); + }); + }); + + describe('scroll to top button', () => { + it('should scroll to top when clicked', () => { + messagesEl.scrollHeight = 1000; + messagesEl.clientHeight = 500; + messagesEl.scrollTop = 500; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const container = parentEl.querySelector('.claudian-nav-sidebar'); + const topBtn = container!.children[0]; + topBtn.click(); + + expect(messagesEl.scrollToCalls.length).toBe(1); + expect(messagesEl.scrollToCalls[0].top).toBe(0); + expect(messagesEl.scrollToCalls[0].behavior).toBe('smooth'); + }); + }); + + describe('scroll to bottom button', () => { + it('should scroll to bottom when clicked', () => { + messagesEl.scrollHeight = 1000; + messagesEl.clientHeight = 500; + messagesEl.scrollTop = 0; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const container = parentEl.querySelector('.claudian-nav-sidebar'); + const bottomBtn = container!.children[4]; + bottomBtn.click(); + + expect(messagesEl.scrollToCalls.length).toBe(1); + expect(messagesEl.scrollToCalls[0].top).toBe(1000); + expect(messagesEl.scrollToCalls[0].behavior).toBe('smooth'); + }); + }); + + describe('previous/next message navigation', () => { + function addUserMessage(el: MockElement, offset: number): MockElement { + const msg = el.createDiv({ cls: 'claudian-message claudian-message-user' }); + msg.offsetTop = offset; + return msg; + } + + function addAssistantMessage(el: MockElement, offset: number): MockElement { + const msg = el.createDiv({ cls: 'claudian-message claudian-message-assistant' }); + msg.offsetTop = offset; + return msg; + } + + function addConversation(el: MockElement, userOffsets: number[], assistantOffsets: number[]): void { + // Interleave user and assistant messages in order + const all = [ + ...userOffsets.map(o => ({ offset: o, role: 'user' as const })), + ...assistantOffsets.map(o => ({ offset: o, role: 'assistant' as const })), + ].sort((a, b) => a.offset - b.offset); + for (const m of all) { + if (m.role === 'user') addUserMessage(el, m.offset); + else addAssistantMessage(el, m.offset); + } + } + + function getButtons(parent: MockElement) { + const container = parent.querySelector('.claudian-nav-sidebar')!; + return { + prev: container.children[1], + next: container.children[3], + }; + } + + it('should scroll to next user message below current scroll position', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + // user@0, assistant@100, user@400, assistant@500, user@800 + addConversation(messagesEl, [0, 400, 800], [100, 500]); + messagesEl.scrollTop = 0; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const { next } = getButtons(parentEl); + next.click(); + + const lastCall = messagesEl.scrollToCalls[messagesEl.scrollToCalls.length - 1]; + // Should skip assistant@100 and go to user@400 + expect(lastCall.top).toBe(390); // offsetTop(400) - 10 + expect(lastCall.behavior).toBe('smooth'); + }); + + it('should scroll to previous user message above current scroll position', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + addConversation(messagesEl, [0, 400, 800], [100, 500]); + messagesEl.scrollTop = 800; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const { prev } = getButtons(parentEl); + prev.click(); + + const lastCall = messagesEl.scrollToCalls[messagesEl.scrollToCalls.length - 1]; + // Should skip assistant@500 and go to user@400 + expect(lastCall.top).toBe(390); // offsetTop(400) - 10 + expect(lastCall.behavior).toBe('smooth'); + }); + + it('should not require double-click when scrolled to a user message', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + addConversation(messagesEl, [0, 400, 800], [100, 500]); + // Scrolled to user message at offset 400 (scroll position = 390) + messagesEl.scrollTop = 390; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const { next } = getButtons(parentEl); + next.click(); + + const lastCall = messagesEl.scrollToCalls[messagesEl.scrollToCalls.length - 1]; + // Should go to user@800, not stay at user@400 + expect(lastCall.top).toBe(790); // offsetTop(800) - 10 + }); + + it('should not require double-click for prev when scrolled to a user message', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + addConversation(messagesEl, [0, 400, 800], [100, 500]); + // Scrolled to user message at offset 800 + messagesEl.scrollTop = 790; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const { prev } = getButtons(parentEl); + prev.click(); + + const lastCall = messagesEl.scrollToCalls[messagesEl.scrollToCalls.length - 1]; + // Should go to user@400, not stay at user@800 + expect(lastCall.top).toBe(390); // offsetTop(400) - 10 + }); + + it('should scroll to bottom when at the last user message and next is clicked', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + addConversation(messagesEl, [0, 400, 800], [100, 500]); + messagesEl.scrollTop = 790; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const { next } = getButtons(parentEl); + next.click(); + + const lastCall = messagesEl.scrollToCalls[messagesEl.scrollToCalls.length - 1]; + expect(lastCall.top).toBe(2000); + }); + + it('should scroll to top when at the first user message and prev is clicked', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + addConversation(messagesEl, [0, 400, 800], [100, 500]); + messagesEl.scrollTop = 0; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const { prev } = getButtons(parentEl); + prev.click(); + + const lastCall = messagesEl.scrollToCalls[messagesEl.scrollToCalls.length - 1]; + expect(lastCall.top).toBe(0); + }); + }); + + describe('conversation directory', () => { + function addMessage( + el: MockElement, + role: 'user' | 'assistant', + offset: number, + tocTitle?: string + ): MockElement { + const msg = el.createDiv({ cls: `claudian-message claudian-message-${role}` }); + msg.offsetTop = offset; + if (tocTitle) { + msg.setAttribute('data-toc-title', tocTitle); + } + return msg; + } + + function getDirectoryButton(parent: MockElement): MockElement { + const container = parent.querySelector('.claudian-nav-sidebar')!; + return container.children[2]; + } + + it('should show an empty directory state when there are no user messages', () => { + messagesEl.scrollHeight = 1000; + messagesEl.clientHeight = 500; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const directoryBtn = getDirectoryButton(parentEl); + expect(directoryBtn.classList.contains('claudian-hidden')).toBe(false); + + directoryBtn.click(); + + const popover = parentEl.querySelector('.claudian-nav-toc-popover'); + const emptyState = parentEl.querySelector('.claudian-nav-toc-empty'); + expect(popover).not.toBeNull(); + expect(emptyState?.textContent).toBe('No user prompts in this conversation'); + }); + + it('should render directory entries for user messages only', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + addMessage(messagesEl, 'user', 0, 'First prompt'); + addMessage(messagesEl, 'assistant', 120, 'Assistant should not appear'); + addMessage(messagesEl, 'user', 400, 'Second prompt'); + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + getDirectoryButton(parentEl).click(); + + const popover = parentEl.querySelector('.claudian-nav-toc-popover'); + const items = parentEl.querySelectorAll('.claudian-nav-toc-item'); + expect(popover).not.toBeNull(); + expect(items).toHaveLength(2); + expect(items[0].textContent).toBe('1. First prompt'); + expect(items[1].textContent).toBe('2. Second prompt'); + }); + + it('should fall back to visible user message text when toc metadata is missing', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + const userMsg = addMessage(messagesEl, 'user', 0); + userMsg.createDiv({ + cls: 'claudian-message-content', + text: 'Legacy prompt title\nsecond line', + }); + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const directoryBtn = getDirectoryButton(parentEl); + expect(directoryBtn.classList.contains('claudian-hidden')).toBe(false); + + directoryBtn.click(); + + const items = parentEl.querySelectorAll('.claudian-nav-toc-item'); + expect(items).toHaveLength(1); + expect(items[0].textContent).toBe('1. Legacy prompt title'); + }); + + it('should include user messages marked by data-role when class lookup misses', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + const userMsg = messagesEl.createDiv({ + cls: 'claudian-message', + attr: { 'data-role': 'user' }, + }); + userMsg.offsetTop = 120; + userMsg.createDiv({ + cls: 'claudian-message-content', + text: 'Role based prompt', + }); + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + getDirectoryButton(parentEl).click(); + + const items = parentEl.querySelectorAll('.claudian-nav-toc-item'); + expect(items).toHaveLength(1); + expect(items[0].textContent).toBe('1. Role based prompt'); + }); + + it('should scroll to a selected directory entry and close the directory', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + addMessage(messagesEl, 'user', 0, 'First prompt'); + addMessage(messagesEl, 'user', 400, 'Second prompt'); + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + getDirectoryButton(parentEl).click(); + const items = parentEl.querySelectorAll('.claudian-nav-toc-item'); + items[1].click(); + + const lastCall = messagesEl.scrollToCalls[messagesEl.scrollToCalls.length - 1]; + expect(lastCall.top).toBe(390); + expect(lastCall.behavior).toBe('smooth'); + expect(parentEl.querySelector('.claudian-nav-toc-popover')).toBeNull(); + }); + + it('should close the directory when the directory button is clicked again', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + addMessage(messagesEl, 'user', 0, 'First prompt'); + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const directoryBtn = getDirectoryButton(parentEl); + directoryBtn.click(); + expect(parentEl.querySelector('.claudian-nav-toc-popover')).not.toBeNull(); + + directoryBtn.click(); + expect(parentEl.querySelector('.claudian-nav-toc-popover')).toBeNull(); + }); + + it('should keep the directory button visible when message DOM changes', () => { + messagesEl.scrollHeight = 1000; + messagesEl.clientHeight = 500; + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const directoryBtn = getDirectoryButton(parentEl); + expect(directoryBtn.classList.contains('claudian-hidden')).toBe(false); + + addMessage(messagesEl, 'user', 0, 'New prompt'); + mutationCallback?.([], {} as MutationObserver); + jest.advanceTimersByTime(16); + + expect(directoryBtn.classList.contains('claudian-hidden')).toBe(false); + }); + + it('should refresh an open directory to an empty state when user messages are removed', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + const userMsg = addMessage(messagesEl, 'user', 0, 'First prompt'); + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const directoryBtn = getDirectoryButton(parentEl); + directoryBtn.click(); + expect(parentEl.querySelector('.claudian-nav-toc-popover')).not.toBeNull(); + + messagesEl.empty(); + mutationCallback?.([ + { + type: 'childList', + target: messagesEl, + addedNodes: [], + removedNodes: [userMsg], + } as unknown as MutationRecord, + ], {} as MutationObserver); + jest.advanceTimersByTime(16); + + const emptyState = parentEl.querySelector('.claudian-nav-toc-empty'); + expect(directoryBtn.classList.contains('claudian-hidden')).toBe(false); + expect(parentEl.querySelector('.claudian-nav-toc-popover')).not.toBeNull(); + expect(emptyState?.textContent).toBe('No user prompts in this conversation'); + }); + + it('should not rebuild an open directory for assistant content mutations', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + addMessage(messagesEl, 'user', 0, 'First prompt'); + const assistantMsg = addMessage(messagesEl, 'assistant', 120); + const assistantContent = assistantMsg.createDiv({ cls: 'claudian-message-content' }); + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + const directoryBtn = getDirectoryButton(parentEl); + directoryBtn.click(); + const originalPopover = parentEl.querySelector('.claudian-nav-toc-popover'); + expect(originalPopover).not.toBeNull(); + + const assistantChunk = assistantContent.createDiv({ text: 'Streaming response chunk' }); + mutationCallback?.([ + { + type: 'childList', + target: assistantContent, + addedNodes: [assistantChunk], + removedNodes: [], + } as unknown as MutationRecord, + ], {} as MutationObserver); + jest.advanceTimersByTime(16); + + expect(parentEl.querySelector('.claudian-nav-toc-popover')).toBe(originalPopover); + }); + + it('should refresh an open directory when a user message toc title changes', () => { + messagesEl.scrollHeight = 2000; + messagesEl.clientHeight = 500; + const userMsg = addMessage(messagesEl, 'user', 0, 'First prompt'); + + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + getDirectoryButton(parentEl).click(); + const originalPopover = parentEl.querySelector('.claudian-nav-toc-popover'); + expect(originalPopover).not.toBeNull(); + + userMsg.setAttribute('data-toc-title', 'Updated prompt'); + mutationCallback?.([ + { + type: 'attributes', + target: userMsg, + attributeName: 'data-toc-title', + } as unknown as MutationRecord, + ], {} as MutationObserver); + jest.advanceTimersByTime(16); + + const updatedPopover = parentEl.querySelector('.claudian-nav-toc-popover'); + const items = parentEl.querySelectorAll('.claudian-nav-toc-item'); + expect(updatedPopover).not.toBe(originalPopover); + expect(items).toHaveLength(1); + expect(items[0].textContent).toBe('1. Updated prompt'); + }); + }); + + describe('destroy', () => { + it('should remove container from DOM', () => { + sidebar = new NavigationSidebar( + parentEl as unknown as HTMLElement, + messagesEl as unknown as HTMLElement + ); + + expect(parentEl.querySelector('.claudian-nav-sidebar')).not.toBeNull(); + + sidebar.destroy(); + + expect(parentEl.querySelector('.claudian-nav-sidebar')).toBeNull(); + }); + }); +}); diff --git a/tests/unit/features/chat/ui/StatusPanel.test.ts b/tests/unit/features/chat/ui/StatusPanel.test.ts new file mode 100644 index 0000000..98519b0 --- /dev/null +++ b/tests/unit/features/chat/ui/StatusPanel.test.ts @@ -0,0 +1,964 @@ +import type { TodoItem } from '@/core/tools/todo'; +import { StatusPanel } from '@/features/chat/ui/StatusPanel'; + +// Mock obsidian +jest.mock('obsidian', () => ({ + setIcon: jest.fn((el: any, iconName: string) => { + el.setAttribute('data-icon', iconName); + }), + Notice: jest.fn(), +})); + +type Listener = (event: any) => void; + +class MockClassList { + private classes = new Set(); + + constructor(private onChange: () => void = () => {}) {} + + add(...items: string[]): void { + items.forEach((item) => this.classes.add(item)); + this.onChange(); + } + + remove(...items: string[]): void { + items.forEach((item) => this.classes.delete(item)); + this.onChange(); + } + + contains(item: string): boolean { + return this.classes.has(item); + } + + has(item: string): boolean { + return this.classes.has(item); + } + + toggle(item: string, force?: boolean): void { + if (force === undefined) { + if (this.classes.has(item)) { + this.classes.delete(item); + } else { + this.classes.add(item); + } + this.onChange(); + return; + } + if (force) { + this.classes.add(item); + } else { + this.classes.delete(item); + } + this.onChange(); + } + + clear(): void { + this.classes.clear(); + this.onChange(); + } + + toArray(): string[] { + return Array.from(this.classes); + } +} + +class MockElement { + tagName: string; + classList: MockClassList; + style: Record = {}; + children: MockElement[] = []; + attributes: Record = {}; + dataset: Record = {}; + parent: MockElement | null = null; + textContent = ''; + private _scrollTop = 0; + private listeners: Record = {}; + + constructor(tagName: string) { + this.tagName = tagName.toUpperCase(); + this.classList = new MockClassList(() => this.syncDisplay()); + } + + set className(value: string) { + this.classList.clear(); + value.split(/\s+/).filter(Boolean).forEach((cls) => this.classList.add(cls)); + this.syncDisplay(); + } + + get className(): string { + return this.classList.toArray().join(' '); + } + + get scrollHeight(): number { + return 1000; + } + + get scrollTop(): number { + return this._scrollTop; + } + + set scrollTop(_value: number) { + this._scrollTop = _value; + } + + get ownerDocument(): any { + return (global as any).document; + } + + appendChild(child: MockElement): MockElement { + child.parent = this; + this.children.push(child); + return child; + } + + addClass(cls: string): void { + cls.split(/\s+/).filter(Boolean).forEach((item) => this.classList.add(item)); + } + + removeClass(cls: string): void { + cls.split(/\s+/).filter(Boolean).forEach((item) => this.classList.remove(item)); + } + + toggleClass(cls: string, force: boolean): void { + this.classList.toggle(cls, force); + } + + hasClass(cls: string): boolean { + return this.classList.has(cls); + } + + remove(): void { + if (!this.parent) return; + this.parent.children = this.parent.children.filter((child) => child !== this); + this.parent = null; + } + + setAttribute(name: string, value: string): void { + this.attributes[name] = value; + } + + getAttribute(name: string): string | null { + // Check attributes first + if (this.attributes[name] !== undefined) { + return this.attributes[name]; + } + // For data-* attributes, also check dataset + if (name.startsWith('data-')) { + const dataKey = name.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + return this.dataset[dataKey] ?? null; + } + return null; + } + + addEventListener(type: string, listener: Listener): void { + if (!this.listeners[type]) { + this.listeners[type] = []; + } + this.listeners[type].push(listener); + } + + removeEventListener(type: string, listener: Listener): void { + if (!this.listeners[type]) return; + this.listeners[type] = this.listeners[type].filter((l) => l !== listener); + } + + dispatchEvent(event: any): void { + const listeners = this.listeners[event.type] || []; + for (const listener of listeners) { + listener(event); + } + } + + click(): void { + this.dispatchEvent({ type: 'click', stopPropagation: jest.fn(), preventDefault: jest.fn() }); + } + + empty(): void { + this.children = []; + this.textContent = ''; + } + + private syncDisplay(): void { + if (this.classList.has('claudian-hidden')) { + this.style.display = 'none'; + return; + } + if ( + this.classList.has('claudian-status-panel-todos') + || this.classList.has('claudian-status-panel-content') + || this.classList.has('claudian-status-panel-bash') + || this.classList.has('claudian-status-panel-bash-content') + || this.classList.has('claudian-tool-content') + ) { + this.style.display = 'block'; + return; + } + this.style.display = ''; + } + + // Obsidian-style helper methods + createDiv(options?: { cls?: string; text?: string }): MockElement { + const el = new MockElement('div'); + if (options?.cls) el.className = options.cls; + if (options?.text) el.textContent = options.text; + this.appendChild(el); + return el; + } + + createSpan(options?: { cls?: string; text?: string }): MockElement { + const el = new MockElement('span'); + if (options?.cls) el.className = options.cls; + if (options?.text) el.textContent = options.text; + this.appendChild(el); + return el; + } + + setText(text: string): void { + this.textContent = text; + } + + querySelector(selector: string): MockElement | null { + return this.querySelectorAll(selector)[0] || null; + } + + querySelectorAll(selector: string): MockElement[] { + const matches: MockElement[] = []; + const match = (el: MockElement): boolean => { + // Handle attribute selectors like [data-icon] + const attrMatch = selector.match(/\[([a-zA-Z0-9_-]+)\]/); + if (attrMatch) { + const attrName = attrMatch[1]; + // Convert data-* attributes to dataset keys (data-foo-bar -> fooBar) + if (attrName.startsWith('data-')) { + const dataKey = attrName.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase()); + return el.dataset[dataKey] !== undefined; + } + return el.attributes[attrName] !== undefined; + } + + // Handle class selectors like .claudian-status-panel + const classMatch = selector.match(/\.([a-zA-Z0-9_-]+)/g); + if (classMatch) { + for (const cls of classMatch) { + const className = cls.slice(1); + if (!el.classList.has(className)) { + return false; + } + } + } + return classMatch !== null; + }; + const walk = (el: MockElement) => { + if (match(el)) { + matches.push(el); + } + for (const child of el.children) { + walk(child); + } + }; + for (const child of this.children) { + walk(child); + } + return matches; + } +} + +function createMockDocument() { + return { + createElement: (tag: string) => new MockElement(tag), + }; +} + +describe('StatusPanel', () => { + let containerEl: MockElement; + let panel: StatusPanel; + let originalDocument: any; + let originalNavigator: any; + let writeTextMock: jest.Mock; + + beforeEach(() => { + originalDocument = (global as any).document; + originalNavigator = (global as any).navigator; + (global as any).document = createMockDocument(); + writeTextMock = jest.fn().mockResolvedValue(undefined); + (global as any).navigator = { clipboard: { writeText: writeTextMock } }; + containerEl = new MockElement('div'); + panel = new StatusPanel(); + }); + + afterEach(() => { + panel.destroy(); + (global as any).document = originalDocument; + (global as any).navigator = originalNavigator; + }); + + describe('mount', () => { + it('should create panel element when mounted', () => { + panel.mount(containerEl as unknown as HTMLElement); + + expect(containerEl.querySelector('.claudian-status-panel')).not.toBeNull(); + }); + + it('should create hidden todo container initially', () => { + panel.mount(containerEl as unknown as HTMLElement); + + const todoContainer = containerEl.querySelector('.claudian-status-panel-todos'); + expect(todoContainer).not.toBeNull(); + expect(todoContainer!.style.display).toBe('none'); + }); + + it('should not reserve panel spacing before content is shown', () => { + panel.mount(containerEl as unknown as HTMLElement); + + const panelEl = containerEl.querySelector('.claudian-status-panel'); + expect(panelEl?.hasClass('claudian-status-panel--visible')).toBe(false); + }); + }); + + describe('updateTodos', () => { + beforeEach(() => { + panel.mount(containerEl as unknown as HTMLElement); + }); + + it('should show panel when todos are provided', () => { + const todos: TodoItem[] = [ + { content: 'Task 1', status: 'pending', activeForm: 'Doing Task 1' }, + ]; + + panel.updateTodos(todos); + + const todoContainer = containerEl.querySelector('.claudian-status-panel-todos'); + expect(todoContainer!.style.display).toBe('block'); + expect(containerEl.querySelector('.claudian-status-panel') + ?.hasClass('claudian-status-panel--visible')).toBe(true); + }); + + it('should hide panel when todos is null', () => { + const todos: TodoItem[] = [ + { content: 'Task 1', status: 'pending', activeForm: 'Doing Task 1' }, + ]; + + panel.updateTodos(todos); + panel.updateTodos(null); + + const todoContainer = containerEl.querySelector('.claudian-status-panel-todos'); + expect(todoContainer!.style.display).toBe('none'); + expect(containerEl.querySelector('.claudian-status-panel') + ?.hasClass('claudian-status-panel--visible')).toBe(false); + }); + + it('should hide panel when todos is empty array', () => { + const todos: TodoItem[] = [ + { content: 'Task 1', status: 'pending', activeForm: 'Doing Task 1' }, + ]; + + panel.updateTodos(todos); + panel.updateTodos([]); + + const todoContainer = containerEl.querySelector('.claudian-status-panel-todos'); + expect(todoContainer!.style.display).toBe('none'); + }); + + it('should display correct task count', () => { + const todos: TodoItem[] = [ + { content: 'Task 1', status: 'completed', activeForm: 'Doing Task 1' }, + { content: 'Task 2', status: 'pending', activeForm: 'Doing Task 2' }, + { content: 'Task 3', status: 'in_progress', activeForm: 'Working on Task 3' }, + ]; + + panel.updateTodos(todos); + + const label = containerEl.querySelector('.claudian-status-panel-label'); + expect(label?.textContent).toBe('Tasks (1/3)'); + }); + + it('should show current task in collapsed header', () => { + const todos: TodoItem[] = [ + { content: 'Task 1', status: 'pending', activeForm: 'Doing Task 1' }, + { content: 'Task 2', status: 'in_progress', activeForm: 'Working on Task 2' }, + ]; + + panel.updateTodos(todos); + + const current = containerEl.querySelector('.claudian-status-panel-current'); + expect(current?.textContent).toBe('Working on Task 2'); + }); + + it('should render all todo items in content area', () => { + const todos: TodoItem[] = [ + { content: 'Task 1', status: 'pending', activeForm: 'Doing Task 1' }, + { content: 'Task 2', status: 'completed', activeForm: 'Doing Task 2' }, + ]; + + panel.updateTodos(todos); + + const items = containerEl.querySelectorAll('.claudian-todo-item'); + expect(items.length).toBe(2); + }); + + it('should apply correct status classes to items', () => { + const todos: TodoItem[] = [ + { content: 'Task 1', status: 'pending', activeForm: 'Task 1' }, + { content: 'Task 2', status: 'in_progress', activeForm: 'Task 2' }, + { content: 'Task 3', status: 'completed', activeForm: 'Task 3' }, + ]; + + panel.updateTodos(todos); + + expect(containerEl.querySelector('.claudian-todo-pending')).not.toBeNull(); + expect(containerEl.querySelector('.claudian-todo-in_progress')).not.toBeNull(); + expect(containerEl.querySelector('.claudian-todo-completed')).not.toBeNull(); + }); + + it('should handle updateTodos called before mount with todos to display', () => { + const unmountedPanel = new StatusPanel(); + + // Should not throw, just silently handle unmounted state + expect(() => { + unmountedPanel.updateTodos([{ content: 'Task', status: 'pending', activeForm: 'Task' }]); + }).not.toThrow(); + }); + + it('should handle updateTodos called with null before mount', () => { + const unmountedPanel = new StatusPanel(); + + // Should not throw + expect(() => { + unmountedPanel.updateTodos(null); + }).not.toThrow(); + }); + }); + + describe('toggle', () => { + beforeEach(() => { + panel.mount(containerEl as unknown as HTMLElement); + panel.updateTodos([ + { content: 'Task 1', status: 'in_progress', activeForm: 'Doing Task 1' }, + ]); + }); + + it('should expand content on header click', () => { + const header = containerEl.querySelector('.claudian-status-panel-header'); + const content = containerEl.querySelector('.claudian-status-panel-content'); + + expect(content!.style.display).toBe('none'); + + header!.click(); + + expect(content!.style.display).toBe('block'); + }); + + it('should collapse content on second click', () => { + const header = containerEl.querySelector('.claudian-status-panel-header'); + const content = containerEl.querySelector('.claudian-status-panel-content'); + + header!.click(); + expect(content!.style.display).toBe('block'); + + header!.click(); + expect(content!.style.display).toBe('none'); + }); + + it('should show list icon in header', () => { + const icon = containerEl.querySelector('.claudian-status-panel-icon'); + expect(icon).not.toBeNull(); + expect(icon?.getAttribute('data-icon')).toBe('list-checks'); + }); + + it('should hide current task when expanded', () => { + const header = containerEl.querySelector('.claudian-status-panel-header'); + + expect(containerEl.querySelector('.claudian-status-panel-current')).not.toBeNull(); + + header!.click(); + + expect(containerEl.querySelector('.claudian-status-panel-current')).toBeNull(); + }); + + it('should toggle on Enter key', () => { + const header = containerEl.querySelector('.claudian-status-panel-header'); + const content = containerEl.querySelector('.claudian-status-panel-content'); + + const event = { type: 'keydown', key: 'Enter', preventDefault: jest.fn() }; + header!.dispatchEvent(event); + + expect(content!.style.display).toBe('block'); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should toggle on Space key', () => { + const header = containerEl.querySelector('.claudian-status-panel-header'); + const content = containerEl.querySelector('.claudian-status-panel-content'); + + const event = { type: 'keydown', key: ' ', preventDefault: jest.fn() }; + header!.dispatchEvent(event); + + expect(content!.style.display).toBe('block'); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('should not toggle on other keys', () => { + const header = containerEl.querySelector('.claudian-status-panel-header'); + const content = containerEl.querySelector('.claudian-status-panel-content'); + + const event = { type: 'keydown', key: 'Tab', preventDefault: jest.fn() }; + header!.dispatchEvent(event); + + expect(content!.style.display).toBe('none'); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + }); + + describe('accessibility', () => { + beforeEach(() => { + panel.mount(containerEl as unknown as HTMLElement); + }); + + it('should set tabindex on header', () => { + const header = containerEl.querySelector('.claudian-status-panel-header'); + expect(header?.getAttribute('tabindex')).toBe('0'); + }); + + it('should set role button on header', () => { + const header = containerEl.querySelector('.claudian-status-panel-header'); + expect(header?.getAttribute('role')).toBe('button'); + }); + + it('should update aria-expanded on toggle', () => { + panel.updateTodos([{ content: 'Task', status: 'pending', activeForm: 'Task' }]); + const header = containerEl.querySelector('.claudian-status-panel-header'); + + expect(header!.getAttribute('aria-expanded')).toBe('false'); + + header!.click(); + expect(header!.getAttribute('aria-expanded')).toBe('true'); + + header!.click(); + expect(header!.getAttribute('aria-expanded')).toBe('false'); + }); + + it('should set descriptive aria-label', () => { + panel.updateTodos([ + { content: 'Task 1', status: 'completed', activeForm: 'Task 1' }, + { content: 'Task 2', status: 'pending', activeForm: 'Task 2' }, + ]); + + const header = containerEl.querySelector('.claudian-status-panel-header'); + expect(header?.getAttribute('aria-label')).toBe('Expand task list - 1 of 2 completed'); + }); + + it('should hide status icons from screen readers', () => { + panel.updateTodos([{ content: 'Task', status: 'pending', activeForm: 'Task' }]); + + const icon = containerEl.querySelector('.claudian-todo-status-icon'); + expect(icon?.getAttribute('aria-hidden')).toBe('true'); + }); + }); + + describe('remount', () => { + it('should re-create panel structure after remount', () => { + panel.mount(containerEl as unknown as HTMLElement); + + panel.updateTodos([ + { content: 'Task 1', status: 'completed', activeForm: 'Doing Task 1' }, + { content: 'Task 2', status: 'in_progress', activeForm: 'Doing Task 2' }, + ]); + + panel.remount(); + + expect(containerEl.querySelector('.claudian-status-panel')).not.toBeNull(); + const label = containerEl.querySelector('.claudian-status-panel-label'); + expect(label?.textContent).toBe('Tasks (1/2)'); + }); + + it('should not throw when called without mount', () => { + const unmountedPanel = new StatusPanel(); + expect(() => unmountedPanel.remount()).not.toThrow(); + }); + + it('should clean up event listeners before remounting', () => { + panel.mount(containerEl as unknown as HTMLElement); + panel.updateTodos([ + { content: 'Task 1', status: 'in_progress', activeForm: 'Doing Task 1' }, + ]); + + const header = containerEl.querySelector('.claudian-status-panel-header'); + header!.click(); + + panel.remount(); + + const content = containerEl.querySelector('.claudian-status-panel-content'); + expect(content!.style.display).toBe('none'); + }); + }); + + describe('completion status icon', () => { + beforeEach(() => { + panel.mount(containerEl as unknown as HTMLElement); + }); + + it('should show check icon when all todos are completed', () => { + panel.updateTodos([ + { content: 'Task 1', status: 'completed', activeForm: 'Task 1' }, + { content: 'Task 2', status: 'completed', activeForm: 'Task 2' }, + ]); + + const status = containerEl.querySelector('.status-completed'); + expect(status).not.toBeNull(); + expect(status?.getAttribute('data-icon')).toBe('check'); + }); + + it('should not show check icon when some todos are incomplete', () => { + panel.updateTodos([ + { content: 'Task 1', status: 'completed', activeForm: 'Task 1' }, + { content: 'Task 2', status: 'pending', activeForm: 'Task 2' }, + ]); + + const status = containerEl.querySelector('.status-completed'); + expect(status).toBeNull(); + }); + }); + + describe('destroy', () => { + it('should remove panel from DOM', () => { + panel.mount(containerEl as unknown as HTMLElement); + + expect(containerEl.querySelector('.claudian-status-panel')).not.toBeNull(); + + panel.destroy(); + + expect(containerEl.querySelector('.claudian-status-panel')).toBeNull(); + }); + + it('should be safe to call multiple times', () => { + panel.mount(containerEl as unknown as HTMLElement); + + expect(() => { + panel.destroy(); + panel.destroy(); + }).not.toThrow(); + }); + + it('should handle destroy without mount', () => { + const unmountedPanel = new StatusPanel(); + + expect(() => { + unmountedPanel.destroy(); + }).not.toThrow(); + }); + }); + + describe('bash outputs', () => { + beforeEach(() => { + panel.mount(containerEl as unknown as HTMLElement); + }); + + it('should render bash section with header and entries', () => { + panel.addBashOutput({ + id: 'bash-1', + command: 'echo hello', + status: 'completed', + output: 'hello', + exitCode: 0, + }); + + const bashContainer = containerEl.querySelector('.claudian-status-panel-bash'); + expect(bashContainer).not.toBeNull(); + expect(bashContainer!.style.display).toBe('block'); + + const header = containerEl.querySelector('.claudian-status-panel-bash-header'); + expect(header).not.toBeNull(); + const label = header!.querySelector('.claudian-tool-label'); + expect(label).not.toBeNull(); + expect(label!.textContent).toBe('Command panel'); + + const entries = containerEl.querySelectorAll('.claudian-status-panel-bash-entry'); + expect(entries.length).toBe(1); + }); + + it('should collapse and expand the bash section', () => { + panel.addBashOutput({ + id: 'bash-1', + command: 'echo hello', + status: 'completed', + output: 'hello', + exitCode: 0, + }); + + const content = containerEl.querySelector('.claudian-status-panel-bash-content'); + expect(content).not.toBeNull(); + expect(content!.style.display).toBe('block'); + + const header = containerEl.querySelector('.claudian-status-panel-bash-header'); + expect(header).not.toBeNull(); + const label = header!.querySelector('.claudian-tool-label'); + expect(label).not.toBeNull(); + expect(label!.textContent).toBe('Command panel'); + + header!.click(); + expect(content!.style.display).toBe('none'); + const collapsedHeader = containerEl.querySelector('.claudian-status-panel-bash-header'); + expect(collapsedHeader).not.toBeNull(); + const collapsedLabel = collapsedHeader!.querySelector('.claudian-tool-label'); + expect(collapsedLabel).not.toBeNull(); + expect(collapsedLabel!.textContent).toBe('echo hello'); + + header!.click(); + expect(content!.style.display).toBe('block'); + const expandedHeaderAgain = containerEl.querySelector('.claudian-status-panel-bash-header'); + expect(expandedHeaderAgain).not.toBeNull(); + const expandedLabelAgain = expandedHeaderAgain!.querySelector('.claudian-tool-label'); + expect(expandedLabelAgain).not.toBeNull(); + expect(expandedLabelAgain!.textContent).toBe('Command panel'); + }); + + it('should collapse and expand individual bash output entries', () => { + panel.addBashOutput({ + id: 'bash-1', + command: 'echo hello', + status: 'completed', + output: 'hello', + exitCode: 0, + }); + + const entry = containerEl.querySelector('.claudian-status-panel-bash-entry'); + expect(entry).not.toBeNull(); + + const entryHeader = entry!.querySelector('.claudian-tool-header'); + const entryContent = entry!.querySelector('.claudian-tool-content'); + + expect(entryContent).not.toBeNull(); + expect(entryContent!.style.display).toBe('block'); + expect(entryHeader!.getAttribute('aria-expanded')).toBe('true'); + + entryHeader!.click(); + + const entryAfterClick = containerEl.querySelector('.claudian-status-panel-bash-entry'); + const contentAfterClick = entryAfterClick!.querySelector('.claudian-tool-content'); + const headerAfterClick = entryAfterClick!.querySelector('.claudian-tool-header'); + + expect(contentAfterClick!.style.display).toBe('none'); + expect(headerAfterClick!.getAttribute('aria-expanded')).toBe('false'); + + const event = { type: 'keydown', key: 'Enter', preventDefault: jest.fn() }; + headerAfterClick!.dispatchEvent(event); + + const entryAfterKeydown = containerEl.querySelector('.claudian-status-panel-bash-entry'); + const contentAfterKeydown = entryAfterKeydown!.querySelector('.claudian-tool-content'); + const headerAfterKeydown = entryAfterKeydown!.querySelector('.claudian-tool-header'); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(contentAfterKeydown!.style.display).toBe('block'); + expect(headerAfterKeydown!.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should clear bash outputs via action button', () => { + panel.addBashOutput({ + id: 'bash-1', + command: 'echo hello', + status: 'completed', + output: 'hello', + exitCode: 0, + }); + + const clearButton = containerEl.querySelector('.claudian-status-panel-bash-action-clear'); + expect(clearButton).not.toBeNull(); + + clearButton!.click(); + + const bashContainer = containerEl.querySelector('.claudian-status-panel-bash'); + expect(bashContainer).not.toBeNull(); + expect(bashContainer!.style.display).toBe('none'); + }); + + it('should stopPropagation on clear button keydown to prevent header toggle', () => { + panel.addBashOutput({ + id: 'bash-1', + command: 'echo hello', + status: 'completed', + output: 'hello', + exitCode: 0, + }); + + const content = containerEl.querySelector('.claudian-status-panel-bash-content'); + expect(content!.style.display).toBe('block'); + + const clearButton = containerEl.querySelector('.claudian-status-panel-bash-action-clear'); + expect(clearButton).not.toBeNull(); + + const event = { type: 'keydown', key: 'Enter', preventDefault: jest.fn(), stopPropagation: jest.fn() }; + clearButton!.dispatchEvent(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + }); + + it('should copy latest bash output via action button', async () => { + panel.addBashOutput({ + id: 'bash-1', + command: 'echo hello', + status: 'completed', + output: 'hello', + exitCode: 0, + }); + + const copyButton = containerEl.querySelector('.claudian-status-panel-bash-action-copy'); + expect(copyButton).not.toBeNull(); + + copyButton!.click(); + + await Promise.resolve(); + expect(writeTextMock).toHaveBeenCalledWith('$ echo hello\nhello'); + }); + + it('should stopPropagation on copy button keydown to prevent header toggle', async () => { + panel.addBashOutput({ + id: 'bash-1', + command: 'echo hello', + status: 'completed', + output: 'hello', + exitCode: 0, + }); + + const content = containerEl.querySelector('.claudian-status-panel-bash-content'); + expect(content!.style.display).toBe('block'); + + const copyButton = containerEl.querySelector('.claudian-status-panel-bash-action-copy'); + expect(copyButton).not.toBeNull(); + + const event = { type: 'keydown', key: ' ', preventDefault: jest.fn(), stopPropagation: jest.fn() }; + copyButton!.dispatchEvent(event); + + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalled(); + + await Promise.resolve(); + expect(writeTextMock).toHaveBeenCalledWith('$ echo hello\nhello'); + }); + + it('should cap bash outputs to the most recent entries', () => { + for (let i = 0; i < 55; i++) { + panel.addBashOutput({ + id: `bash-${i}`, + command: `echo ${i}`, + status: 'completed', + output: `${i}`, + exitCode: 0, + }); + } + + const entries = containerEl.querySelectorAll('.claudian-status-panel-bash-entry'); + expect(entries.length).toBe(50); + }); + + it('should scroll bash content to bottom when outputs update', () => { + panel.addBashOutput({ + id: 'bash-1', + command: 'echo hello', + status: 'completed', + output: 'hello', + exitCode: 0, + }); + + const content = containerEl.querySelector('.claudian-status-panel-bash-content'); + expect(content).not.toBeNull(); + expect((content as any).scrollTop).toBe((content as any).scrollHeight); + }); + + it('should update a running bash output to completed with output', () => { + panel.addBashOutput({ + id: 'bash-1', + command: 'echo hello', + status: 'running', + output: '', + }); + + let entry = containerEl.querySelector('.claudian-status-panel-bash-entry'); + let text = entry!.querySelector('.claudian-tool-result-text'); + expect(text!.textContent).toBe('Running...'); + + panel.updateBashOutput('bash-1', { status: 'completed', output: 'hello', exitCode: 0 }); + + entry = containerEl.querySelector('.claudian-status-panel-bash-entry'); + text = entry!.querySelector('.claudian-tool-result-text'); + expect(text!.textContent).toBe('hello'); + + const statusEl = entry!.querySelector('.claudian-tool-status'); + expect(statusEl!.classList.contains('status-completed')).toBe(true); + }); + + it('should update a running bash output to error', () => { + panel.addBashOutput({ + id: 'bash-1', + command: 'bad-command', + status: 'running', + output: '', + }); + + panel.updateBashOutput('bash-1', { status: 'error', output: 'command not found', exitCode: 127 }); + + const entry = containerEl.querySelector('.claudian-status-panel-bash-entry'); + const text = entry!.querySelector('.claudian-tool-result-text'); + expect(text!.textContent).toBe('command not found'); + + const statusEl = entry!.querySelector('.claudian-tool-status'); + expect(statusEl!.classList.contains('status-error')).toBe(true); + }); + + it('should be a no-op when updating a non-existent bash output', () => { + panel.addBashOutput({ + id: 'bash-1', + command: 'echo hello', + status: 'running', + output: '', + }); + + panel.updateBashOutput('nonexistent', { status: 'completed', output: 'done' }); + + const entry = containerEl.querySelector('.claudian-status-panel-bash-entry'); + const text = entry!.querySelector('.claudian-tool-result-text'); + expect(text!.textContent).toBe('Running...'); + }); + + it('should set aria-expanded on the bash section header', () => { + panel.addBashOutput({ + id: 'bash-1', + command: 'echo hello', + status: 'completed', + output: 'hello', + exitCode: 0, + }); + + const header = containerEl.querySelector('.claudian-status-panel-bash-header'); + expect(header!.getAttribute('aria-expanded')).toBe('true'); + + header!.click(); + const headerAfterCollapse = containerEl.querySelector('.claudian-status-panel-bash-header'); + expect(headerAfterCollapse!.getAttribute('aria-expanded')).toBe('false'); + + headerAfterCollapse!.click(); + const headerAfterExpand = containerEl.querySelector('.claudian-status-panel-bash-header'); + expect(headerAfterExpand!.getAttribute('aria-expanded')).toBe('true'); + }); + + it('should handle clipboard failure gracefully', async () => { + writeTextMock.mockRejectedValueOnce(new Error('Clipboard denied')); + + panel.addBashOutput({ + id: 'bash-1', + command: 'echo hello', + status: 'completed', + output: 'hello', + exitCode: 0, + }); + + const copyButton = containerEl.querySelector('.claudian-status-panel-bash-action-copy'); + expect(copyButton).not.toBeNull(); + + copyButton!.click(); + + await Promise.resolve(); + expect(writeTextMock).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts b/tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts new file mode 100644 index 0000000..c58a9df --- /dev/null +++ b/tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts @@ -0,0 +1,155 @@ +import { FileContextState } from '@/features/chat/ui/file-context/state/FileContextState'; + +describe('FileContextState', () => { + let state: FileContextState; + + beforeEach(() => { + state = new FileContextState(); + }); + + describe('initial state', () => { + it('should start with no attached files', () => { + expect(state.getAttachedFiles().size).toBe(0); + }); + + it('should start with session not started', () => { + expect(state.isSessionStarted()).toBe(false); + }); + + it('should start with current note not sent', () => { + expect(state.hasSentCurrentNote()).toBe(false); + }); + + it('should start with no MCP mentions', () => { + expect(state.getMentionedMcpServers().size).toBe(0); + }); + }); + + describe('session lifecycle', () => { + it('should mark session as started', () => { + state.startSession(); + expect(state.isSessionStarted()).toBe(true); + }); + + it('should mark current note as sent', () => { + state.markCurrentNoteSent(); + expect(state.hasSentCurrentNote()).toBe(true); + }); + }); + + describe('resetForNewConversation', () => { + it('should reset all state', () => { + state.startSession(); + state.markCurrentNoteSent(); + state.attachFile('file1.md'); + state.addMentionedMcpServer('server1'); + + state.resetForNewConversation(); + + expect(state.isSessionStarted()).toBe(false); + expect(state.hasSentCurrentNote()).toBe(false); + expect(state.getAttachedFiles().size).toBe(0); + expect(state.getMentionedMcpServers().size).toBe(0); + }); + }); + + describe('resetForLoadedConversation', () => { + it('should set state based on whether conversation has messages', () => { + state.attachFile('file1.md'); + state.addMentionedMcpServer('server1'); + + state.resetForLoadedConversation(true); + + expect(state.isSessionStarted()).toBe(true); + expect(state.hasSentCurrentNote()).toBe(true); + expect(state.getAttachedFiles().size).toBe(0); + expect(state.getMentionedMcpServers().size).toBe(0); + }); + + it('should not mark as started when no messages', () => { + state.resetForLoadedConversation(false); + + expect(state.isSessionStarted()).toBe(false); + expect(state.hasSentCurrentNote()).toBe(false); + }); + }); + + describe('file attachments', () => { + it('should attach a file', () => { + state.attachFile('test.md'); + expect(state.getAttachedFiles().has('test.md')).toBe(true); + }); + + it('should return a copy of attached files (not the internal set)', () => { + state.attachFile('test.md'); + const files = state.getAttachedFiles(); + files.add('other.md'); + expect(state.getAttachedFiles().has('other.md')).toBe(false); + }); + + it('should detach a file', () => { + state.attachFile('test.md'); + state.detachFile('test.md'); + expect(state.getAttachedFiles().has('test.md')).toBe(false); + }); + + it('should set attached files replacing existing', () => { + state.attachFile('old.md'); + state.setAttachedFiles(['new1.md', 'new2.md']); + const files = state.getAttachedFiles(); + expect(files.has('old.md')).toBe(false); + expect(files.has('new1.md')).toBe(true); + expect(files.has('new2.md')).toBe(true); + }); + + it('should clear all attachments', () => { + state.attachFile('a.md'); + state.clearAttachments(); + expect(state.getAttachedFiles().size).toBe(0); + }); + }); + + describe('MCP server mentions', () => { + it('should add a mentioned MCP server', () => { + state.addMentionedMcpServer('server1'); + expect(state.getMentionedMcpServers().has('server1')).toBe(true); + }); + + it('should return a copy of mentioned servers', () => { + state.addMentionedMcpServer('server1'); + const servers = state.getMentionedMcpServers(); + servers.add('server2'); + expect(state.getMentionedMcpServers().has('server2')).toBe(false); + }); + + it('should clear MCP mentions', () => { + state.addMentionedMcpServer('server1'); + state.clearMcpMentions(); + expect(state.getMentionedMcpServers().size).toBe(0); + }); + + it('should set mentioned MCP servers and return true when changed', () => { + const changed = state.setMentionedMcpServers(new Set(['a', 'b'])); + expect(changed).toBe(true); + expect(state.getMentionedMcpServers()).toEqual(new Set(['a', 'b'])); + }); + + it('should return false when setting same servers', () => { + state.setMentionedMcpServers(new Set(['a', 'b'])); + const changed = state.setMentionedMcpServers(new Set(['a', 'b'])); + expect(changed).toBe(false); + }); + + it('should return true when sizes differ', () => { + state.setMentionedMcpServers(new Set(['a'])); + const changed = state.setMentionedMcpServers(new Set(['a', 'b'])); + expect(changed).toBe(true); + }); + + it('should return true when same size but different contents', () => { + state.setMentionedMcpServers(new Set(['a', 'b'])); + const changed = state.setMentionedMcpServers(new Set(['a', 'c'])); + expect(changed).toBe(true); + }); + }); +}); diff --git a/tests/unit/features/chat/ui/textareaResize.test.ts b/tests/unit/features/chat/ui/textareaResize.test.ts new file mode 100644 index 0000000..34c0a3c --- /dev/null +++ b/tests/unit/features/chat/ui/textareaResize.test.ts @@ -0,0 +1,102 @@ +/** + * @jest-environment jsdom + */ + +import { + autoResizeTextarea, + calculateTextareaMaxHeight, + calculateTextareaMinHeight, + TEXTAREA_BASE_MIN_HEIGHT, + TEXTAREA_MAX_HEIGHT_PERCENT, + TEXTAREA_MIN_MAX_HEIGHT, +} from '@/features/chat/ui/textareaResize'; + +describe('textareaResize', () => { + it('returns the base height when content exactly matches base flex allocation', () => { + expect(calculateTextareaMinHeight({ + contentHeight: 102, + flexAllocatedHeight: 102, + })).toBe(TEXTAREA_BASE_MIN_HEIGHT); + }); + + it('uses the content height when content exceeds flex allocation', () => { + expect(calculateTextareaMinHeight({ + contentHeight: 128, + flexAllocatedHeight: 102, + })).toBe(128); + }); + + it('returns the base height when content fits inside flex allocation', () => { + expect(calculateTextareaMinHeight({ + contentHeight: 80, + flexAllocatedHeight: 102, + })).toBe(TEXTAREA_BASE_MIN_HEIGHT); + }); + + it('measures from base height so previously grown content can shrink', () => { + const textarea = createResizeTextarea({ + baseOffsetHeight: 102, + grownOffsetHeight: 116, + baseScrollHeight: 102, + grownScrollHeight: 116, + }); + + textarea.style.setProperty('--claudian-textarea-min-height', '116px'); + + autoResizeTextarea(textarea); + + expect(textarea.style.getPropertyValue('--claudian-textarea-min-height')).toBe('60px'); + }); + + it('measures from base height so long content does not bounce', () => { + const textarea = createResizeTextarea({ + baseOffsetHeight: 102, + grownOffsetHeight: 116, + baseScrollHeight: 116, + grownScrollHeight: 116, + }); + + textarea.style.setProperty('--claudian-textarea-min-height', '116px'); + + autoResizeTextarea(textarea); + + expect(textarea.style.getPropertyValue('--claudian-textarea-min-height')).toBe('116px'); + }); + + it('caps max height by viewport percentage with a minimum usable cap', () => { + expect(calculateTextareaMaxHeight(100)).toBe(TEXTAREA_MIN_MAX_HEIGHT); + expect(calculateTextareaMaxHeight(1000)).toBe(1000 * TEXTAREA_MAX_HEIGHT_PERCENT); + }); +}); + +function createResizeTextarea({ + baseOffsetHeight, + grownOffsetHeight, + baseScrollHeight, + grownScrollHeight, +}: { + baseOffsetHeight: number; + grownOffsetHeight: number; + baseScrollHeight: number; + grownScrollHeight: number; +}): HTMLTextAreaElement { + const textarea = document.createElement('textarea'); + + textarea.setCssProps = (props: Record) => { + Object.entries(props).forEach(([key, value]) => { + textarea.style.setProperty(key, value); + }); + }; + + const isBaseHeight = () => + textarea.style.getPropertyValue('--claudian-textarea-min-height') === `${TEXTAREA_BASE_MIN_HEIGHT}px`; + + Object.defineProperty(textarea, 'offsetHeight', { + get: () => (isBaseHeight() ? baseOffsetHeight : grownOffsetHeight), + }); + Object.defineProperty(textarea, 'scrollHeight', { + get: () => (isBaseHeight() ? baseScrollHeight : grownScrollHeight), + }); + + return textarea; +} diff --git a/tests/unit/features/chat/utils/usageInfo.test.ts b/tests/unit/features/chat/utils/usageInfo.test.ts new file mode 100644 index 0000000..3f4af0e --- /dev/null +++ b/tests/unit/features/chat/utils/usageInfo.test.ts @@ -0,0 +1,57 @@ +import { TEST_CODEX_MODEL } from '@test/helpers/codexModels'; + +import { calculateUsagePercentage, recalculateUsageForModel } from '@/features/chat/utils/usageInfo'; + +describe('usageInfo', () => { + describe('calculateUsagePercentage', () => { + it('rounds to the nearest integer and clamps to 0-100', () => { + expect(calculateUsagePercentage(13623, 100000)).toBe(14); + expect(calculateUsagePercentage(500000, 200000)).toBe(100); + expect(calculateUsagePercentage(500, 0)).toBe(0); + }); + }); + + describe('recalculateUsageForModel', () => { + it('preserves an authoritative context window for the same model', () => { + const usage = { + model: TEST_CODEX_MODEL, + inputTokens: 1000, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + contextWindow: 258400, + contextWindowIsAuthoritative: true, + contextTokens: 129200, + percentage: 50, + }; + + expect(recalculateUsageForModel(usage, TEST_CODEX_MODEL, 200000)).toEqual({ + ...usage, + model: TEST_CODEX_MODEL, + contextWindow: 258400, + contextWindowIsAuthoritative: true, + percentage: 50, + }); + }); + + it('falls back to the UI context window when the model changes', () => { + const usage = { + model: TEST_CODEX_MODEL, + inputTokens: 1000, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + contextWindow: 258400, + contextWindowIsAuthoritative: true, + contextTokens: 100000, + percentage: 39, + }; + + expect(recalculateUsageForModel(usage, 'gpt-5.4-mini', 200000)).toEqual({ + ...usage, + model: 'gpt-5.4-mini', + contextWindow: 200000, + contextWindowIsAuthoritative: false, + percentage: 50, + }); + }); + }); +}); diff --git a/tests/unit/features/inline-edit/InlineEditService.test.ts b/tests/unit/features/inline-edit/InlineEditService.test.ts new file mode 100644 index 0000000..e6170b4 --- /dev/null +++ b/tests/unit/features/inline-edit/InlineEditService.test.ts @@ -0,0 +1,1223 @@ +import '@/providers'; + +// eslint-disable-next-line jest/no-mocks-import +import { + getLastOptions, + resetMockMessages, + setMockMessages, +} from '@test/__mocks__/claude-agent-sdk'; +import * as fs from 'fs'; + +// Mock fs module +jest.mock('fs'); + +// Now import after all mocks are set up +import { buildInlineEditPrompt, parseInlineEditResponse } from '@/core/prompt/inlineEdit'; +import { getPathFromToolInput } from '@/core/tools/toolInput'; +import type { InlineEditRequest } from '@/providers/claude/auxiliary/ClaudeInlineEditService'; +import { + createReadOnlyHook, + InlineEditService, +} from '@/providers/claude/auxiliary/ClaudeInlineEditService'; +import { buildCursorContext } from '@/utils/editor'; + +// Create a mock plugin +function createMockPlugin(settings = {}) { + return { + settings: { + model: 'sonnet', + thinkingBudget: 'off', + ...settings, + }, + app: { + vault: { + adapter: { + basePath: '/test/vault/path', + }, + }, + }, + getActiveEnvironmentVariables: jest.fn().mockReturnValue(''), + getResolvedProviderCliPath: jest.fn().mockReturnValue('/fake/claude'), + } as any; +} + +// Hook functions accept typed HookInput / return typed HookJSONOutput, but the +// implementation only reads tool_name/tool_input. Cast I/O to any in tests. +const callHook = async (hook: any, input: any, ...rest: any[]): Promise => + hook(input, ...rest); + +describe('InlineEditService', () => { + let service: InlineEditService; + let mockPlugin: any; + + beforeEach(() => { + jest.clearAllMocks(); + resetMockMessages(); + mockPlugin = createMockPlugin(); + service = new InlineEditService(mockPlugin); + }); + + + describe('buildPrompt', () => { + it('should build prompt with correct format', () => { + const request: InlineEditRequest = { + mode: 'selection', + selectedText: 'Hello world', + instruction: 'Fix the greeting', + notePath: 'notes/test.md', + }; + + const prompt = buildInlineEditPrompt(request); + + // New format: instruction first, then XML context (no wrapper) + expect(prompt).toContain('Fix the greeting'); + expect(prompt).toContain(''); + expect(prompt).toContain('Hello world'); + expect(prompt).toContain(''); + // Verify instruction comes before selection + expect(prompt.indexOf('Fix the greeting')).toBeLessThan(prompt.indexOf(' { + const request: InlineEditRequest = { + mode: 'selection', + selectedText: 'Line 1\nLine 2\nLine 3', + instruction: 'Fix formatting', + notePath: 'doc.md', + }; + + const prompt = buildInlineEditPrompt(request); + + expect(prompt).toContain('Line 1\nLine 2\nLine 3'); + }); + + it('should handle empty selected text', () => { + const request: InlineEditRequest = { + mode: 'selection', + selectedText: '', + instruction: 'Add content', + notePath: 'empty.md', + }; + + const prompt = buildInlineEditPrompt(request); + + // New format: instruction first, then XML context (no wrapper) + expect(prompt).toContain('Add content'); + expect(prompt).toContain(''); + // Verify instruction comes before selection + expect(prompt.indexOf('Add content')).toBeLessThan(prompt.indexOf(' { + const request: InlineEditRequest = { + mode: 'selection', + selectedText: 'test', + instruction: 'Fix this', + notePath: 'test.md', + contextFiles: ['notes/helper.md', 'docs/api.md'], + }; + + const prompt = buildInlineEditPrompt(request); + + // Context files should be appended with tag + expect(prompt).toContain(''); + expect(prompt).toContain('notes/helper.md'); + expect(prompt).toContain('docs/api.md'); + expect(prompt).toContain(''); + // Original content should still be present + expect(prompt).toContain(' { + const request: InlineEditRequest = { + mode: 'selection', + selectedText: 'test', + instruction: 'Fix this', + notePath: 'test.md', + contextFiles: [], + }; + + const prompt = buildInlineEditPrompt(request); + + expect(prompt).not.toContain(''); + expect(prompt).toContain(' { + const request: InlineEditRequest = { + mode: 'selection', + selectedText: 'test', + instruction: 'Fix this', + notePath: 'test.md', + }; + + const prompt = buildInlineEditPrompt(request); + + expect(prompt).not.toContain(''); + expect(prompt).toContain(' { + const request: InlineEditRequest = { + mode: 'cursor', + instruction: 'insert here', + notePath: 'test.md', + cursorContext: { + beforeCursor: 'before', + afterCursor: 'after', + isInbetween: false, + line: 0, + column: 6, + }, + contextFiles: ['utils.ts'], + }; + + const prompt = buildInlineEditPrompt(request); + + expect(prompt).toContain(''); + expect(prompt).toContain('utils.ts'); + expect(prompt).toContain(' { + it('should extract text from replacement tags', () => { + const response = 'Here is the edit:\nFixed text here'; + + const result = parseInlineEditResponse(response); + + expect(result.success).toBe(true); + expect(result.editedText).toBe('Fixed text here'); + }); + + it('should handle multiline replacement content', () => { + const response = 'Line 1\nLine 2\nLine 3'; + + const result = parseInlineEditResponse(response); + + expect(result.success).toBe(true); + expect(result.editedText).toBe('Line 1\nLine 2\nLine 3'); + }); + + it('should return clarification when no replacement tags', () => { + const response = 'Could you please clarify what you mean by "fix"?'; + + const result = parseInlineEditResponse(response); + + expect(result.success).toBe(true); + expect(result.clarification).toBe('Could you please clarify what you mean by "fix"?'); + expect(result.editedText).toBeUndefined(); + }); + + it('should return error for empty response', () => { + const result = parseInlineEditResponse(''); + + expect(result.success).toBe(false); + expect(result.error).toBe('Empty response'); + }); + + it('should return error for whitespace-only response', () => { + const result = parseInlineEditResponse(' \n\t '); + + expect(result.success).toBe(false); + expect(result.error).toBe('Empty response'); + }); + + it('should handle replacement tags with special characters', () => { + const response = 'const x = a < b && c > d;'; + + const result = parseInlineEditResponse(response); + + expect(result.success).toBe(true); + expect(result.editedText).toBe('const x = a < b && c > d;'); + }); + + it('should extract first replacement tag if multiple exist', () => { + const response = 'first then second'; + + const result = parseInlineEditResponse(response); + + expect(result.success).toBe(true); + expect(result.editedText).toBe('first'); + }); + + it('should handle empty replacement tags', () => { + const response = ''; + + const result = parseInlineEditResponse(response); + + expect(result.success).toBe(true); + expect(result.editedText).toBe(''); + }); + }); + + describe('editText', () => { + beforeEach(() => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + }); + + it('should return error when vault path cannot be determined', async () => { + mockPlugin.app.vault.adapter.basePath = undefined; + service = new InlineEditService(mockPlugin); + + const result = await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('vault path'); + }); + + it('should return error when claude CLI not found', async () => { + mockPlugin.getResolvedProviderCliPath.mockReturnValue(null); + + const result = await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Claude CLI not found'); + }); + + it('should use restricted read-only tools', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'fixed' }] }, + }, + { type: 'result' }, + ]); + + await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + const options = getLastOptions(); + expect(options?.tools).toContain('Read'); + expect(options?.tools).toContain('Grep'); + expect(options?.tools).toContain('Glob'); + expect(options?.tools).toContain('LS'); + expect(options?.tools).toContain('WebSearch'); + expect(options?.tools).toContain('WebFetch'); + // Should NOT include write tools + expect(options?.tools).not.toContain('Write'); + expect(options?.tools).not.toContain('Edit'); + expect(options?.tools).not.toContain('Bash'); + }); + + it('should bypass permissions for read-only tools', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'fixed' }] }, + }, + { type: 'result' }, + ]); + + await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + const options = getLastOptions(); + expect(options?.permissionMode).toBe('bypassPermissions'); + }); + + it('should set settingSources to project only when loadUserClaudeSettings is false', async () => { + mockPlugin.settings.loadUserClaudeSettings = false; + service = new InlineEditService(mockPlugin); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'fixed' }] }, + }, + { type: 'result' }, + ]); + + await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + const options = getLastOptions(); + expect(options?.settingSources).toEqual(['project', 'local']); + }); + + it('should set settingSources to include user when loadUserClaudeSettings is true', async () => { + mockPlugin.settings.loadUserClaudeSettings = true; + service = new InlineEditService(mockPlugin); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'fixed' }] }, + }, + { type: 'result' }, + ]); + + await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + const options = getLastOptions(); + expect(options?.settingSources).toEqual(['user', 'project', 'local']); + }); + + it('should set adaptive thinking for Claude models', async () => { + mockPlugin.settings.model = 'sonnet'; + service = new InlineEditService(mockPlugin); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'fixed' }] }, + }, + { type: 'result' }, + ]); + + await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + const options = getLastOptions(); + expect(options?.thinking).toEqual({ type: 'adaptive' }); + expect(options?.maxThinkingTokens).toBeUndefined(); + }); + + it('should use the model override when provided', async () => { + mockPlugin.settings.model = 'sonnet'; + service = new InlineEditService(mockPlugin); + service.setModelOverride('opus'); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'fixed' }] }, + }, + { type: 'result' }, + ]); + + await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + expect(getLastOptions()?.model).toBe('opus'); + }); + + it('should set adaptive thinking with effort for custom models', async () => { + mockPlugin.settings.model = 'custom-model'; + mockPlugin.settings.thinkingBudget = 'medium'; + mockPlugin.settings.effortLevel = 'medium'; + service = new InlineEditService(mockPlugin); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'fixed' }] }, + }, + { type: 'result' }, + ]); + + await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + const options = getLastOptions(); + expect(options?.thinking).toEqual({ type: 'adaptive' }); + expect(options?.effort).toBe('medium'); + expect(options?.maxThinkingTokens).toBeUndefined(); + }); + + it('should capture session ID for conversation continuity', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'inline-session-123' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'What do you want to change?' }] }, + }, + { type: 'result' }, + ]); + + await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + // Verify session was captured by checking continueConversation resumes it + setMockMessages([ + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'fixed' }] }, + }, + { type: 'result' }, + ]); + + await service.continueConversation('make it better'); + + const options = getLastOptions(); + expect(options?.resume).toBe('inline-session-123'); + }); + + it('should return clarification response', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'Could you clarify what "fix" means?' }] }, + }, + { type: 'result' }, + ]); + + const result = await service.editText({ + mode: 'selection', + selectedText: 'broken code', + instruction: 'fix', + notePath: 'test.md', + }); + + expect(result.success).toBe(true); + expect(result.clarification).toBe('Could you clarify what "fix" means?'); + }); + }); + + describe('continueConversation', () => { + beforeEach(() => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + }); + + it('should return error when no active conversation', async () => { + const result = await service.continueConversation('more details'); + + expect(result.success).toBe(false); + expect(result.error).toContain('No active conversation'); + }); + + it('should resume session on follow-up', async () => { + // First message to establish session + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'continue-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'What do you want?' }] }, + }, + { type: 'result' }, + ]); + + await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + // Follow-up message + setMockMessages([ + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'final result' }] }, + }, + { type: 'result' }, + ]); + + await service.continueConversation('make it blue'); + + const options = getLastOptions(); + expect(options?.resume).toBe('continue-session'); + }); + + it('should prepend context files when provided', async () => { + // First message to establish session + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'context-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'What do you want?' }] }, + }, + { type: 'result' }, + ]); + + await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + // Follow-up message with context files + setMockMessages([ + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'final result' }] }, + }, + { type: 'result' }, + ]); + + await service.continueConversation('make it blue', ['notes/helper.md', 'docs/api.md']); + + // The prompt should include the context files + // Since we can't directly access the prompt, we verify the session resumed + const options = getLastOptions(); + expect(options?.resume).toBe('context-session'); + }); + + it('should not modify prompt when no context files provided', async () => { + // First message to establish session + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'no-context-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'What do you want?' }] }, + }, + { type: 'result' }, + ]); + + await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + // Follow-up without context files + setMockMessages([ + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'result' }] }, + }, + { type: 'result' }, + ]); + + await service.continueConversation('make it blue'); + + const options = getLastOptions(); + expect(options?.resume).toBe('no-context-session'); + }); + + it('should handle empty context files array', async () => { + // First message to establish session + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'empty-context-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'What do you want?' }] }, + }, + { type: 'result' }, + ]); + + await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + // Follow-up with empty context files array + setMockMessages([ + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'result' }] }, + }, + { type: 'result' }, + ]); + + await service.continueConversation('make it blue', []); + + const options = getLastOptions(); + expect(options?.resume).toBe('empty-context-session'); + }); + }); + + describe('resetConversation', () => { + it('should clear session so continueConversation fails', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + + // First establish a session + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'some-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'What do you want?' }] }, + }, + { type: 'result' }, + ]); + + await service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + // Reset should clear the session + service.resetConversation(); + + // continueConversation should fail since session is cleared + const result = await service.continueConversation('more details'); + expect(result.success).toBe(false); + expect(result.error).toContain('No active conversation'); + }); + }); + + describe('cancel', () => { + it('should abort ongoing request', async () => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'test-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'fixed' }] }, + }, + ]); + + const editPromise = service.editText({ + mode: 'selection', + selectedText: 'test', + instruction: 'fix', + notePath: 'test.md', + }); + + // Cancel immediately + service.cancel(); + + const result = await editPromise; + expect(result.success).toBe(false); + expect(result.error).toBe('Cancelled'); + }); + + it('should handle cancel when no request is running', () => { + expect(() => service.cancel()).not.toThrow(); + }); + }); + + describe('read-only hook enforcement', () => { + it('should create hook that allows read-only tools', () => { + const hook = createReadOnlyHook(); + + expect(hook.hooks).toHaveLength(1); + }); + + it('should allow Read tool through hook', async () => { + const hook = createReadOnlyHook(); + const result = await callHook(hook.hooks[0], { tool_name: 'Read', tool_input: { file_path: 'test.md' } }); + + expect(result.continue).toBe(true); + }); + + it('should allow Grep tool through hook', async () => { + const hook = createReadOnlyHook(); + const result = await callHook(hook.hooks[0], { tool_name: 'Grep', tool_input: { pattern: 'test' } }); + + expect(result.continue).toBe(true); + }); + + it('should allow WebSearch tool through hook', async () => { + const hook = createReadOnlyHook(); + const result = await callHook(hook.hooks[0], { tool_name: 'WebSearch', tool_input: { query: 'test' } }); + + expect(result.continue).toBe(true); + }); + + it('should block Write tool through hook', async () => { + const hook = createReadOnlyHook(); + const result = await callHook(hook.hooks[0], { tool_name: 'Write', tool_input: { file_path: 'test.md' } }); + + expect(result.continue).toBe(false); + expect(result.hookSpecificOutput.permissionDecision).toBe('deny'); + expect(result.hookSpecificOutput.permissionDecisionReason).toContain('not allowed'); + }); + + it('should block Bash tool through hook', async () => { + const hook = createReadOnlyHook(); + const result = await callHook(hook.hooks[0], { tool_name: 'Bash', tool_input: { command: 'rm -rf /' } }); + + expect(result.continue).toBe(false); + expect(result.hookSpecificOutput.permissionDecision).toBe('deny'); + }); + + it('should block Edit tool through hook', async () => { + const hook = createReadOnlyHook(); + const result = await callHook(hook.hooks[0], { tool_name: 'Edit', tool_input: { file_path: 'test.md' } }); + + expect(result.continue).toBe(false); + }); + }); + + describe('error handling', () => { + beforeEach(() => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + }); + + it('should surface SDK query errors', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const sdk = require('@anthropic-ai/claude-agent-sdk'); + const spy = jest.spyOn(sdk, 'query').mockImplementation(() => { + throw new Error('boom'); + }); + + try { + const result = await service.editText({ + mode: 'selection', + selectedText: 'text', + instruction: 'edit', + notePath: 'note.md', + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('boom'); + } finally { + spy.mockRestore(); + } + }); + + it('returns null path for unknown tool input', () => { + expect(getPathFromToolInput('Unknown', {})).toBeNull(); + }); + + it('extracts LS path from tool input', () => { + expect(getPathFromToolInput('LS', { path: 'notes' })).toBe('notes'); + }); + }); + + describe('buildCursorContext', () => { + // Helper to create mock getLine function from array of lines + const createGetLine = (lines: string[]) => (line: number) => lines[line] ?? ''; + + describe('inline mode detection', () => { + it('should detect inline when cursor is in middle of text', () => { + const lines = ['Hello world']; + const ctx = buildCursorContext(createGetLine(lines), 1, 0, 6); + + expect(ctx.isInbetween).toBe(false); + expect(ctx.beforeCursor).toBe('Hello '); + expect(ctx.afterCursor).toBe('world'); + expect(ctx.line).toBe(0); + expect(ctx.column).toBe(6); + }); + + it('should detect inline when cursor is at line start with text after', () => { + const lines = ['Hello world']; + const ctx = buildCursorContext(createGetLine(lines), 1, 0, 0); + + expect(ctx.isInbetween).toBe(false); + expect(ctx.beforeCursor).toBe(''); + expect(ctx.afterCursor).toBe('Hello world'); + }); + + it('should detect inline when cursor is at line end with text before', () => { + const lines = ['Hello world']; + const ctx = buildCursorContext(createGetLine(lines), 1, 0, 11); + + expect(ctx.isInbetween).toBe(false); + expect(ctx.beforeCursor).toBe('Hello world'); + expect(ctx.afterCursor).toBe(''); + }); + + it('should preserve whitespace around cursor', () => { + const lines = [' hello world ']; + const ctx = buildCursorContext(createGetLine(lines), 1, 0, 9); + + expect(ctx.isInbetween).toBe(false); + expect(ctx.beforeCursor).toBe(' hello '); + expect(ctx.afterCursor).toBe(' world '); + }); + }); + + describe('inbetween mode detection', () => { + it('should detect inbetween on empty line', () => { + const lines = ['First paragraph', '', 'Second paragraph']; + const ctx = buildCursorContext(createGetLine(lines), 3, 1, 0); + + expect(ctx.isInbetween).toBe(true); + expect(ctx.beforeCursor).toBe('First paragraph'); + expect(ctx.afterCursor).toBe('Second paragraph'); + }); + + it('should detect inbetween on whitespace-only line', () => { + const lines = ['First paragraph', ' ', 'Second paragraph']; + const ctx = buildCursorContext(createGetLine(lines), 3, 1, 2); + + expect(ctx.isInbetween).toBe(true); + expect(ctx.beforeCursor).toBe('First paragraph'); + expect(ctx.afterCursor).toBe('Second paragraph'); + }); + + it('should detect inbetween when cursor on line with only whitespace before and after', () => { + const lines = ['Content', ' \t ', 'More content']; + const ctx = buildCursorContext(createGetLine(lines), 3, 1, 2); + + expect(ctx.isInbetween).toBe(true); + }); + + it('should find nearest non-empty line before cursor', () => { + const lines = ['Content', '', '', '', 'More']; + const ctx = buildCursorContext(createGetLine(lines), 5, 2, 0); + + expect(ctx.isInbetween).toBe(true); + expect(ctx.beforeCursor).toBe('Content'); + }); + + it('should find nearest non-empty line after cursor', () => { + const lines = ['Content', '', '', '', 'More']; + const ctx = buildCursorContext(createGetLine(lines), 5, 2, 0); + + expect(ctx.isInbetween).toBe(true); + expect(ctx.afterCursor).toBe('More'); + }); + + it('should handle cursor at document start (empty first line)', () => { + const lines = ['', 'First content']; + const ctx = buildCursorContext(createGetLine(lines), 2, 0, 0); + + expect(ctx.isInbetween).toBe(true); + expect(ctx.beforeCursor).toBe(''); + expect(ctx.afterCursor).toBe('First content'); + }); + + it('should handle cursor at document end (empty last line)', () => { + const lines = ['Last content', '']; + const ctx = buildCursorContext(createGetLine(lines), 2, 1, 0); + + expect(ctx.isInbetween).toBe(true); + expect(ctx.beforeCursor).toBe('Last content'); + expect(ctx.afterCursor).toBe(''); + }); + + it('should handle multiple consecutive empty lines', () => { + const lines = ['Para A', '', '', '', 'Para B']; + const ctx = buildCursorContext(createGetLine(lines), 5, 2, 0); + + expect(ctx.isInbetween).toBe(true); + expect(ctx.beforeCursor).toBe('Para A'); + expect(ctx.afterCursor).toBe('Para B'); + }); + }); + + describe('edge cases', () => { + it('should handle single line document with cursor', () => { + const lines = ['Only line']; + const ctx = buildCursorContext(createGetLine(lines), 1, 0, 5); + + expect(ctx.isInbetween).toBe(false); + expect(ctx.beforeCursor).toBe('Only '); + expect(ctx.afterCursor).toBe('line'); + }); + + it('should handle empty document', () => { + const lines = ['']; + const ctx = buildCursorContext(createGetLine(lines), 1, 0, 0); + + expect(ctx.isInbetween).toBe(true); + expect(ctx.beforeCursor).toBe(''); + expect(ctx.afterCursor).toBe(''); + }); + + it('should preserve line and column in context', () => { + const lines = ['Line 0', 'Line 1', 'Line 2']; + const ctx = buildCursorContext(createGetLine(lines), 3, 1, 3); + + expect(ctx.line).toBe(1); + expect(ctx.column).toBe(3); + }); + }); + }); + + describe('buildCursorPrompt', () => { + it('should build inline cursor prompt correctly', () => { + const request: InlineEditRequest = { + mode: 'cursor', + instruction: 'add missing word', + notePath: 'notes/test.md', + cursorContext: { + beforeCursor: 'The quick brown ', + afterCursor: ' jumps over', + isInbetween: false, + line: 5, + column: 16, + }, + }; + + const prompt = buildInlineEditPrompt(request); + + // New format: instruction first, then XML context (no wrapper) + expect(prompt).toContain('add missing word'); + expect(prompt).toContain(''); + expect(prompt).toContain('The quick brown | jumps over #inline'); + expect(prompt).toContain(''); + // Verify instruction comes before cursor context + expect(prompt.indexOf('add missing word')).toBeLessThan(prompt.indexOf(' { + const request: InlineEditRequest = { + mode: 'cursor', + instruction: 'add a new section', + notePath: 'docs/readme.md', + cursorContext: { + beforeCursor: '# Introduction', + afterCursor: '## Features', + isInbetween: true, + line: 3, + column: 0, + }, + }; + + const prompt = buildInlineEditPrompt(request); + + // New format: instruction first, then XML context (no wrapper) + expect(prompt).toContain('add a new section'); + expect(prompt).toContain(''); + expect(prompt).toContain('# Introduction'); + expect(prompt).toContain('| #inbetween'); + expect(prompt).toContain('## Features'); + expect(prompt).toContain(''); + // Verify instruction comes before cursor context + expect(prompt.indexOf('add a new section')).toBeLessThan(prompt.indexOf(' { + const request: InlineEditRequest = { + mode: 'cursor', + instruction: 'add header', + notePath: 'empty.md', + cursorContext: { + beforeCursor: '', + afterCursor: 'First paragraph', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const prompt = buildInlineEditPrompt(request); + + expect(prompt).toContain('| #inbetween'); + expect(prompt).toContain('First paragraph'); + expect(prompt).not.toMatch(/\n\n\| #inbetween/); // No double newline before marker + }); + + it('should handle inbetween with no content after cursor', () => { + const request: InlineEditRequest = { + mode: 'cursor', + instruction: 'add footer', + notePath: 'doc.md', + cursorContext: { + beforeCursor: 'Last paragraph', + afterCursor: '', + isInbetween: true, + line: 10, + column: 0, + }, + }; + + const prompt = buildInlineEditPrompt(request); + + expect(prompt).toContain('Last paragraph'); + expect(prompt).toContain('| #inbetween'); + }); + }); + + describe('buildPrompt mode dispatch', () => { + it('should dispatch to selection prompt for selection mode', () => { + const request: InlineEditRequest = { + mode: 'selection', + instruction: 'fix this', + notePath: 'test.md', + selectedText: 'selected text here', + }; + + const prompt = buildInlineEditPrompt(request); + + expect(prompt).toContain('selected text here'); + expect(prompt).not.toContain('#inline'); + expect(prompt).not.toContain('#inbetween'); + }); + + it('should dispatch to cursor prompt for cursor mode', () => { + const request: InlineEditRequest = { + mode: 'cursor', + instruction: 'insert here', + notePath: 'test.md', + cursorContext: { + beforeCursor: 'before', + afterCursor: 'after', + isInbetween: false, + line: 0, + column: 6, + }, + }; + + const prompt = buildInlineEditPrompt(request); + + expect(prompt).toContain('before|after #inline'); + }); + }); + + describe('parseResponse with insertion tags', () => { + it('should extract text from insertion tags', () => { + const response = 'Here is the content:\ninserted text here'; + + const result = parseInlineEditResponse(response); + + expect(result.success).toBe(true); + expect(result.insertedText).toBe('inserted text here'); + expect(result.editedText).toBeUndefined(); + }); + + it('should handle multiline insertion content', () => { + const response = 'Line 1\nLine 2\nLine 3'; + + const result = parseInlineEditResponse(response); + + expect(result.success).toBe(true); + expect(result.insertedText).toBe('Line 1\nLine 2\nLine 3'); + }); + + it('should prefer replacement tags over insertion tags', () => { + const response = 'replacedinserted'; + + const result = parseInlineEditResponse(response); + + expect(result.success).toBe(true); + expect(result.editedText).toBe('replaced'); + expect(result.insertedText).toBeUndefined(); + }); + + it('should handle insertion tags with leading/trailing newlines', () => { + const response = '\n## New Section\n\nContent here\n'; + + const result = parseInlineEditResponse(response); + + expect(result.success).toBe(true); + expect(result.insertedText).toBe('\n## New Section\n\nContent here\n'); + }); + + it('should handle empty insertion tags', () => { + const response = ''; + + const result = parseInlineEditResponse(response); + + expect(result.success).toBe(true); + expect(result.insertedText).toBe(''); + }); + + it('should handle insertion with special characters', () => { + const response = 'const x = a < b && c > d;'; + + const result = parseInlineEditResponse(response); + + expect(result.success).toBe(true); + expect(result.insertedText).toBe('const x = a < b && c > d;'); + }); + + it('should return clarification when no tags present', () => { + const response = 'What would you like me to insert?'; + + const result = parseInlineEditResponse(response); + + expect(result.success).toBe(true); + expect(result.clarification).toBe('What would you like me to insert?'); + expect(result.insertedText).toBeUndefined(); + expect(result.editedText).toBeUndefined(); + }); + }); + + describe('editText with cursor mode', () => { + beforeEach(() => { + (fs.existsSync as jest.Mock).mockReturnValue(true); + }); + + it('should handle cursor mode request', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'cursor-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: 'fox' }] }, + }, + { type: 'result' }, + ]); + + const result = await service.editText({ + mode: 'cursor', + instruction: 'what animal?', + notePath: 'test.md', + cursorContext: { + beforeCursor: 'The quick brown ', + afterCursor: ' jumps over', + isInbetween: false, + line: 0, + column: 16, + }, + }); + + expect(result.success).toBe(true); + expect(result.insertedText).toBe('fox'); + }); + + it('should handle inbetween mode request', async () => { + setMockMessages([ + { type: 'system', subtype: 'init', session_id: 'inbetween-session' }, + { + type: 'assistant', + message: { content: [{ type: 'text', text: '## Description\n\nNew section content' }] }, + }, + { type: 'result' }, + ]); + + const result = await service.editText({ + mode: 'cursor', + instruction: 'add description section', + notePath: 'readme.md', + cursorContext: { + beforeCursor: '# Title', + afterCursor: '## Features', + isInbetween: true, + line: 2, + column: 0, + }, + }); + + expect(result.success).toBe(true); + expect(result.insertedText).toContain('## Description'); + }); + }); +}); diff --git a/tests/unit/features/inline-edit/ui/InlineEditModal.openAndWait.test.ts b/tests/unit/features/inline-edit/ui/InlineEditModal.openAndWait.test.ts new file mode 100644 index 0000000..fe965c5 --- /dev/null +++ b/tests/unit/features/inline-edit/ui/InlineEditModal.openAndWait.test.ts @@ -0,0 +1,1609 @@ +import '@/providers'; + +import { createMockEl } from '@test/helpers/mockElement'; +import { MarkdownRenderer, Notice } from 'obsidian'; + +import { ProviderRegistry } from '@/core/providers/ProviderRegistry'; +import { type InlineEditContext, InlineEditModal } from '@/features/inline-edit/ui/InlineEditModal'; +import { VaultFolderCache } from '@/shared/mention/VaultMentionCache'; +import * as editorUtils from '@/utils/editor'; + +const mentionDropdownCtor = jest.fn(); +jest.mock('@/shared/mention/MentionDropdownController', () => ({ + MentionDropdownController: function MockMentionDropdownController(...args: any[]) { + mentionDropdownCtor(...args); + return { + handleInputChange: jest.fn(), + handleKeydown: jest.fn().mockReturnValue(false), + destroy: jest.fn(), + }; + }, +})); + +jest.mock('@/shared/components/SlashCommandDropdown', () => ({ + SlashCommandDropdown: jest.fn().mockImplementation(() => ({ + handleKeydown: jest.fn().mockReturnValue(false), + destroy: jest.fn(), + })), +})); + +jest.mock('@/utils/externalContextScanner', () => ({ + externalContextScanner: { + scanPaths: jest.fn().mockReturnValue([]), + }, +})); + +function createDeferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +describe('InlineEditModal - openAndWait', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('uses editorCallback references first and falls back to view.editor before rejecting', async () => { + const callbackEditor = {} as any; + const fallbackEditor = {} as any; + + const app = { + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const plugin = {} as any; + const view = { editor: fallbackEditor } as any; + + const editContext: InlineEditContext = { + mode: 'cursor', + cursorContext: { + beforeCursor: '', + afterCursor: '', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const getEditorViewSpy = jest + .spyOn(editorUtils, 'getEditorView') + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined); + + const modal = new InlineEditModal(app, plugin, callbackEditor, view, editContext, 'note.md'); + const result = await modal.openAndWait(); + + expect(result).toEqual({ decision: 'reject' }); + expect(getEditorViewSpy).toHaveBeenNthCalledWith(1, callbackEditor); + expect(getEditorViewSpy).toHaveBeenNthCalledWith(2, fallbackEditor); + expect(app.workspace.getActiveViewOfType).not.toHaveBeenCalled(); + + const noticeMock = Notice as unknown as jest.Mock; + expect(noticeMock).toHaveBeenCalledWith( + 'Inline edit unavailable: could not access the active editor. Try reopening the note.' + ); + }); + + it('wires mention getCachedVaultFolders through VaultFolderCache.getFolders', async () => { + const originalDocument = (global as any).document; + (global as any).document = { + body: createMockEl('body'), + createElement: (tagName: string) => createMockEl(tagName), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + try { + const app = { + vault: { + getFiles: jest.fn().mockReturnValue([]), + getAllLoadedFiles: jest.fn().mockReturnValue([]), + }, + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const plugin = { + settings: { + hiddenProviderCommands: { + claude: [], + codex: [], + }, + }, + getSdkCommands: jest.fn().mockReturnValue([]), + } as any; + plugin.providerHost = plugin; + const editor = {} as any; + const view = { editor } as any; + + let widgetRef: any = null; + const dispatch = jest.fn((transaction: any) => { + const effects = Array.isArray(transaction?.effects) + ? transaction.effects + : transaction?.effects + ? [transaction.effects] + : []; + for (const effect of effects) { + const widget = effect?.value?.widget; + if (widget && typeof widget.createInputDOM === 'function') { + widgetRef = widget; + widget.createInputDOM(); + } + } + }); + const editorView = { + state: { + doc: { + line: jest.fn(() => ({ from: 0 })), + lineAt: jest.fn(() => ({ from: 0 })), + }, + }, + dispatch, + dom: { + ownerDocument: (global as any).document, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + } as any; + + const getEditorViewSpy = jest + .spyOn(editorUtils, 'getEditorView') + .mockReturnValue(editorView); + const getFoldersSpy = jest + .spyOn(VaultFolderCache.prototype, 'getFolders') + .mockReturnValue([{ name: 'src', path: 'src' } as any]); + + const editContext: InlineEditContext = { + mode: 'cursor', + cursorContext: { + beforeCursor: '', + afterCursor: '', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md'); + const resultPromise = modal.openAndWait(); + + expect(mentionDropdownCtor).toHaveBeenCalled(); + const callbacks = mentionDropdownCtor.mock.calls[0]?.[2]; + expect(callbacks).toBeDefined(); + expect(callbacks.getCachedVaultFolders()).toEqual([{ name: 'src', path: 'src' }]); + expect(getFoldersSpy).toHaveBeenCalledTimes(1); + + widgetRef?.reject(); + await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); + + getEditorViewSpy.mockRestore(); + getFoldersSpy.mockRestore(); + } finally { + (global as any).document = originalDocument; + } + }); + + it('uses provider-scoped hidden commands for Codex inline edit dropdowns', async () => { + const originalDocument = (global as any).document; + (global as any).document = { + body: createMockEl('body'), + createElement: (tagName: string) => createMockEl(tagName), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + try { + const app = { + vault: { + getFiles: jest.fn().mockReturnValue([]), + getAllLoadedFiles: jest.fn().mockReturnValue([]), + }, + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const plugin = { + settings: { + hiddenProviderCommands: { + claude: ['commit'], + codex: ['analyze'], + }, + }, + getConversationSync: jest.fn().mockReturnValue(null), + getView: jest.fn().mockReturnValue({ + getActiveTab: jest.fn().mockReturnValue({ + providerId: 'codex', + service: null, + conversationId: null, + }), + }), + } as any; + plugin.providerHost = plugin; + const editor = {} as any; + const view = { editor } as any; + + let widgetRef: any = null; + const dispatch = jest.fn((transaction: any) => { + const effects = Array.isArray(transaction?.effects) + ? transaction.effects + : transaction?.effects + ? [transaction.effects] + : []; + for (const effect of effects) { + const widget = effect?.value?.widget; + if (widget && typeof widget.createInputDOM === 'function') { + widgetRef = widget; + widget.createInputDOM(); + } + } + }); + const editorView = { + state: { + doc: { + line: jest.fn(() => ({ from: 0 })), + lineAt: jest.fn(() => ({ from: 0 })), + }, + }, + dispatch, + dom: { + ownerDocument: (global as any).document, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + } as any; + + jest.spyOn(editorUtils, 'getEditorView').mockReturnValue(editorView); + + const editContext: InlineEditContext = { + mode: 'cursor', + cursorContext: { + beforeCursor: '', + afterCursor: '', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md'); + const resultPromise = modal.openAndWait(); + + const { SlashCommandDropdown } = jest.requireMock('@/shared/components/SlashCommandDropdown'); + const constructorCall = SlashCommandDropdown.mock.calls[0]; + expect(Array.from(constructorCall[3].hiddenCommands)).toEqual(['analyze']); + + widgetRef?.reject(); + await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); + } finally { + (global as any).document = originalDocument; + } + }); + + it('passes the active chat runtime model into inline edit services when available', async () => { + const originalDocument = (global as any).document; + (global as any).document = { + body: createMockEl('body'), + createElement: (tagName: string) => createMockEl(tagName), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + try { + const app = { + vault: { + getFiles: jest.fn().mockReturnValue([]), + getAllLoadedFiles: jest.fn().mockReturnValue([]), + }, + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const inlineEditService = { + cancel: jest.fn(), + continueConversation: jest.fn(), + editText: jest.fn(), + resetConversation: jest.fn(), + setModelOverride: jest.fn(), + }; + const providerSpy = jest + .spyOn(ProviderRegistry, 'createInlineEditService') + .mockReturnValue(inlineEditService as any); + const plugin = { + settings: { + hiddenProviderCommands: { + claude: [], + opencode: [], + }, + }, + getConversationSync: jest.fn().mockReturnValue(null), + getView: jest.fn().mockReturnValue({ + getActiveTab: jest.fn().mockReturnValue({ + conversationId: null, + draftModel: 'opencode:openai/gpt-5.4', + providerId: 'opencode', + service: { + getAuxiliaryModel: jest.fn().mockReturnValue('opencode:openai/gpt-5.4'), + providerId: 'opencode', + }, + }), + }), + } as any; + plugin.providerHost = plugin; + const editor = {} as any; + const view = { editor } as any; + + let widgetRef: any = null; + const dispatch = jest.fn((transaction: any) => { + const effects = Array.isArray(transaction?.effects) + ? transaction.effects + : transaction?.effects + ? [transaction.effects] + : []; + for (const effect of effects) { + const widget = effect?.value?.widget; + if (widget && typeof widget.createInputDOM === 'function') { + widgetRef = widget; + widget.createInputDOM(); + } + } + }); + const editorView = { + state: { + doc: { + line: jest.fn(() => ({ from: 0 })), + lineAt: jest.fn(() => ({ from: 0 })), + }, + }, + dispatch, + dom: { + ownerDocument: (global as any).document, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + } as any; + + const getEditorViewSpy = jest + .spyOn(editorUtils, 'getEditorView') + .mockReturnValue(editorView); + + const editContext: InlineEditContext = { + mode: 'cursor', + cursorContext: { + beforeCursor: '', + afterCursor: '', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md'); + const resultPromise = modal.openAndWait(); + + expect(providerSpy).toHaveBeenCalledWith(plugin, 'opencode'); + expect(inlineEditService.setModelOverride).toHaveBeenCalledWith('opencode:openai/gpt-5.4'); + + widgetRef.reject(); + await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); + getEditorViewSpy.mockRestore(); + providerSpy.mockRestore(); + } finally { + (global as any).document = originalDocument; + } + }); + + it('passes the bound conversation model into inline edit services before runtime initialization', async () => { + const originalDocument = (global as any).document; + (global as any).document = { + body: createMockEl('body'), + createElement: (tagName: string) => createMockEl(tagName), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + try { + const app = { + vault: { + getFiles: jest.fn().mockReturnValue([]), + getAllLoadedFiles: jest.fn().mockReturnValue([]), + }, + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const inlineEditService = { + cancel: jest.fn(), + continueConversation: jest.fn(), + editText: jest.fn(), + resetConversation: jest.fn(), + setModelOverride: jest.fn(), + }; + const providerSpy = jest + .spyOn(ProviderRegistry, 'createInlineEditService') + .mockReturnValue(inlineEditService as any); + const conversation = { + id: 'conv-1', + providerId: 'opencode', + selectedModel: 'opencode:anthropic/claude-sonnet-4', + }; + const plugin = { + settings: { + hiddenProviderCommands: { + claude: [], + opencode: [], + }, + }, + getConversationSync: jest.fn().mockReturnValue(conversation), + getView: jest.fn().mockReturnValue({ + getActiveTab: jest.fn().mockReturnValue({ + conversationId: 'conv-1', + draftModel: null, + providerId: 'opencode', + service: null, + }), + }), + } as any; + plugin.providerHost = plugin; + const editor = {} as any; + const view = { editor } as any; + + let widgetRef: any = null; + const dispatch = jest.fn((transaction: any) => { + const effects = Array.isArray(transaction?.effects) + ? transaction.effects + : transaction?.effects + ? [transaction.effects] + : []; + for (const effect of effects) { + const widget = effect?.value?.widget; + if (widget && typeof widget.createInputDOM === 'function') { + widgetRef = widget; + widget.createInputDOM(); + } + } + }); + const editorView = { + state: { + doc: { + line: jest.fn(() => ({ from: 0 })), + lineAt: jest.fn(() => ({ from: 0 })), + }, + }, + dispatch, + dom: { + ownerDocument: (global as any).document, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + } as any; + + const getEditorViewSpy = jest + .spyOn(editorUtils, 'getEditorView') + .mockReturnValue(editorView); + + const editContext: InlineEditContext = { + mode: 'cursor', + cursorContext: { + beforeCursor: '', + afterCursor: '', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md'); + const resultPromise = modal.openAndWait(); + + expect(providerSpy).toHaveBeenCalledWith(plugin, 'opencode'); + expect(inlineEditService.setModelOverride).toHaveBeenCalledWith('opencode:anthropic/claude-sonnet-4'); + + widgetRef.reject(); + await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); + getEditorViewSpy.mockRestore(); + providerSpy.mockRestore(); + } finally { + (global as any).document = originalDocument; + } + }); + + it('shows a single notice and degrades gracefully when getFiles throws', async () => { + const originalDocument = (global as any).document; + (global as any).document = { + body: createMockEl('body'), + createElement: (tagName: string) => createMockEl(tagName), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + try { + const app = { + vault: { + adapter: { basePath: '/vault' }, + getFiles: jest.fn().mockImplementation(() => { + throw new Error('vault unavailable'); + }), + getAllLoadedFiles: jest.fn().mockReturnValue([]), + }, + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const plugin = { + settings: { + hiddenProviderCommands: { + claude: [], + codex: [], + }, + }, + getSdkCommands: jest.fn().mockReturnValue([]), + } as any; + plugin.providerHost = plugin; + const editor = {} as any; + const view = { editor } as any; + + let widgetRef: any = null; + const dispatch = jest.fn((transaction: any) => { + const effects = Array.isArray(transaction?.effects) + ? transaction.effects + : transaction?.effects + ? [transaction.effects] + : []; + for (const effect of effects) { + const widget = effect?.value?.widget; + if (widget && typeof widget.createInputDOM === 'function') { + widgetRef = widget; + widget.createInputDOM(); + } + } + }); + const editorView = { + state: { + doc: { + line: jest.fn(() => ({ from: 0 })), + lineAt: jest.fn(() => ({ from: 0, number: 1 })), + }, + }, + dispatch, + dom: { + ownerDocument: (global as any).document, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + } as any; + + const getEditorViewSpy = jest + .spyOn(editorUtils, 'getEditorView') + .mockReturnValue(editorView); + + const { externalContextScanner } = jest.requireMock('@/utils/externalContextScanner'); + (externalContextScanner.scanPaths as jest.Mock).mockImplementation((paths: string[]) => { + if (paths[0] === '/external') { + return [ + { + path: '/external/src/app.md', + name: 'app.md', + relativePath: 'src/app.md', + contextRoot: '/external', + mtime: 1000, + }, + ]; + } + return []; + }); + + const editContext: InlineEditContext = { + mode: 'cursor', + cursorContext: { + beforeCursor: '', + afterCursor: '', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const modal = new InlineEditModal( + app, + plugin, + editor, + view, + editContext, + 'note.md', + () => ['/external'] + ); + const resultPromise = modal.openAndWait(); + + const callbacks = mentionDropdownCtor.mock.calls[0]?.[2]; + expect(callbacks.getCachedVaultFiles()).toEqual([]); + expect(callbacks.getCachedVaultFiles()).toEqual([]); + + const editTextMock = jest.fn().mockResolvedValue({ + success: true, + clarification: 'Need more detail', + }); + widgetRef.inlineEditService = { + editText: editTextMock, + continueConversation: jest.fn(), + cancel: jest.fn(), + resetConversation: jest.fn(), + }; + + widgetRef.inputEl.value = 'Please check @external/src/app.md.'; + await widgetRef.generate(); + + expect(editTextMock).toHaveBeenCalledTimes(1); + expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['/external/src/app.md']); + + const noticeMock = Notice as unknown as jest.Mock; + expect(noticeMock).toHaveBeenCalledTimes(1); + expect(noticeMock).toHaveBeenCalledWith( + 'Failed to load vault files. Vault @-mentions may be unavailable.' + ); + + widgetRef.reject(); + await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); + getEditorViewSpy.mockRestore(); + } finally { + (global as any).document = originalDocument; + } + }); + + it('parses @mentions into contextFiles at send time without dropdown attachment state', async () => { + const originalDocument = (global as any).document; + (global as any).document = { + body: createMockEl('body'), + createElement: (tagName: string) => createMockEl(tagName), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + try { + const app = { + vault: { + adapter: { basePath: '/vault' }, + getFiles: jest.fn().mockReturnValue([ + { path: 'notes/a.md' }, + { path: 'notes/b.md' }, + ]), + getAllLoadedFiles: jest.fn().mockReturnValue([]), + }, + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const plugin = { + settings: { + hiddenProviderCommands: { + claude: [], + codex: [], + }, + }, + getSdkCommands: jest.fn().mockReturnValue([]), + } as any; + plugin.providerHost = plugin; + const editor = {} as any; + const view = { editor } as any; + + let widgetRef: any = null; + const dispatch = jest.fn((transaction: any) => { + const effects = Array.isArray(transaction?.effects) + ? transaction.effects + : transaction?.effects + ? [transaction.effects] + : []; + for (const effect of effects) { + const widget = effect?.value?.widget; + if (widget && typeof widget.createInputDOM === 'function') { + widgetRef = widget; + widget.createInputDOM(); + } + } + }); + const editorView = { + state: { + doc: { + line: jest.fn(() => ({ from: 0 })), + lineAt: jest.fn(() => ({ from: 0, number: 1 })), + }, + }, + dispatch, + dom: { + ownerDocument: (global as any).document, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + } as any; + + const getEditorViewSpy = jest + .spyOn(editorUtils, 'getEditorView') + .mockReturnValue(editorView); + + const editContext: InlineEditContext = { + mode: 'cursor', + cursorContext: { + beforeCursor: '', + afterCursor: '', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md'); + const resultPromise = modal.openAndWait(); + + const editTextMock = jest.fn().mockResolvedValue({ + success: true, + clarification: 'Need more detail', + }); + widgetRef.inlineEditService = { + editText: editTextMock, + continueConversation: jest.fn(), + cancel: jest.fn(), + resetConversation: jest.fn(), + }; + + widgetRef.inputEl.value = 'Please check @notes/a.md and @notes/a.md.'; + await widgetRef.generate(); + + expect(editTextMock).toHaveBeenCalledTimes(1); + expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['notes/a.md']); + + widgetRef.reject(); + await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); + getEditorViewSpy.mockRestore(); + } finally { + (global as any).document = originalDocument; + } + }); + + it('resolves external context @mentions into contextFiles at send time', async () => { + const originalDocument = (global as any).document; + (global as any).document = { + body: createMockEl('body'), + createElement: (tagName: string) => createMockEl(tagName), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + try { + const app = { + vault: { + adapter: { basePath: '/vault' }, + getFiles: jest.fn().mockReturnValue([{ path: 'notes/local.md' }]), + getAllLoadedFiles: jest.fn().mockReturnValue([]), + }, + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const plugin = { + settings: { + hiddenProviderCommands: { + claude: [], + codex: [], + }, + }, + getSdkCommands: jest.fn().mockReturnValue([]), + } as any; + plugin.providerHost = plugin; + const editor = {} as any; + const view = { editor } as any; + + let widgetRef: any = null; + const dispatch = jest.fn((transaction: any) => { + const effects = Array.isArray(transaction?.effects) + ? transaction.effects + : transaction?.effects + ? [transaction.effects] + : []; + for (const effect of effects) { + const widget = effect?.value?.widget; + if (widget && typeof widget.createInputDOM === 'function') { + widgetRef = widget; + widget.createInputDOM(); + } + } + }); + const editorView = { + state: { + doc: { + line: jest.fn(() => ({ from: 0 })), + lineAt: jest.fn(() => ({ from: 0, number: 1 })), + }, + }, + dispatch, + dom: { + ownerDocument: (global as any).document, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + } as any; + + const getEditorViewSpy = jest + .spyOn(editorUtils, 'getEditorView') + .mockReturnValue(editorView); + + const editContext: InlineEditContext = { + mode: 'cursor', + cursorContext: { + beforeCursor: '', + afterCursor: '', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const { externalContextScanner } = jest.requireMock('@/utils/externalContextScanner'); + (externalContextScanner.scanPaths as jest.Mock).mockImplementation((paths: string[]) => { + if (paths[0] === '/external') { + return [ + { + path: '/external/src/app.md', + name: 'app.md', + relativePath: 'src/app.md', + contextRoot: '/external', + mtime: 1000, + }, + ]; + } + return []; + }); + + const modal = new InlineEditModal( + app, + plugin, + editor, + view, + editContext, + 'note.md', + () => ['/external'] + ); + const resultPromise = modal.openAndWait(); + + const editTextMock = jest.fn().mockResolvedValue({ + success: true, + clarification: 'Need more detail', + }); + widgetRef.inlineEditService = { + editText: editTextMock, + continueConversation: jest.fn(), + cancel: jest.fn(), + resetConversation: jest.fn(), + }; + + widgetRef.inputEl.value = 'Please check @external/src/app.md.'; + await widgetRef.generate(); + + expect(editTextMock).toHaveBeenCalledTimes(1); + expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['/external/src/app.md']); + + widgetRef.reject(); + await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); + getEditorViewSpy.mockRestore(); + } finally { + (global as any).document = originalDocument; + } + }); + + it('parses vault @mentions with spaces into contextFiles at send time', async () => { + const originalDocument = (global as any).document; + (global as any).document = { + body: createMockEl('body'), + createElement: (tagName: string) => createMockEl(tagName), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + try { + const app = { + vault: { + adapter: { basePath: '/vault' }, + getFiles: jest.fn().mockReturnValue([ + { path: 'notes/my note.md' }, + ]), + getAllLoadedFiles: jest.fn().mockReturnValue([]), + }, + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const plugin = { + settings: { + hiddenProviderCommands: { + claude: [], + codex: [], + }, + }, + getSdkCommands: jest.fn().mockReturnValue([]), + } as any; + plugin.providerHost = plugin; + const editor = {} as any; + const view = { editor } as any; + + let widgetRef: any = null; + const dispatch = jest.fn((transaction: any) => { + const effects = Array.isArray(transaction?.effects) + ? transaction.effects + : transaction?.effects + ? [transaction.effects] + : []; + for (const effect of effects) { + const widget = effect?.value?.widget; + if (widget && typeof widget.createInputDOM === 'function') { + widgetRef = widget; + widget.createInputDOM(); + } + } + }); + const editorView = { + state: { + doc: { + line: jest.fn(() => ({ from: 0 })), + lineAt: jest.fn(() => ({ from: 0, number: 1 })), + }, + }, + dispatch, + dom: { + ownerDocument: (global as any).document, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + } as any; + + const getEditorViewSpy = jest + .spyOn(editorUtils, 'getEditorView') + .mockReturnValue(editorView); + + const editContext: InlineEditContext = { + mode: 'cursor', + cursorContext: { + beforeCursor: '', + afterCursor: '', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'note.md'); + const resultPromise = modal.openAndWait(); + + const editTextMock = jest.fn().mockResolvedValue({ + success: true, + clarification: 'Need more detail', + }); + widgetRef.inlineEditService = { + editText: editTextMock, + continueConversation: jest.fn(), + cancel: jest.fn(), + resetConversation: jest.fn(), + }; + + widgetRef.inputEl.value = 'Please check @notes/my note.md.'; + await widgetRef.generate(); + + expect(editTextMock).toHaveBeenCalledTimes(1); + expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['notes/my note.md']); + + widgetRef.reject(); + await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); + getEditorViewSpy.mockRestore(); + } finally { + (global as any).document = originalDocument; + } + }); + + it('resolves external @mentions when vault has no files', async () => { + const originalDocument = (global as any).document; + (global as any).document = { + body: createMockEl('body'), + createElement: (tagName: string) => createMockEl(tagName), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + try { + const app = { + vault: { + adapter: { basePath: '/vault' }, + getFiles: jest.fn().mockReturnValue([]), + getAllLoadedFiles: jest.fn().mockReturnValue([]), + }, + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const plugin = { + settings: { + hiddenProviderCommands: { + claude: [], + codex: [], + }, + }, + getSdkCommands: jest.fn().mockReturnValue([]), + } as any; + plugin.providerHost = plugin; + const editor = {} as any; + const view = { editor } as any; + + let widgetRef: any = null; + const dispatch = jest.fn((transaction: any) => { + const effects = Array.isArray(transaction?.effects) + ? transaction.effects + : transaction?.effects + ? [transaction.effects] + : []; + for (const effect of effects) { + const widget = effect?.value?.widget; + if (widget && typeof widget.createInputDOM === 'function') { + widgetRef = widget; + widget.createInputDOM(); + } + } + }); + const editorView = { + state: { + doc: { + line: jest.fn(() => ({ from: 0 })), + lineAt: jest.fn(() => ({ from: 0, number: 1 })), + }, + }, + dispatch, + dom: { + ownerDocument: (global as any).document, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + } as any; + + const getEditorViewSpy = jest + .spyOn(editorUtils, 'getEditorView') + .mockReturnValue(editorView); + + const editContext: InlineEditContext = { + mode: 'cursor', + cursorContext: { + beforeCursor: '', + afterCursor: '', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const { externalContextScanner } = jest.requireMock('@/utils/externalContextScanner'); + (externalContextScanner.scanPaths as jest.Mock).mockImplementation((paths: string[]) => { + if (paths[0] === '/external') { + return [ + { + path: '/external/src/my file.md', + name: 'my file.md', + relativePath: 'src/my file.md', + contextRoot: '/external', + mtime: 1000, + }, + ]; + } + return []; + }); + + const modal = new InlineEditModal( + app, + plugin, + editor, + view, + editContext, + 'note.md', + () => ['/external'] + ); + const resultPromise = modal.openAndWait(); + + const editTextMock = jest.fn().mockResolvedValue({ + success: true, + clarification: 'Need more detail', + }); + widgetRef.inlineEditService = { + editText: editTextMock, + continueConversation: jest.fn(), + cancel: jest.fn(), + resetConversation: jest.fn(), + }; + + widgetRef.inputEl.value = 'Please check @external/src/my file.md.'; + await widgetRef.generate(); + + expect(editTextMock).toHaveBeenCalledTimes(1); + expect(editTextMock.mock.calls[0][0].contextFiles).toEqual(['/external/src/my file.md']); + + widgetRef.reject(); + await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); + getEditorViewSpy.mockRestore(); + } finally { + (global as any).document = originalDocument; + } + }); + + it('renders clarification replies as markdown with the active note path', async () => { + const originalDocument = (global as any).document; + (global as any).document = { + body: createMockEl('body'), + createElement: (tagName: string) => createMockEl(tagName), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + try { + const app = { + vault: { + getFiles: jest.fn().mockReturnValue([]), + getAllLoadedFiles: jest.fn().mockReturnValue([]), + }, + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const plugin = { + settings: { + hiddenProviderCommands: { + claude: [], + codex: [], + }, + mediaFolder: '', + }, + getSdkCommands: jest.fn().mockReturnValue([]), + } as any; + plugin.providerHost = plugin; + const editor = {} as any; + const view = { editor } as any; + + let widgetRef: any = null; + const dispatch = jest.fn((transaction: any) => { + const effects = Array.isArray(transaction?.effects) + ? transaction.effects + : transaction?.effects + ? [transaction.effects] + : []; + for (const effect of effects) { + const widget = effect?.value?.widget; + if (widget && typeof widget.createInputDOM === 'function') { + widgetRef = widget; + widget.createInputDOM(); + } + } + }); + const editorView = { + state: { + doc: { + line: jest.fn(() => ({ from: 0 })), + lineAt: jest.fn(() => ({ from: 0, number: 1 })), + }, + }, + dispatch, + dom: { + ownerDocument: (global as any).document, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + } as any; + + const getEditorViewSpy = jest + .spyOn(editorUtils, 'getEditorView') + .mockReturnValue(editorView); + + const editContext: InlineEditContext = { + mode: 'cursor', + cursorContext: { + beforeCursor: '', + afterCursor: '', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'math/note.md'); + const resultPromise = modal.openAndWait(); + + (MarkdownRenderer.renderMarkdown as jest.Mock).mockClear(); + widgetRef.showAgentReply('Should this use $Z(f)$?'); + await Promise.resolve(); + await Promise.resolve(); + + expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalledWith( + 'Should this use $Z(f)$?', + expect.anything(), + 'math/note.md', + plugin + ); + + widgetRef.reject(); + await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); + getEditorViewSpy.mockRestore(); + } finally { + (global as any).document = originalDocument; + } + }); + + it('does not let stale clarification markdown renders overwrite newer replies', async () => { + const originalDocument = (global as any).document; + (global as any).document = { + body: createMockEl('body'), + createElement: (tagName: string) => createMockEl(tagName), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + try { + const app = { + vault: { + getFiles: jest.fn().mockReturnValue([]), + getAllLoadedFiles: jest.fn().mockReturnValue([]), + }, + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const plugin = { + settings: { + hiddenProviderCommands: { + claude: [], + codex: [], + }, + mediaFolder: '', + }, + getSdkCommands: jest.fn().mockReturnValue([]), + } as any; + plugin.providerHost = plugin; + const editor = {} as any; + const view = { editor } as any; + + let widgetRef: any = null; + const dispatch = jest.fn((transaction: any) => { + const effects = Array.isArray(transaction?.effects) + ? transaction.effects + : transaction?.effects + ? [transaction.effects] + : []; + for (const effect of effects) { + const widget = effect?.value?.widget; + if (widget && typeof widget.createInputDOM === 'function') { + widgetRef = widget; + widget.createInputDOM(); + } + } + }); + const editorView = { + state: { + doc: { + line: jest.fn(() => ({ from: 0 })), + lineAt: jest.fn(() => ({ from: 0, number: 1 })), + }, + }, + dispatch, + dom: { + ownerDocument: (global as any).document, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + } as any; + + const getEditorViewSpy = jest + .spyOn(editorUtils, 'getEditorView') + .mockReturnValue(editorView); + + const firstRender = createDeferred(); + const secondRender = createDeferred(); + (MarkdownRenderer.renderMarkdown as jest.Mock) + .mockImplementationOnce((markdown: string, container: any) => { + container.createDiv({ text: markdown }); + return firstRender.promise; + }) + .mockImplementationOnce((markdown: string, container: any) => { + container.createDiv({ text: markdown }); + return secondRender.promise; + }); + + const editContext: InlineEditContext = { + mode: 'cursor', + cursorContext: { + beforeCursor: '', + afterCursor: '', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'math/note.md'); + const resultPromise = modal.openAndWait(); + + widgetRef.showAgentReply('First clarification'); + widgetRef.showAgentReply('Second clarification'); + + secondRender.resolve(); + await secondRender.promise; + await Promise.resolve(); + await Promise.resolve(); + + expect(widgetRef.agentReplyEl.children).toHaveLength(1); + expect(widgetRef.agentReplyEl.children[0].textContent).toBe('Second clarification'); + + firstRender.resolve(); + await firstRender.promise; + await Promise.resolve(); + await Promise.resolve(); + + expect(widgetRef.agentReplyEl.children).toHaveLength(1); + expect(widgetRef.agentReplyEl.children[0].textContent).toBe('Second clarification'); + + widgetRef.reject(); + await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); + getEditorViewSpy.mockRestore(); + } finally { + (global as any).document = originalDocument; + } + }); + + it('renders accept and reject controls in the block preview', async () => { + const originalDocument = (global as any).document; + (global as any).document = { + body: createMockEl('body'), + createElement: (tagName: string) => createMockEl(tagName), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + try { + const app = { + vault: { + getFiles: jest.fn().mockReturnValue([]), + getAllLoadedFiles: jest.fn().mockReturnValue([]), + }, + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const plugin = { + settings: { + hiddenProviderCommands: { + claude: [], + codex: [], + }, + mediaFolder: '', + }, + getSdkCommands: jest.fn().mockReturnValue([]), + } as any; + plugin.providerHost = plugin; + const editor = {} as any; + const view = { editor } as any; + + let widgetRef: any = null; + const dispatch = jest.fn((transaction: any) => { + const effects = Array.isArray(transaction?.effects) + ? transaction.effects + : transaction?.effects + ? [transaction.effects] + : []; + for (const effect of effects) { + const widget = effect?.value?.widget; + if (widget && typeof widget.createInputDOM === 'function') { + widgetRef = widget; + widget.createInputDOM(); + } + } + }); + const editorView = { + state: { + doc: { + line: jest.fn(() => ({ from: 0 })), + lineAt: jest.fn(() => ({ from: 0, number: 1 })), + }, + }, + dispatch, + dom: { + ownerDocument: (global as any).document, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + } as any; + + const getEditorViewSpy = jest + .spyOn(editorUtils, 'getEditorView') + .mockReturnValue(editorView); + + const editContext: InlineEditContext = { + mode: 'cursor', + cursorContext: { + beforeCursor: '', + afterCursor: '', + isInbetween: true, + line: 0, + column: 0, + }, + }; + + const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'math/note.md'); + const resultPromise = modal.openAndWait(); + + const rejectSpy = jest.spyOn(widgetRef, 'reject').mockImplementation(() => {}); + const acceptSpy = jest.spyOn(widgetRef, 'accept').mockImplementation(() => {}); + + const previewEl = widgetRef.createDiffPreviewDOM([ + { type: 'insert', text: 'Updated text' }, + ]); + const actionBar = previewEl.querySelector('.claudian-inline-preview-actions'); + const actionButtons = previewEl.querySelectorAll('.claudian-inline-preview-action'); + + expect(actionBar).not.toBeNull(); + expect(actionButtons).toHaveLength(2); + expect(actionButtons[0].textContent).toBe('Reject'); + expect(actionButtons[1].textContent).toBe('Accept'); + + actionButtons[0].click(); + actionButtons[1].click(); + + expect(rejectSpy).toHaveBeenCalledTimes(1); + expect(acceptSpy).toHaveBeenCalledTimes(1); + + rejectSpy.mockRestore(); + acceptSpy.mockRestore(); + widgetRef.reject(); + await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); + getEditorViewSpy.mockRestore(); + } finally { + (global as any).document = originalDocument; + } + }); + + it('renders markdown diff documents with block context', async () => { + const originalDocument = (global as any).document; + (global as any).document = { + body: createMockEl('body'), + createElement: (tagName: string) => createMockEl(tagName), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + + try { + const app = { + vault: { + getFiles: jest.fn().mockReturnValue([]), + getAllLoadedFiles: jest.fn().mockReturnValue([]), + }, + workspace: { + getActiveViewOfType: jest.fn(), + }, + } as any; + const plugin = { + settings: { + hiddenProviderCommands: { + claude: [], + codex: [], + }, + mediaFolder: '', + }, + getSdkCommands: jest.fn().mockReturnValue([]), + } as any; + const oldMarkdown = '```ts\nconst value = 1;\n```'; + const newMarkdown = '```ts\nconst value = 2;\n```'; + plugin.providerHost = plugin; + const editor = { + getCursor: jest.fn((which: string) => which === 'from' + ? { line: 0, ch: 0 } + : { line: 0, ch: 3 }), + getSelection: jest.fn().mockReturnValue(oldMarkdown), + } as any; + const view = { editor } as any; + + let widgetRef: any = null; + let diffOps: Array<{ type: string; text: string }> | undefined; + let hasPreviewText = false; + const dispatch = jest.fn((transaction: any) => { + const effects = Array.isArray(transaction?.effects) + ? transaction.effects + : transaction?.effects + ? [transaction.effects] + : []; + for (const effect of effects) { + if (effect?.value?.diffOps) { + diffOps = effect.value.diffOps; + } + if (effect?.value?.previewText) { + hasPreviewText = true; + } + + const widget = effect?.value?.widget; + if (widget && typeof widget.createInputDOM === 'function' && !widgetRef) { + widgetRef = widget; + widget.createInputDOM(); + } + } + }); + const editorView = { + state: { + doc: { + line: jest.fn(() => ({ from: 0 })), + lineAt: jest.fn(() => ({ from: 0, number: 1 })), + }, + }, + dispatch, + dom: { + ownerDocument: (global as any).document, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }, + } as any; + + const getEditorViewSpy = jest + .spyOn(editorUtils, 'getEditorView') + .mockReturnValue(editorView); + + const editContext: InlineEditContext = { + mode: 'selection', + selectedText: oldMarkdown, + }; + + const modal = new InlineEditModal(app, plugin, editor, view, editContext, 'math/note.md'); + const resultPromise = modal.openAndWait(); + widgetRef.inlineEditService = { + editText: jest.fn().mockResolvedValue({ + success: true, + editedText: newMarkdown, + }), + continueConversation: jest.fn(), + cancel: jest.fn(), + resetConversation: jest.fn(), + }; + + widgetRef.inputEl.value = 'Improve the statement'; + await widgetRef.generate(); + + expect(diffOps).toEqual([ + { type: 'equal', text: '```ts\n' }, + { type: 'delete', text: 'const value = 1;\n' }, + { type: 'insert', text: 'const value = 2;\n' }, + { type: 'equal', text: '```' }, + ]); + expect(hasPreviewText).toBe(false); + + (MarkdownRenderer.renderMarkdown as jest.Mock).mockClear(); + const previewEl = widgetRef.createDiffPreviewDOM(diffOps); + for (let i = 0; i < 5 && (MarkdownRenderer.renderMarkdown as jest.Mock).mock.calls.length < 2; i++) { + await Promise.resolve(); + } + + expect(MarkdownRenderer.renderMarkdown).toHaveBeenNthCalledWith( + 1, + oldMarkdown, + expect.anything(), + 'math/note.md', + plugin + ); + expect(MarkdownRenderer.renderMarkdown).toHaveBeenNthCalledWith( + 2, + newMarkdown, + expect.anything(), + 'math/note.md', + plugin + ); + + const diffBlocks = previewEl.querySelectorAll('.claudian-diff-block'); + expect(diffBlocks).toHaveLength(2); + expect(diffBlocks[0].hasClass('claudian-diff-del')).toBe(true); + expect(diffBlocks[1].hasClass('claudian-diff-ins')).toBe(true); + + widgetRef.reject(); + await expect(resultPromise).resolves.toEqual({ decision: 'reject' }); + getEditorViewSpy.mockRestore(); + } finally { + (global as any).document = originalDocument; + } + }); +}); diff --git a/tests/unit/features/inline-edit/ui/InlineEditModal.test.ts b/tests/unit/features/inline-edit/ui/InlineEditModal.test.ts new file mode 100644 index 0000000..8e848b5 --- /dev/null +++ b/tests/unit/features/inline-edit/ui/InlineEditModal.test.ts @@ -0,0 +1,495 @@ +import '@/providers'; + +import { Text } from '@codemirror/state'; +import { WidgetType } from '@codemirror/view'; + +import { buildInlineEditInputDecorations } from '@/features/inline-edit/ui/InlineEditModal'; +import { escapeHtml, normalizeInsertionText } from '@/utils/inlineEdit'; +import { normalizePathForVault } from '@/utils/path'; + +class TestWidget extends WidgetType { + toDOM(): HTMLElement { + return {} as HTMLElement; + } +} + +// Copy of the diff algorithm from InlineEditModal for testing +interface DiffOp { + type: 'equal' | 'insert' | 'delete'; + text: string; +} + +function computeDiff(oldText: string, newText: string): DiffOp[] { + const oldWords = oldText.split(/(\s+)/); + const newWords = newText.split(/(\s+)/); + const m = oldWords.length, + n = newWords.length; + const dp: number[][] = Array(m + 1) + .fill(null) + .map(() => Array(n + 1).fill(0)); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = + oldWords[i - 1] === newWords[j - 1] + ? dp[i - 1][j - 1] + 1 + : Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + + const ops: DiffOp[] = []; + let i = m, + j = n; + const temp: DiffOp[] = []; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) { + temp.push({ type: 'equal', text: oldWords[i - 1] }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + temp.push({ type: 'insert', text: newWords[j - 1] }); + j--; + } else { + temp.push({ type: 'delete', text: oldWords[i - 1] }); + i--; + } + } + + temp.reverse(); + for (const op of temp) { + if (ops.length > 0 && ops[ops.length - 1].type === op.type) { + ops[ops.length - 1].text += op.text; + } else { + ops.push({ ...op }); + } + } + return ops; +} + +function diffToHtml(ops: DiffOp[]): string { + return ops + .map((op) => { + const escaped = op.text.replace(//g, '>'); + switch (op.type) { + case 'delete': + return `${escaped}`; + case 'insert': + return `${escaped}`; + default: + return escaped; + } + }) + .join(''); +} + +describe('InlineEditModal - Insertion Newline Trimming', () => { + it('builds line-start block widget decorations without range ordering errors', () => { + expect(() => buildInlineEditInputDecorations({ + doc: Text.of(['First line', 'Second line']), + inputPos: 0, + widget: new TestWidget(), + isInbetween: false, + })).not.toThrow(); + }); + + describe('normalizeInsertionText', () => { + it('should remove leading newlines', () => { + const input = '\n\nContent here'; + const result = normalizeInsertionText(input); + expect(result).toBe('Content here'); + }); + + it('should remove trailing newlines', () => { + const input = 'Content here\n\n'; + const result = normalizeInsertionText(input); + expect(result).toBe('Content here'); + }); + + it('should remove both leading and trailing newlines', () => { + const input = '\n\nContent here\n\n'; + const result = normalizeInsertionText(input); + expect(result).toBe('Content here'); + }); + + it('should preserve internal newlines', () => { + const input = '\n## Section\n\nParagraph content\n'; + const result = normalizeInsertionText(input); + expect(result).toBe('## Section\n\nParagraph content'); + }); + + it('should handle text with no newlines', () => { + const input = 'Simple text'; + const result = normalizeInsertionText(input); + expect(result).toBe('Simple text'); + }); + + it('should handle only newlines', () => { + const input = '\n\n\n'; + const result = normalizeInsertionText(input); + expect(result).toBe(''); + }); + + it('should handle empty string', () => { + const input = ''; + const result = normalizeInsertionText(input); + expect(result).toBe(''); + }); + + it('should not trim spaces (only newlines)', () => { + const input = ' Content with spaces '; + const result = normalizeInsertionText(input); + expect(result).toBe(' Content with spaces '); + }); + + it('should handle multiline markdown content', () => { + const input = '\n## Description\n\nThis project provides tools for managing notes.\n\n### Features\n- Feature 1\n- Feature 2\n'; + const result = normalizeInsertionText(input); + expect(result).toBe('## Description\n\nThis project provides tools for managing notes.\n\n### Features\n- Feature 1\n- Feature 2'); + }); + + it('should handle code blocks with newlines', () => { + const input = '\n```javascript\nconst x = 1;\n```\n'; + const result = normalizeInsertionText(input); + expect(result).toBe('```javascript\nconst x = 1;\n```'); + }); + + it('should handle CRLF newlines', () => { + const input = '\r\n\r\nContent\r\n'; + const result = normalizeInsertionText(input); + expect(result).toBe('Content'); + }); + }); +}); + +describe('inlineEditUtils - escapeHtml', () => { + it('should escape angle brackets and ampersands', () => { + expect(escapeHtml('a < b && c > d')).toBe('a < b && c > d'); + }); + + it('should escape ampersands', () => { + expect(escapeHtml('a & b')).toBe('a & b'); + }); + + it('should handle empty string', () => { + expect(escapeHtml('')).toBe(''); + }); +}); + +describe('InlineEditModal - Word-level Diff', () => { + describe('computeDiff', () => { + it('should return equal for identical text', () => { + const result = computeDiff('hello world', 'hello world'); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('equal'); + expect(result[0].text).toBe('hello world'); + }); + + it('should detect single word replacement', () => { + const result = computeDiff('hello world', 'hello universe'); + + const types = result.map((op) => op.type); + expect(types).toContain('equal'); + expect(types).toContain('delete'); + expect(types).toContain('insert'); + + const deleted = result.find((op) => op.type === 'delete'); + const inserted = result.find((op) => op.type === 'insert'); + expect(deleted?.text).toBe('world'); + expect(inserted?.text).toBe('universe'); + }); + + it('should detect word insertion', () => { + const result = computeDiff('hello world', 'hello beautiful world'); + + const inserted = result.find((op) => op.type === 'insert'); + expect(inserted).toBeDefined(); + expect(inserted?.text).toContain('beautiful'); + }); + + it('should detect word deletion', () => { + const result = computeDiff('hello beautiful world', 'hello world'); + + const deleted = result.find((op) => op.type === 'delete'); + expect(deleted).toBeDefined(); + expect(deleted?.text).toContain('beautiful'); + }); + + it('should handle complete replacement', () => { + const result = computeDiff('foo bar baz', 'one two three'); + + const deleted = result.filter((op) => op.type === 'delete'); + const inserted = result.filter((op) => op.type === 'insert'); + + expect(deleted.length).toBeGreaterThan(0); + expect(inserted.length).toBeGreaterThan(0); + }); + + it('should preserve whitespace in diff', () => { + const result = computeDiff('a b', 'a b'); + + // The diff should handle different amounts of whitespace + expect(result.length).toBeGreaterThanOrEqual(1); + }); + + it('should handle empty old text', () => { + const result = computeDiff('', 'new text'); + + const inserted = result.filter((op) => op.type === 'insert'); + expect(inserted.length).toBeGreaterThan(0); + }); + + it('should handle empty new text', () => { + const result = computeDiff('old text', ''); + + const deleted = result.filter((op) => op.type === 'delete'); + expect(deleted.length).toBeGreaterThan(0); + }); + + it('should merge consecutive operations of same type', () => { + const result = computeDiff('a b c', 'x y z'); + + // Should merge consecutive deletes and inserts + const types = result.map((op) => op.type); + // Check no two consecutive same types (they should be merged) + for (let i = 1; i < types.length; i++) { + if (types[i] === types[i - 1] && types[i] !== 'equal') { + // Whitespace might separate merged ops + // eslint-disable-next-line jest/no-conditional-expect + expect(result[i - 1].text.trim() || result[i].text.trim()).toBeTruthy(); + } + } + }); + + it('should handle text with punctuation', () => { + const result = computeDiff('Hello, world!', 'Hello, universe!'); + + const deleted = result.find((op) => op.type === 'delete'); + const inserted = result.find((op) => op.type === 'insert'); + + expect(deleted?.text).toContain('world'); + expect(inserted?.text).toContain('universe'); + }); + + it('should handle text with newlines', () => { + const result = computeDiff('line1\nline2', 'line1\nmodified'); + + const deleted = result.find((op) => op.type === 'delete'); + const inserted = result.find((op) => op.type === 'insert'); + + expect(deleted).toBeDefined(); + expect(inserted).toBeDefined(); + }); + + it('should handle multiline text', () => { + const oldText = 'First line\nSecond line\nThird line'; + const newText = 'First line\nModified line\nThird line'; + + const result = computeDiff(oldText, newText); + + expect(result.some((op) => op.type === 'delete')).toBe(true); + expect(result.some((op) => op.type === 'insert')).toBe(true); + }); + }); + + describe('diffToHtml', () => { + it('should return plain text for equal ops', () => { + const ops: DiffOp[] = [{ type: 'equal', text: 'hello' }]; + + const html = diffToHtml(ops); + + expect(html).toBe('hello'); + expect(html).not.toContain('span'); + }); + + it('should wrap deleted text with del class', () => { + const ops: DiffOp[] = [{ type: 'delete', text: 'removed' }]; + + const html = diffToHtml(ops); + + expect(html).toContain('claudian-diff-del'); + expect(html).toContain('removed'); + }); + + it('should wrap inserted text with ins class', () => { + const ops: DiffOp[] = [{ type: 'insert', text: 'added' }]; + + const html = diffToHtml(ops); + + expect(html).toContain('claudian-diff-ins'); + expect(html).toContain('added'); + }); + + it('should escape HTML special characters', () => { + const ops: DiffOp[] = [{ type: 'insert', text: '' }]; + + const html = diffToHtml(ops); + + expect(html).toContain('<script>'); + expect(html).not.toContain('.png]]', app); + + expect(result).not.toContain('']])); + const result = replaceImageEmbedsWithHtml('![[test.png]]', app); + + expect(result).not.toContain('')).toBe( + '<script>alert("x&y")</script>' + ); + }); + + it('returns text unchanged when no special characters', () => { + expect(escapeHtml('Hello World')).toBe('Hello World'); + }); + + it('handles empty string', () => { + expect(escapeHtml('')).toBe(''); + }); +}); diff --git a/tests/unit/utils/interrupt.test.ts b/tests/unit/utils/interrupt.test.ts new file mode 100644 index 0000000..c65538f --- /dev/null +++ b/tests/unit/utils/interrupt.test.ts @@ -0,0 +1,73 @@ +import { + isBracketInterruptText, + isCompactionCanceledStderr, + isInterruptSignalText, +} from '@/utils/interrupt'; + +describe('interrupt utils', () => { + describe('isBracketInterruptText', () => { + it('matches canonical SDK interrupt markers', () => { + expect(isBracketInterruptText('[Request interrupted by user]')).toBe(true); + expect(isBracketInterruptText('[Request interrupted by user for tool use]')).toBe(true); + }); + + it('matches canonical markers with surrounding whitespace', () => { + expect(isBracketInterruptText(' [Request interrupted by user] ')).toBe(true); + expect(isBracketInterruptText('\n[Request interrupted by user for tool use]\n')).toBe(true); + }); + + it('rejects partial and prefixed variants', () => { + expect(isBracketInterruptText('[Request interrupted by user] extra')).toBe(false); + expect(isBracketInterruptText('prefix [Request interrupted by user]')).toBe(false); + expect(isBracketInterruptText('[Request interrupted]')).toBe(false); + }); + }); + + describe('isCompactionCanceledStderr', () => { + it('matches canonical compaction stderr marker', () => { + expect( + isCompactionCanceledStderr( + 'Error: Compaction canceled.', + ), + ).toBe(true); + }); + + it('accepts whitespace around canonical compaction stderr marker', () => { + expect( + isCompactionCanceledStderr( + '\n Error: Compaction canceled. \n', + ), + ).toBe(true); + }); + + it('rejects embedded mentions and non-canonical wrappers', () => { + expect( + isCompactionCanceledStderr( + '## Context\\nError: Compaction canceled.', + ), + ).toBe(false); + expect( + isCompactionCanceledStderr( + 'Error: Compaction canceled.', + ), + ).toBe(false); + }); + }); + + describe('isInterruptSignalText', () => { + it('matches all supported interrupt markers', () => { + expect(isInterruptSignalText('[Request interrupted by user]')).toBe(true); + expect(isInterruptSignalText('[Request interrupted by user for tool use]')).toBe(true); + expect( + isInterruptSignalText( + 'Error: Compaction canceled.', + ), + ).toBe(true); + }); + + it('rejects regular content', () => { + expect(isInterruptSignalText('Hello')).toBe(false); + expect(isInterruptSignalText('Error: Timeout.')).toBe(false); + }); + }); +}); diff --git a/tests/unit/utils/markdown.test.ts b/tests/unit/utils/markdown.test.ts new file mode 100644 index 0000000..d1669af --- /dev/null +++ b/tests/unit/utils/markdown.test.ts @@ -0,0 +1,35 @@ +import { appendMarkdownSnippet } from '@/utils/markdown'; + +describe('appendMarkdownSnippet', () => { + it('returns existing prompt when snippet is empty', () => { + expect(appendMarkdownSnippet('Hello', '')).toBe('Hello'); + }); + + it('returns existing prompt when snippet is whitespace only', () => { + expect(appendMarkdownSnippet('Hello', ' \n ')).toBe('Hello'); + }); + + it('returns trimmed snippet when existing prompt is empty', () => { + expect(appendMarkdownSnippet('', ' Hello ')).toBe('Hello'); + }); + + it('returns trimmed snippet when existing prompt is whitespace only', () => { + expect(appendMarkdownSnippet(' ', ' Hello ')).toBe('Hello'); + }); + + it('adds double newline separator when prompt does not end with newline', () => { + expect(appendMarkdownSnippet('First', 'Second')).toBe('First\n\nSecond'); + }); + + it('adds single newline when prompt ends with one newline', () => { + expect(appendMarkdownSnippet('First\n', 'Second')).toBe('First\n\nSecond'); + }); + + it('adds no separator when prompt ends with double newline', () => { + expect(appendMarkdownSnippet('First\n\n', 'Second')).toBe('First\n\nSecond'); + }); + + it('trims the snippet before appending', () => { + expect(appendMarkdownSnippet('First', ' Second ')).toBe('First\n\nSecond'); + }); +}); diff --git a/tests/unit/utils/markdownMath.test.ts b/tests/unit/utils/markdownMath.test.ts new file mode 100644 index 0000000..9a595b6 --- /dev/null +++ b/tests/unit/utils/markdownMath.test.ts @@ -0,0 +1,54 @@ +import { + escapeMathDelimitersForStreaming, + hasStreamingMathDelimiters, +} from '@/utils/markdownMath'; + +describe('markdownMath', () => { + describe('escapeMathDelimitersForStreaming', () => { + it('escapes inline and display math delimiters outside code', () => { + expect(escapeMathDelimitersForStreaming('Use $x + y$ and $$z^2$$.')).toBe( + 'Use \\$x + y\\$ and \\$\\$z^2\\$\\$.' + ); + }); + + it('preserves inline code and fenced code dollars', () => { + const markdown = [ + 'Text $x$', + '`echo $PATH`', + '```bash', + 'echo "$HOME"', + '```', + 'Done $$y$$', + ].join('\n'); + + expect(escapeMathDelimitersForStreaming(markdown)).toBe([ + 'Text \\$x\\$', + '`echo $PATH`', + '```bash', + 'echo "$HOME"', + '```', + 'Done \\$\\$y\\$\\$', + ].join('\n')); + }); + + it('keeps already escaped dollars unchanged', () => { + expect(escapeMathDelimitersForStreaming('Cost is \\$5, math is $x$.')).toBe( + 'Cost is \\$5, math is \\$x\\$.' + ); + }); + + it('does not alter dollars inside raw html tag attributes', () => { + expect(escapeMathDelimitersForStreaming('value $y$')).toBe( + 'value \\$y\\$' + ); + }); + }); + + describe('hasStreamingMathDelimiters', () => { + it('detects unescaped dollars outside code', () => { + expect(hasStreamingMathDelimiters('math $x$')).toBe(true); + expect(hasStreamingMathDelimiters('`echo $PATH`')).toBe(false); + expect(hasStreamingMathDelimiters('\\$5')).toBe(false); + }); + }); +}); diff --git a/tests/unit/utils/mcp.test.ts b/tests/unit/utils/mcp.test.ts new file mode 100644 index 0000000..c3f643b --- /dev/null +++ b/tests/unit/utils/mcp.test.ts @@ -0,0 +1,256 @@ +import { extractMcpMentions, parseCommand, splitCommandString, transformMcpMentions } from '@/utils/mcp'; + +describe('extractMcpMentions', () => { + it('extracts valid MCP mentions', () => { + const validNames = new Set(['context7', 'server1']); + const text = 'Check @context7 and @server1 for info'; + const result = extractMcpMentions(text, validNames); + expect(result).toEqual(new Set(['context7', 'server1'])); + }); + + it('ignores invalid mentions', () => { + const validNames = new Set(['context7']); + const text = 'Check @unknown for info'; + const result = extractMcpMentions(text, validNames); + expect(result).toEqual(new Set()); + }); + + it('ignores context folder mentions (with /)', () => { + const validNames = new Set(['folder']); + const text = 'Check @folder/ for files'; + const result = extractMcpMentions(text, validNames); + expect(result).toEqual(new Set()); + }); +}); + +describe('transformMcpMentions', () => { + const validNames = new Set(['context7', 'server1']); + + it('appends MCP to valid mentions', () => { + const text = 'Check @context7 for info'; + const result = transformMcpMentions(text, validNames); + expect(result).toBe('Check @context7 MCP for info'); + }); + + it('transforms multiple mentions', () => { + const text = '@context7 and @server1'; + const result = transformMcpMentions(text, validNames); + expect(result).toBe('@context7 MCP and @server1 MCP'); + }); + + it('transforms duplicate mentions', () => { + const text = '@context7 then @context7 again'; + const result = transformMcpMentions(text, validNames); + expect(result).toBe('@context7 MCP then @context7 MCP again'); + }); + + it('does not double-transform if already has MCP', () => { + const text = '@context7 MCP for info'; + const result = transformMcpMentions(text, validNames); + expect(result).toBe('@context7 MCP for info'); + }); + + it('does not transform context folder mentions', () => { + const names = new Set(['folder']); + const text = '@folder/ for files'; + const result = transformMcpMentions(text, names); + expect(result).toBe('@folder/ for files'); + }); + + it('does not transform partial matches', () => { + const text = '@context7abc is different'; + const result = transformMcpMentions(text, validNames); + expect(result).toBe('@context7abc is different'); + }); + + it('handles overlapping names correctly (longer first)', () => { + const names = new Set(['context', 'context7']); + const text = '@context7 and @context'; + const result = transformMcpMentions(text, names); + expect(result).toBe('@context7 MCP and @context MCP'); + }); + + it('transforms mention at end of text', () => { + const text = 'Check @context7'; + const result = transformMcpMentions(text, validNames); + expect(result).toBe('Check @context7 MCP'); + }); + + it('transforms mention at start of text', () => { + const text = '@context7 is useful'; + const result = transformMcpMentions(text, validNames); + expect(result).toBe('@context7 MCP is useful'); + }); + + it('returns unchanged text when no valid names', () => { + const text = '@context7 for info'; + const result = transformMcpMentions(text, new Set()); + expect(result).toBe('@context7 for info'); + }); + + it('handles special regex characters in server name', () => { + const names = new Set(['test.server']); + const text = '@test.server for info'; + const result = transformMcpMentions(text, names); + expect(result).toBe('@test.server MCP for info'); + }); + + // Punctuation edge cases + it('transforms mention followed by period', () => { + const text = 'Check @context7.'; + const result = transformMcpMentions(text, validNames); + expect(result).toBe('Check @context7 MCP.'); + }); + + it('transforms mention followed by comma', () => { + const text = '@context7, please check'; + const result = transformMcpMentions(text, validNames); + expect(result).toBe('@context7 MCP, please check'); + }); + + it('transforms mention followed by colon', () => { + const text = '@context7: check this'; + const result = transformMcpMentions(text, validNames); + expect(result).toBe('@context7 MCP: check this'); + }); + + it('transforms mention followed by question mark', () => { + const text = 'Did you check @context7?'; + const result = transformMcpMentions(text, validNames); + expect(result).toBe('Did you check @context7 MCP?'); + }); + + it('does not transform partial match with dot-suffix', () => { + // @test should NOT match in @test.foo when only "test" is valid + const names = new Set(['test']); + const text = '@test.foo is unknown'; + const result = transformMcpMentions(text, names); + expect(result).toBe('@test.foo is unknown'); + }); + + it('transforms server with dot in name followed by period', () => { + const names = new Set(['test.server']); + const text = 'Check @test.server.'; + const result = transformMcpMentions(text, names); + expect(result).toBe('Check @test.server MCP.'); + }); + + // Multiline + it('transforms mentions across multiple lines', () => { + const text = 'First @context7\nSecond @server1'; + const result = transformMcpMentions(text, validNames); + expect(result).toBe('First @context7 MCP\nSecond @server1 MCP'); + }); + + it('transforms mention followed by newline', () => { + const text = '@context7\nmore text'; + const result = transformMcpMentions(text, validNames); + expect(result).toBe('@context7 MCP\nmore text'); + }); + + // Empty input + it('handles empty input text', () => { + const result = transformMcpMentions('', validNames); + expect(result).toBe(''); + }); +}); + +describe('splitCommandString', () => { + it('splits simple command', () => { + expect(splitCommandString('node server.js')).toEqual(['node', 'server.js']); + }); + + it('handles single word', () => { + expect(splitCommandString('claude')).toEqual(['claude']); + }); + + it('handles empty string', () => { + expect(splitCommandString('')).toEqual([]); + }); + + it('handles whitespace-only string', () => { + expect(splitCommandString(' ')).toEqual([]); + }); + + it('handles double-quoted arguments', () => { + expect(splitCommandString('echo "hello world"')).toEqual(['echo', 'hello world']); + }); + + it('handles single-quoted arguments', () => { + expect(splitCommandString("echo 'hello world'")).toEqual(['echo', 'hello world']); + }); + + it('handles multiple quoted arguments', () => { + expect(splitCommandString('cmd "arg one" "arg two"')).toEqual(['cmd', 'arg one', 'arg two']); + }); + + it('handles mixed quoted and unquoted arguments', () => { + expect(splitCommandString('cmd --flag "quoted arg" plain')).toEqual(['cmd', '--flag', 'quoted arg', 'plain']); + }); + + it('handles multiple spaces between arguments', () => { + expect(splitCommandString('cmd arg1 arg2')).toEqual(['cmd', 'arg1', 'arg2']); + }); + + it('handles leading and trailing whitespace', () => { + expect(splitCommandString(' cmd arg ')).toEqual(['cmd', 'arg']); + }); + + it('handles tab characters as whitespace', () => { + expect(splitCommandString('cmd\targ1\targ2')).toEqual(['cmd', 'arg1', 'arg2']); + }); + + it('preserves spaces inside quotes', () => { + expect(splitCommandString('"path with spaces/bin" --arg')).toEqual(['path with spaces/bin', '--arg']); + }); + + it('handles adjacent quoted and unquoted content', () => { + // Quotes are stripped, so "foo"bar becomes foobar + expect(splitCommandString('"foo"bar')).toEqual(['foobar']); + }); +}); + +describe('parseCommand', () => { + it('parses command with no arguments', () => { + expect(parseCommand('claude')).toEqual({ cmd: 'claude', args: [] }); + }); + + it('parses command with arguments', () => { + expect(parseCommand('node server.js --port 3000')).toEqual({ + cmd: 'node', + args: ['server.js', '--port', '3000'], + }); + }); + + it('uses providedArgs when given', () => { + expect(parseCommand('node', ['--version'])).toEqual({ + cmd: 'node', + args: ['--version'], + }); + }); + + it('ignores command string parsing when providedArgs is non-empty', () => { + expect(parseCommand('node server.js', ['--help'])).toEqual({ + cmd: 'node server.js', + args: ['--help'], + }); + }); + + it('falls back to parsing when providedArgs is empty array', () => { + expect(parseCommand('node server.js', [])).toEqual({ + cmd: 'node', + args: ['server.js'], + }); + }); + + it('handles empty command string', () => { + expect(parseCommand('')).toEqual({ cmd: '', args: [] }); + }); + + it('handles quoted arguments in command string', () => { + expect(parseCommand('echo "hello world" --verbose')).toEqual({ + cmd: 'echo', + args: ['hello world', '--verbose'], + }); + }); +}); diff --git a/tests/unit/utils/obsidianCompat.test.ts b/tests/unit/utils/obsidianCompat.test.ts new file mode 100644 index 0000000..7b79608 --- /dev/null +++ b/tests/unit/utils/obsidianCompat.test.ts @@ -0,0 +1,18 @@ +import type { Workspace, WorkspaceLeaf } from 'obsidian'; + +import { revealWorkspaceLeaf } from '@/utils/obsidianCompat'; + +describe('obsidianCompat', () => { + describe('revealWorkspaceLeaf', () => { + it('reveals the workspace leaf', async () => { + const leaf = {} as WorkspaceLeaf; + const workspace = { + revealLeaf: jest.fn().mockResolvedValue(undefined), + } as unknown as Workspace; + + await revealWorkspaceLeaf(workspace, leaf); + + expect((workspace as unknown as { revealLeaf: jest.Mock }).revealLeaf).toHaveBeenCalledWith(leaf); + }); + }); +}); diff --git a/tests/unit/utils/path.test.ts b/tests/unit/utils/path.test.ts new file mode 100644 index 0000000..bf9ca15 --- /dev/null +++ b/tests/unit/utils/path.test.ts @@ -0,0 +1,677 @@ +import type * as fsType from 'fs'; +import type * as osType from 'os'; +import type * as pathType from 'path'; + +const fs = jest.requireActual('fs'); +const os = jest.requireActual('os'); +const path = jest.requireActual('path'); + +import { findClaudeCLIPath } from '@/providers/claude/cli/findClaudeCLIPath'; +import { + expandHomePath, + getVaultPath, + isPathWithinDirectory, + isPathWithinVault, + normalizePathForComparison, + normalizePathForFilesystem, + normalizePathForVault, + parsePathEntries, + translateMsysPath, +} from '@/utils/path'; + +const isWindows = process.platform === 'win32'; + +describe('getVaultPath', () => { + it('returns basePath when adapter exposes the property directly', () => { + const mockApp = { + vault: { + adapter: { + basePath: '/Users/test/my-vault', + }, + }, + } as any; + + expect(getVaultPath(mockApp)).toBe('/Users/test/my-vault'); + }); + + it('returns basePath for wrapped adapters that fail `in` checks', () => { + const adapter = new Proxy( + { basePath: '/Users/test/wrapped-vault' }, + { + has: () => false, + }, + ); + + expect('basePath' in adapter).toBe(false); + expect(getVaultPath({ vault: { adapter } } as any)).toBe('/Users/test/wrapped-vault'); + }); + + it('returns null when adapter does not expose a string basePath', () => { + expect(getVaultPath({ vault: { adapter: {} } } as any)).toBeNull(); + expect(getVaultPath({ vault: { adapter: { basePath: 123 } } } as any)).toBeNull(); + }); + + it('returns null when adapter is undefined', () => { + expect(getVaultPath({ vault: { adapter: undefined } } as any)).toBeNull(); + }); + + it('preserves empty and platform-specific base paths', () => { + expect(getVaultPath({ vault: { adapter: { basePath: '' } } } as any)).toBe(''); + expect(getVaultPath({ vault: { adapter: { basePath: '/Users/test/My Obsidian Vault' } } } as any)).toBe( + '/Users/test/My Obsidian Vault', + ); + expect(getVaultPath({ vault: { adapter: { basePath: 'C:\\Users\\test\\vault' } } } as any)).toBe( + 'C:\\Users\\test\\vault', + ); + }); +}); + +describe('expandHomePath', () => { + it('expands ~ to home directory', () => { + expect(expandHomePath('~')).toBe(os.homedir()); + }); + + it('expands ~/ prefix', () => { + const result = expandHomePath('~/Documents'); + expect(result).toBe(path.join(os.homedir(), 'Documents')); + }); + + it('expands nested ~/path', () => { + const result = expandHomePath('~/a/b/c'); + expect(result).toBe(path.join(os.homedir(), 'a', 'b', 'c')); + }); + + it('returns non-tilde path unchanged', () => { + expect(expandHomePath('/usr/local/bin')).toBe('/usr/local/bin'); + }); + + it('does not expand ~ in middle of path', () => { + expect(expandHomePath('/some/~/path')).toBe('/some/~/path'); + }); + + it('expands $VAR format environment variables', () => { + const original = process.env.TEST_EXPAND_VAR; + process.env.TEST_EXPAND_VAR = '/custom/path'; + try { + const result = expandHomePath('$TEST_EXPAND_VAR/bin'); + expect(result).toBe('/custom/path/bin'); + } finally { + if (original === undefined) delete process.env.TEST_EXPAND_VAR; + else process.env.TEST_EXPAND_VAR = original; + } + }); + + it('expands ${VAR} format environment variables', () => { + const original = process.env.TEST_EXPAND_VAR2; + process.env.TEST_EXPAND_VAR2 = '/another/path'; + try { + const result = expandHomePath('${TEST_EXPAND_VAR2}/lib'); + expect(result).toBe('/another/path/lib'); + } finally { + if (original === undefined) delete process.env.TEST_EXPAND_VAR2; + else process.env.TEST_EXPAND_VAR2 = original; + } + }); + + it('expands %VAR% format environment variables', () => { + const original = process.env.TEST_EXPAND_PCT; + process.env.TEST_EXPAND_PCT = '/pct/path'; + try { + const result = expandHomePath('%TEST_EXPAND_PCT%/dir'); + expect(result).toBe('/pct/path/dir'); + } finally { + if (original === undefined) delete process.env.TEST_EXPAND_PCT; + else process.env.TEST_EXPAND_PCT = original; + } + }); + + it('preserves unmatched variable patterns', () => { + delete process.env.NONEXISTENT_VAR_12345; + expect(expandHomePath('$NONEXISTENT_VAR_12345/bin')).toBe('$NONEXISTENT_VAR_12345/bin'); + }); + + it('returns path unchanged when no special patterns', () => { + expect(expandHomePath('/plain/path')).toBe('/plain/path'); + }); + + it('expands ~\\ backslash prefix', () => { + const result = expandHomePath('~\\Documents'); + expect(result).toBe(path.join(os.homedir(), 'Documents')); + }); +}); + +describe('parsePathEntries', () => { + it('returns empty array for undefined', () => { + expect(parsePathEntries(undefined)).toEqual([]); + }); + + it('returns empty array for empty string', () => { + expect(parsePathEntries('')).toEqual([]); + }); + + it('splits on platform separator', () => { + const sep = isWindows ? ';' : ':'; + const result = parsePathEntries(`/a${sep}/b${sep}/c`); + expect(result).toContain('/a'); + expect(result).toContain('/b'); + expect(result).toContain('/c'); + }); + + it('filters out empty segments', () => { + const sep = isWindows ? ';' : ':'; + const result = parsePathEntries(`${sep}/a${sep}${sep}/b${sep}`); + expect(result.every(s => s.length > 0)).toBe(true); + }); + + it('filters out $PATH placeholder', () => { + const sep = isWindows ? ';' : ':'; + const result = parsePathEntries(`/a${sep}$PATH${sep}/b`); + expect(result).not.toContain('$PATH'); + }); + + it('filters out ${PATH} placeholder', () => { + const sep = isWindows ? ';' : ':'; + const result = parsePathEntries(`/a${sep}\${PATH}${sep}/b`); + expect(result).not.toContain('${PATH}'); + }); + + it('filters out %PATH% placeholder', () => { + const sep = isWindows ? ';' : ':'; + const result = parsePathEntries(`/a${sep}%PATH%${sep}/b`); + expect(result).not.toContain('%PATH%'); + }); + + it('strips surrounding double quotes', () => { + const sep = isWindows ? ';' : ':'; + const result = parsePathEntries(`"/quoted/path"${sep}/normal`); + expect(result[0]).toBe('/quoted/path'); + }); + + it('strips surrounding single quotes', () => { + const sep = isWindows ? ';' : ':'; + const result = parsePathEntries(`'/quoted/path'${sep}/normal`); + expect(result[0]).toBe('/quoted/path'); + }); + + it('expands ~ in entries', () => { + const result = parsePathEntries('~/bin'); + expect(result[0]).toBe(path.join(os.homedir(), 'bin')); + }); +}); + +describe('translateMsysPath', () => { + if (!isWindows) { + it('returns value unchanged on non-Windows', () => { + expect(translateMsysPath('/c/Users/test')).toBe('/c/Users/test'); + }); + } + + if (isWindows) { + it('translates /c/ to C:\\ on Windows', () => { + expect(translateMsysPath('/c/Users/test')).toBe('C:\\Users\\test'); + }); + + it('translates uppercase drive letter', () => { + expect(translateMsysPath('/D/projects')).toBe('D:\\projects'); + }); + + it('returns non-msys path unchanged', () => { + expect(translateMsysPath('C:\\Users\\test')).toBe('C:\\Users\\test'); + }); + } +}); + +describe('normalizePathForFilesystem', () => { + it('returns empty string for empty input', () => { + expect(normalizePathForFilesystem('')).toBe(''); + }); + + it('returns empty string for null-like input', () => { + expect(normalizePathForFilesystem(null as any)).toBe(''); + expect(normalizePathForFilesystem(undefined as any)).toBe(''); + }); + + it('returns empty string for non-string input', () => { + expect(normalizePathForFilesystem(123 as any)).toBe(''); + }); + + it('normalizes a regular path', () => { + const result = normalizePathForFilesystem('/usr/local/bin'); + expect(result).toBe('/usr/local/bin'); + }); + + it('normalizes path with redundant separators', () => { + const result = normalizePathForFilesystem('/usr//local///bin'); + expect(result).toBe('/usr/local/bin'); + }); + + it('normalizes path with . segments', () => { + const result = normalizePathForFilesystem('/usr/./local/./bin'); + expect(result).toBe('/usr/local/bin'); + }); + + it('normalizes path with .. segments', () => { + const result = normalizePathForFilesystem('/usr/local/../bin'); + expect(result).toBe('/usr/bin'); + }); + + it('expands ~ in path', () => { + const result = normalizePathForFilesystem('~/Documents'); + expect(result).toBe(path.normalize(path.join(os.homedir(), 'Documents'))); + }); + + it('expands environment variables', () => { + const original = process.env.TEST_NORM_VAR; + process.env.TEST_NORM_VAR = '/test/val'; + try { + const result = normalizePathForFilesystem('$TEST_NORM_VAR/sub'); + expect(result).toBe(path.normalize('/test/val/sub')); + } finally { + if (original === undefined) delete process.env.TEST_NORM_VAR; + else process.env.TEST_NORM_VAR = original; + } + }); +}); + +describe('normalizePathForComparison', () => { + it('returns empty string for empty input', () => { + expect(normalizePathForComparison('')).toBe(''); + }); + + it('returns empty string for null-like input', () => { + expect(normalizePathForComparison(null as any)).toBe(''); + expect(normalizePathForComparison(undefined as any)).toBe(''); + }); + + it('normalizes slashes to forward slash', () => { + // On any platform, result should use forward slashes + const result = normalizePathForComparison('/usr/local/bin'); + expect(result).not.toContain('\\'); + }); + + it('removes trailing slash', () => { + const result = normalizePathForComparison('/usr/local/bin/'); + expect(result).not.toMatch(/\/$/); + }); + + it('removes multiple trailing slashes', () => { + const result = normalizePathForComparison('/usr/local/bin///'); + expect(result).not.toMatch(/\/$/); + }); + + if (isWindows) { + it('lowercases on Windows for case-insensitive comparison', () => { + const result = normalizePathForComparison('C:\\Users\\Test'); + expect(result).toBe(result.toLowerCase()); + }); + } + + if (!isWindows) { + it('preserves case on Unix', () => { + const result = normalizePathForComparison('/Users/Test'); + expect(result).toContain('Test'); + }); + } + + it('normalizes redundant separators', () => { + const result = normalizePathForComparison('/usr//local///bin'); + expect(result).toBe('/usr/local/bin'); + }); +}); + +describe('isPathWithinVault', () => { + const vaultPath = path.resolve('/tmp/test-vault'); + + it('returns true for path within vault', () => { + expect(isPathWithinVault(path.join(vaultPath, 'notes', 'file.md'), vaultPath)).toBe(true); + }); + + it('returns true for vault path itself', () => { + expect(isPathWithinVault(vaultPath, vaultPath)).toBe(true); + }); + + it('returns false for path outside vault', () => { + expect(isPathWithinVault('/completely/different/path', vaultPath)).toBe(false); + }); + + it('returns false for sibling directory', () => { + expect(isPathWithinVault(path.resolve('/tmp/other-vault'), vaultPath)).toBe(false); + }); + + it('handles relative paths resolved against vault', () => { + expect(isPathWithinVault('notes/file.md', vaultPath)).toBe(true); + }); +}); + +describe('isPathWithinDirectory', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('expands home paths before checking containment', () => { + jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); + + expect(isPathWithinDirectory('~/.claude/settings.json', '/home/test/.claude', '/vault')).toBe(true); + }); + + it('blocks symlink escapes from the allowed directory', () => { + const realpathMock = jest.fn((input: fsType.PathLike) => { + const value = String(input); + if (value === '/home/test/.claude') return '/home/test/.claude'; + if (value === '/home/test/.claude/skills/link') return '/home/test/.ssh'; + return path.resolve(value); + }); + + const realpathSpy = jest.spyOn(fs, 'realpathSync').mockImplementation(realpathMock as any); + (realpathSpy as any).native = realpathMock; + + expect(isPathWithinDirectory('/home/test/.claude/skills/link', '/home/test/.claude', '/vault')).toBe(false); + }); +}); + +describe('normalizePathForVault', () => { + const vaultPath = path.resolve('/tmp/test-vault'); + + it('returns null for null/undefined input', () => { + expect(normalizePathForVault(null, vaultPath)).toBeNull(); + expect(normalizePathForVault(undefined, vaultPath)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(normalizePathForVault('', vaultPath)).toBeNull(); + }); + + it('returns relative path for file within vault', () => { + const fullPath = path.join(vaultPath, 'notes', 'file.md'); + const result = normalizePathForVault(fullPath, vaultPath); + expect(result).toBe('notes/file.md'); + }); + + it('returns normalized path for file outside vault', () => { + const result = normalizePathForVault('/other/path/file.md', vaultPath); + expect(result).toContain('file.md'); + }); + + it('uses forward slashes in result', () => { + const fullPath = path.join(vaultPath, 'a', 'b', 'c.md'); + const result = normalizePathForVault(fullPath, vaultPath); + expect(result).not.toContain('\\'); + }); + + it('handles null vaultPath', () => { + const result = normalizePathForVault('/some/path.md', null); + expect(result).toContain('path.md'); + }); +}); + +describe('findClaudeCLIPath', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns null when nothing found', () => { + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + const result = findClaudeCLIPath('/nonexistent/path'); + expect(result).toBeNull(); + }); + + it('resolves from custom path entries', () => { + const claudePath = isWindows + ? 'C:\\custom\\bin\\claude.exe' + : '/custom/bin/claude'; + + jest.spyOn(fs, 'existsSync').mockImplementation( + p => String(p) === claudePath + ); + jest.spyOn(fs, 'statSync').mockImplementation( + p => ({ isFile: () => String(p) === claudePath }) as fsType.Stats + ); + + const result = findClaudeCLIPath(isWindows ? 'C:\\custom\\bin' : '/custom/bin'); + expect(result).toBe(claudePath); + }); + + it('returns string or null', () => { + const result = findClaudeCLIPath(); + expect(result === null || typeof result === 'string').toBe(true); + }); + + it('finds claude from common paths when no custom path provided', () => { + const commonPath = path.join(os.homedir(), '.claude', 'local', 'claude'); + + jest.spyOn(fs, 'existsSync').mockImplementation( + p => String(p) === commonPath + ); + jest.spyOn(fs, 'statSync').mockImplementation( + p => ({ isFile: () => String(p) === commonPath }) as fsType.Stats + ); + + const result = findClaudeCLIPath(); + expect(result).toBe(commonPath); + }); + + it('falls back to npm cli-wrapper.cjs paths when binary not found', () => { + const cliWrapperPath = path.join( + os.homedir(), '.npm-global', 'lib', 'node_modules', + '@anthropic-ai', 'claude-code', 'cli-wrapper.cjs' + ); + + jest.spyOn(fs, 'existsSync').mockImplementation( + p => String(p) === cliWrapperPath + ); + jest.spyOn(fs, 'statSync').mockImplementation( + p => ({ isFile: () => String(p) === cliWrapperPath }) as fsType.Stats + ); + + const result = findClaudeCLIPath(); + expect(result).toBe(cliWrapperPath); + }); + + it('keeps legacy npm cli.js fallback when cli-wrapper.cjs is absent', () => { + const legacyCliPath = path.join( + os.homedir(), '.npm-global', 'lib', 'node_modules', + '@anthropic-ai', 'claude-code', 'cli.js' + ); + + jest.spyOn(fs, 'existsSync').mockImplementation( + p => String(p) === legacyCliPath + ); + jest.spyOn(fs, 'statSync').mockImplementation( + p => ({ isFile: () => String(p) === legacyCliPath }) as fsType.Stats + ); + + const result = findClaudeCLIPath(); + expect(result).toBe(legacyCliPath); + }); + + it('falls back to PATH environment when common and npm paths fail', () => { + const envClaudePath = '/env/specific/bin/claude'; + const originalPath = process.env.PATH; + process.env.PATH = `/env/specific/bin:${originalPath}`; + + jest.spyOn(fs, 'existsSync').mockImplementation( + p => String(p) === envClaudePath + ); + jest.spyOn(fs, 'statSync').mockImplementation( + p => ({ isFile: () => String(p) === envClaudePath }) as fsType.Stats + ); + + try { + const result = findClaudeCLIPath(); + expect(result).toBe(envClaudePath); + } finally { + process.env.PATH = originalPath; + } + }); + + it('returns null for custom path without claude binary on non-Windows', () => { + // On non-Windows, custom path resolution only looks for 'claude' binary + const customDir = '/custom/tools'; + + jest.spyOn(fs, 'existsSync').mockReturnValue(false); + + const result = findClaudeCLIPath(customDir); + expect(result).toBeNull(); + }); + + it('handles inaccessible filesystem paths gracefully', () => { + jest.spyOn(fs, 'existsSync').mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const result = findClaudeCLIPath('/some/path'); + expect(result).toBeNull(); + }); + + it('finds claude via nvm default version when NVM_BIN is not set (Unix)', () => { + if (isWindows) return; + + const savedNvmBin = process.env.NVM_BIN; + const savedNvmDir = process.env.NVM_DIR; + delete process.env.NVM_BIN; + delete process.env.NVM_DIR; + + const nvmDir = '/fake/home/.nvm'; + const claudePath = path.join(nvmDir, 'versions', 'node', 'v22.18.0', 'bin', 'claude'); + const binDir = path.join(nvmDir, 'versions', 'node', 'v22.18.0', 'bin'); + + jest.spyOn(os, 'homedir').mockReturnValue('/fake/home'); + jest.spyOn(fs, 'existsSync').mockImplementation(p => { + const s = String(p); + return s === claudePath || s === binDir; + }); + jest.spyOn(fs, 'readFileSync').mockImplementation(((p: string) => { + if (String(p) === path.join(nvmDir, 'alias', 'default')) return '22'; + throw new Error('not found'); + }) as typeof fs.readFileSync); + jest.spyOn(fs, 'readdirSync').mockImplementation(((p: string) => { + if (String(p) === path.join(nvmDir, 'versions', 'node')) return ['v22.18.0']; + return []; + }) as typeof fs.readdirSync); + jest.spyOn(fs, 'statSync').mockImplementation( + () => ({ isFile: () => true }) as fsType.Stats + ); + + const result = findClaudeCLIPath(); + expect(result).toBe(claudePath); + + if (savedNvmBin !== undefined) process.env.NVM_BIN = savedNvmBin; + else delete process.env.NVM_BIN; + if (savedNvmDir !== undefined) process.env.NVM_DIR = savedNvmDir; + else delete process.env.NVM_DIR; + }); + + it('finds claude via built-in nvm node alias when NVM_BIN is not set (Unix)', () => { + if (isWindows) return; + + const savedNvmBin = process.env.NVM_BIN; + const savedNvmDir = process.env.NVM_DIR; + delete process.env.NVM_BIN; + delete process.env.NVM_DIR; + + const nvmDir = '/fake/home/.nvm'; + const claudePath = path.join(nvmDir, 'versions', 'node', 'v22.18.0', 'bin', 'claude'); + const binDir = path.join(nvmDir, 'versions', 'node', 'v22.18.0', 'bin'); + + jest.spyOn(os, 'homedir').mockReturnValue('/fake/home'); + jest.spyOn(fs, 'existsSync').mockImplementation(p => { + const s = String(p); + return s === claudePath || s === binDir; + }); + jest.spyOn(fs, 'readFileSync').mockImplementation(((p: string) => { + if (String(p) === path.join(nvmDir, 'alias', 'default')) return 'node'; + throw new Error('not found'); + }) as typeof fs.readFileSync); + jest.spyOn(fs, 'readdirSync').mockImplementation(((p: string) => { + if (String(p) === path.join(nvmDir, 'versions', 'node')) return ['v20.10.0', 'v22.18.0']; + return []; + }) as typeof fs.readdirSync); + jest.spyOn(fs, 'statSync').mockImplementation( + () => ({ isFile: () => true }) as fsType.Stats + ); + + const result = findClaudeCLIPath(); + expect(result).toBe(claudePath); + + if (savedNvmBin !== undefined) process.env.NVM_BIN = savedNvmBin; + else delete process.env.NVM_BIN; + if (savedNvmDir !== undefined) process.env.NVM_DIR = savedNvmDir; + else delete process.env.NVM_DIR; + }); +}); + +describe('expandHomePath - Windows environment variable formats', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('expands Windows !VAR! delayed expansion format on win32', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + const original = process.env.TEST_DELAYED; + process.env.TEST_DELAYED = '/delayed/path'; + try { + const result = expandHomePath('!TEST_DELAYED!/bin'); + expect(result).toBe('/delayed/path/bin'); + } finally { + if (original === undefined) delete process.env.TEST_DELAYED; + else process.env.TEST_DELAYED = original; + } + }); + + it('does not expand !VAR! format on non-Windows', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const original = process.env.TEST_DELAYED2; + process.env.TEST_DELAYED2 = '/delayed/path2'; + try { + const result = expandHomePath('!TEST_DELAYED2!/bin'); + expect(result).toBe('!TEST_DELAYED2!/bin'); + } finally { + if (original === undefined) delete process.env.TEST_DELAYED2; + else process.env.TEST_DELAYED2 = original; + } + }); + + it('expands Windows $env:VAR PowerShell format on win32', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + const original = process.env.TEST_PSVAR; + process.env.TEST_PSVAR = '/ps/path'; + try { + const result = expandHomePath('$env:TEST_PSVAR/bin'); + expect(result).toBe('/ps/path/bin'); + } finally { + if (original === undefined) delete process.env.TEST_PSVAR; + else process.env.TEST_PSVAR = original; + } + }); + + it('does not expand $env:VAR format on non-Windows', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + const original = process.env.TEST_PSVAR2; + process.env.TEST_PSVAR2 = '/ps/path2'; + try { + const result = expandHomePath('$env:TEST_PSVAR2/bin'); + // On non-Windows, $env is treated as a regular $VAR lookup for "env" + // which won't match TEST_PSVAR2, so the $env: prefix persists partially + expect(result).not.toBe('/ps/path2/bin'); + } finally { + if (original === undefined) delete process.env.TEST_PSVAR2; + else process.env.TEST_PSVAR2 = original; + } + }); + + it('performs case-insensitive env lookup on win32', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + const original = process.env.MY_CI_VAR; + process.env.MY_CI_VAR = '/ci/val'; + try { + // %var% format uses getEnvValue which does case-insensitive search on Windows + const result = expandHomePath('%my_ci_var%/test'); + expect(result).toBe('/ci/val/test'); + } finally { + if (original === undefined) delete process.env.MY_CI_VAR; + else process.env.MY_CI_VAR = original; + } + }); +}); diff --git a/tests/unit/utils/sdkSession.test.ts b/tests/unit/utils/sdkSession.test.ts new file mode 100644 index 0000000..1f169eb --- /dev/null +++ b/tests/unit/utils/sdkSession.test.ts @@ -0,0 +1,2630 @@ +import { existsSync } from 'fs'; +import * as fsPromises from 'fs/promises'; +import * as os from 'os'; + +import { + collectAsyncSubagentResults, + deleteSDKSession, + encodeVaultPathForSDK, + filterActiveBranch, + getSDKProjectsPath, + getSDKSessionAvailability, + getSDKSessionPath, + isValidSessionId, + loadSDKSessionMessages, + loadSubagentFinalResult, + loadSubagentToolCalls, + parseSDKMessageToChat, + readSDKSession, + resolveToolUseResultStatus, + type SDKNativeMessage, + sdkSessionExists, +} from '@/providers/claude/history/ClaudeHistoryStore'; +import { extractToolResultContent } from '@/providers/claude/sdk/toolResultContent'; + +// Mock fs, fs/promises, and os modules +jest.mock('fs', () => ({ + existsSync: jest.fn(), +})); +jest.mock('fs/promises'); +jest.mock('os'); + +const mockExistsSync = existsSync as jest.MockedFunction; +const mockFsPromises = fsPromises as jest.Mocked; +const mockOs = os as jest.Mocked; + +describe('sdkSession', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockOs.homedir.mockReturnValue('/Users/test'); + }); + + describe('encodeVaultPathForSDK', () => { + it('encodes vault path by replacing all non-alphanumeric chars with dash', () => { + const encoded = encodeVaultPathForSDK('/Users/test/vault'); + // SDK replaces ALL non-alphanumeric characters with `-` + expect(encoded).toBe('-Users-test-vault'); + }); + + it('handles paths with spaces and special characters', () => { + const encoded = encodeVaultPathForSDK("/Users/test/My Vault's~Data"); + expect(encoded).toBe('-Users-test-My-Vault-s-Data'); + }); + + it('handles Unicode characters (Chinese, Japanese, etc.)', () => { + // Unicode characters should be replaced with `-` to match SDK behavior + const encoded = encodeVaultPathForSDK('/Volumes/[Work]弘毅之鹰/学习/东京大学/2025年 秋'); + // All non-alphanumeric (including Chinese, brackets) become `-` + expect(encoded).toBe('-Volumes--Work--------------2025---'); + // Verify only ASCII alphanumeric and dash remain + expect(encoded).toMatch(/^[a-zA-Z0-9-]+$/); + }); + + it('handles brackets and other special characters', () => { + const encoded = encodeVaultPathForSDK('/Users/test/[my-vault](notes)'); + expect(encoded).toBe('-Users-test--my-vault--notes-'); + expect(encoded).not.toContain('['); + expect(encoded).not.toContain(']'); + expect(encoded).not.toContain('('); + expect(encoded).not.toContain(')'); + }); + + it('produces consistent encoding', () => { + const path1 = '/Users/test/my-vault'; + const encoded1 = encodeVaultPathForSDK(path1); + const encoded2 = encodeVaultPathForSDK(path1); + expect(encoded1).toBe(encoded2); + }); + + it('produces different encodings for different paths', () => { + const encoded1 = encodeVaultPathForSDK('/Users/test/vault1'); + const encoded2 = encodeVaultPathForSDK('/Users/test/vault2'); + expect(encoded1).not.toBe(encoded2); + }); + + it('handles backslashes for Windows compatibility', () => { + // Test that backslashes are replaced (Windows path separators) + // Note: path.resolve may modify the input, so we check the output contains no backslashes + const encoded = encodeVaultPathForSDK('C:\\Users\\test\\vault'); + expect(encoded).not.toContain('\\'); + expect(encoded).toContain('-Users-test-vault'); + }); + + it('replaces colons for Windows drive letters', () => { + // Windows paths have colons after drive letter + const encoded = encodeVaultPathForSDK('C:\\Users\\test\\vault'); + expect(encoded).not.toContain(':'); + }); + }); + + describe('getSDKProjectsPath', () => { + it('returns path under home directory', () => { + const projectsPath = getSDKProjectsPath(); + expect(projectsPath).toBe('/Users/test/.claude/projects'); + }); + + it('uses CLAUDE_CONFIG_DIR from the effective SDK environment', () => { + const projectsPath = getSDKProjectsPath({ + environment: { CLAUDE_CONFIG_DIR: '/custom/claude' }, + vaultPath: '/Users/test/vault', + }); + + expect(projectsPath).toBe('/custom/claude/projects'); + }); + + it('resolves a relative CLAUDE_CONFIG_DIR from the SDK working directory', () => { + const projectsPath = getSDKProjectsPath({ + environment: { CLAUDE_CONFIG_DIR: '.claude-custom' }, + vaultPath: '/Users/test/vault', + }); + + expect(projectsPath).toBe('/Users/test/vault/.claude-custom/projects'); + }); + + it('falls back to the default directory when CLAUDE_CONFIG_DIR is unset', () => { + const projectsPath = getSDKProjectsPath({ + environment: {}, + vaultPath: '/Users/test/vault', + }); + + expect(projectsPath).toBe('/Users/test/.claude/projects'); + }); + + it('uses the effective SDK HOME when CLAUDE_CONFIG_DIR is unset', () => { + const projectsPath = getSDKProjectsPath({ + environment: { HOME: '/custom/home' }, + hostPlatform: 'linux', + vaultPath: '/Users/test/vault', + }); + + expect(projectsPath).toBe('/custom/home/.claude/projects'); + }); + + it('uses the effective SDK USERPROFILE on Windows', () => { + const projectsPath = getSDKProjectsPath({ + environment: { USERPROFILE: '/custom/windows-home' }, + hostPlatform: 'win32', + vaultPath: '/Users/test/vault', + }); + + expect(projectsPath).toBe('/custom/windows-home/.claude/projects'); + }); + + it('resolves an empty SDK HOME from the SDK working directory', () => { + const projectsPath = getSDKProjectsPath({ + environment: { HOME: '' }, + hostPlatform: 'linux', + vaultPath: '/Users/test/vault', + }); + + expect(projectsPath).toBe('/Users/test/vault/.claude/projects'); + }); + + it('preserves an explicitly empty CLAUDE_CONFIG_DIR like the SDK', () => { + const projectsPath = getSDKProjectsPath({ + environment: { CLAUDE_CONFIG_DIR: '' }, + vaultPath: '/Users/test/vault', + }); + + expect(projectsPath).toBe('/Users/test/vault/projects'); + }); + }); + + describe('isValidSessionId', () => { + it('accepts valid UUID-style session IDs', () => { + expect(isValidSessionId('abc123')).toBe(true); + expect(isValidSessionId('session-123')).toBe(true); + expect(isValidSessionId('a1b2c3d4-e5f6-7890-abcd-ef1234567890')).toBe(true); + expect(isValidSessionId('test_session_id')).toBe(true); + }); + + it('rejects empty or too long session IDs', () => { + expect(isValidSessionId('')).toBe(false); + expect(isValidSessionId('a'.repeat(129))).toBe(false); + }); + + it('rejects path traversal attempts', () => { + expect(isValidSessionId('../etc/passwd')).toBe(false); + expect(isValidSessionId('..\\windows\\system32')).toBe(false); + expect(isValidSessionId('foo/../bar')).toBe(false); + expect(isValidSessionId('session/subdir')).toBe(false); + expect(isValidSessionId('session\\subdir')).toBe(false); + }); + + it('rejects special characters', () => { + expect(isValidSessionId('session.jsonl')).toBe(false); + expect(isValidSessionId('session:123')).toBe(false); + expect(isValidSessionId('session@host')).toBe(false); + }); + }); + + describe('getSDKSessionPath', () => { + it('constructs correct session file path', () => { + const sessionPath = getSDKSessionPath('/Users/test/vault', 'session-123'); + expect(sessionPath).toContain('.claude/projects'); + expect(sessionPath).toContain('session-123.jsonl'); + }); + + it('throws error for path traversal attempts', () => { + expect(() => getSDKSessionPath('/Users/test/vault', '../etc/passwd')).toThrow('Invalid session ID'); + expect(() => getSDKSessionPath('/Users/test/vault', 'foo/../bar')).toThrow('Invalid session ID'); + expect(() => getSDKSessionPath('/Users/test/vault', 'session/subdir')).toThrow('Invalid session ID'); + }); + + it('throws error for empty session ID', () => { + expect(() => getSDKSessionPath('/Users/test/vault', '')).toThrow('Invalid session ID'); + }); + + it('builds session paths under the effective Claude config directory', () => { + const sessionPath = getSDKSessionPath('/Users/test/vault', 'session-123', { + environment: { CLAUDE_CONFIG_DIR: '/custom/claude' }, + vaultPath: '/Users/test/vault', + }); + + expect(sessionPath).toBe('/custom/claude/projects/-Users-test-vault/session-123.jsonl'); + }); + }); + + describe('sdkSessionExists', () => { + it('returns true when session file exists', () => { + mockExistsSync.mockReturnValue(true); + + const exists = sdkSessionExists('/Users/test/vault', 'session-abc'); + + expect(exists).toBe(true); + }); + + it('returns false when session file does not exist', () => { + mockExistsSync.mockReturnValue(false); + + const exists = sdkSessionExists('/Users/test/vault', 'session-xyz'); + + expect(exists).toBe(false); + }); + + it('returns false on error', () => { + mockExistsSync.mockImplementation(() => { + throw new Error('Permission denied'); + }); + + const exists = sdkSessionExists('/Users/test/vault', 'session-err'); + + expect(exists).toBe(false); + }); + }); + + describe('getSDKSessionAvailability', () => { + it('reports an available session', async () => { + mockFsPromises.access.mockResolvedValue(undefined); + + await expect(getSDKSessionAvailability( + '/Users/test/vault', + 'session-abc', + )).resolves.toBe('available'); + expect(mockFsPromises.access).toHaveBeenCalledWith( + '/Users/test/.claude/projects/-Users-test-vault/session-abc.jsonl', + ); + }); + + it('reports a missing session when a complete scan finds no transcript', async () => { + mockFsPromises.access.mockRejectedValue( + Object.assign(new Error('Missing'), { code: 'ENOENT' }), + ); + mockFsPromises.readdir.mockResolvedValue([]); + + await expect(getSDKSessionAvailability( + '/Users/test/vault', + 'session-missing', + )).resolves.toBe('missing'); + }); + + it('reports a session found under a previous vault project as relocated', async () => { + mockFsPromises.access.mockRejectedValue( + Object.assign(new Error('Missing'), { code: 'ENOENT' }), + ); + mockFsPromises.readdir + .mockResolvedValueOnce([{ + isDirectory: () => true, + isFile: () => false, + name: '-Users-test-previous-vault', + }] as any) + .mockResolvedValueOnce([{ + isDirectory: () => false, + isFile: () => true, + name: 'session-relocated.jsonl', + }] as any); + + await expect(getSDKSessionAvailability( + '/Users/test/vault', + 'session-relocated', + )).resolves.toBe('relocated'); + }); + + it('finds relocated sessions in nested project directories', async () => { + mockFsPromises.access.mockRejectedValue( + Object.assign(new Error('Missing'), { code: 'ENOENT' }), + ); + mockFsPromises.readdir + .mockResolvedValueOnce([{ + isDirectory: () => true, + isFile: () => false, + name: '-Users-test-current-project', + }] as any) + .mockResolvedValueOnce([{ + isDirectory: () => true, + isFile: () => false, + name: '-Users-test-previous-vault', + }] as any) + .mockResolvedValueOnce([{ + isDirectory: () => false, + isFile: () => true, + name: 'session-nested.jsonl', + }] as any); + + await expect(getSDKSessionAvailability( + '/Users/test/vault', + 'session-nested', + )).resolves.toBe('relocated'); + }); + + it('reports unknown when the local Claude projects root is absent', async () => { + mockFsPromises.access.mockRejectedValue( + Object.assign(new Error('Missing'), { code: 'ENOENT' }), + ); + mockFsPromises.readdir.mockRejectedValue( + Object.assign(new Error('Missing root'), { code: 'ENOENT' }), + ); + + await expect(getSDKSessionAvailability( + '/Users/test/vault', + 'session-on-another-machine', + )).resolves.toBe('unknown'); + }); + + it('reports unknown for other filesystem failures', async () => { + mockFsPromises.access.mockRejectedValue( + Object.assign(new Error('Permission denied'), { code: 'EACCES' }), + ); + + await expect(getSDKSessionAvailability( + '/Users/test/vault', + 'session-inaccessible', + )).resolves.toBe('unknown'); + }); + + it('reports unknown when an unscanned symlink could contain the transcript', async () => { + mockFsPromises.access.mockRejectedValue( + Object.assign(new Error('Missing'), { code: 'ENOENT' }), + ); + mockFsPromises.readdir.mockResolvedValue([{ + isDirectory: () => false, + isFile: () => false, + isSymbolicLink: () => true, + name: 'linked-project', + }] as any); + + await expect(getSDKSessionAvailability( + '/Users/test/vault', + 'session-in-linked-project', + )).resolves.toBe('unknown'); + }); + + it('reports unknown for an invalid session ID', async () => { + await expect(getSDKSessionAvailability( + '/Users/test/vault', + '../invalid', + )).resolves.toBe('unknown'); + expect(mockFsPromises.access).not.toHaveBeenCalled(); + }); + }); + + describe('deleteSDKSession', () => { + it('deletes session file when it exists', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.unlink.mockResolvedValue(undefined); + + await deleteSDKSession('/Users/test/vault', 'session-abc'); + + expect(mockFsPromises.unlink).toHaveBeenCalledWith( + '/Users/test/.claude/projects/-Users-test-vault/session-abc.jsonl' + ); + }); + + it('does nothing when session file does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + await deleteSDKSession('/Users/test/vault', 'nonexistent'); + + expect(mockFsPromises.unlink).not.toHaveBeenCalled(); + }); + + it('fails silently when unlink throws', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.unlink.mockRejectedValue(new Error('Permission denied')); + + // Should not throw + await expect(deleteSDKSession('/Users/test/vault', 'session-err')).resolves.toBeUndefined(); + }); + + it('does nothing for invalid session ID', async () => { + await deleteSDKSession('/Users/test/vault', '../invalid'); + + expect(mockFsPromises.unlink).not.toHaveBeenCalled(); + }); + + it('deletes from the effective Claude config directory', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.unlink.mockResolvedValue(undefined); + + await deleteSDKSession('/Users/test/vault', 'session-custom', { + environment: { CLAUDE_CONFIG_DIR: '/custom/claude' }, + vaultPath: '/Users/test/vault', + }); + + expect(mockFsPromises.unlink).toHaveBeenCalledWith( + '/custom/claude/projects/-Users-test-vault/session-custom.jsonl', + ); + }); + }); + + describe('readSDKSession', () => { + it('returns empty result when file does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await readSDKSession('/Users/test/vault', 'nonexistent'); + + expect(result.messages).toEqual([]); + expect(result.skippedLines).toBe(0); + expect(result.error).toBeUndefined(); + }); + + it('parses valid JSONL file', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","message":{"content":"Hello"}}', + '{"type":"assistant","uuid":"a1","message":{"content":"Hi there"}}', + ].join('\n')); + + const result = await readSDKSession('/Users/test/vault', 'session-1'); + + expect(result.messages).toHaveLength(2); + expect(result.messages[0].type).toBe('user'); + expect(result.messages[1].type).toBe('assistant'); + expect(result.skippedLines).toBe(0); + }); + + it('reads from the effective Claude config directory', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue( + '{"type":"user","uuid":"u1","message":{"content":"Hello"}}', + ); + + const result = await readSDKSession('/Users/test/vault', 'session-custom', { + environment: { CLAUDE_CONFIG_DIR: '/custom/claude' }, + vaultPath: '/Users/test/vault', + }); + + expect(result.messages).toHaveLength(1); + expect(mockFsPromises.readFile).toHaveBeenCalledWith( + '/custom/claude/projects/-Users-test-vault/session-custom.jsonl', + 'utf-8', + ); + }); + + it('skips invalid JSON lines and reports count', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","message":{"content":"Hello"}}', + 'invalid json line', + '{"type":"assistant","uuid":"a1","message":{"content":"Hi"}}', + ].join('\n')); + + const result = await readSDKSession('/Users/test/vault', 'session-2'); + + expect(result.messages).toHaveLength(2); + expect(result.skippedLines).toBe(1); + }); + + it('handles empty lines', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","message":{"content":"Test"}}', + '', + ' ', + '{"type":"assistant","uuid":"a1","message":{"content":"Response"}}', + ].join('\n')); + + const result = await readSDKSession('/Users/test/vault', 'session-3'); + + expect(result.messages).toHaveLength(2); + }); + + it('returns error on read failure', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockRejectedValue(new Error('Read error')); + + const result = await readSDKSession('/Users/test/vault', 'session-err'); + + expect(result.messages).toEqual([]); + expect(result.error).toBe('Read error'); + }); + }); + + describe('loadSubagentToolCalls', () => { + it('loads tool calls from subagent sidechain JSONL', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"assistant","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"sub-tool-1","name":"Bash","input":{"command":"ls"}}]}}', + '{"type":"user","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"sub-tool-1","content":"ok","is_error":false}]}}', + ].join('\n')); + + const toolCalls = await loadSubagentToolCalls( + '/Users/test/vault', + 'session-abc', + 'a123' + ); + + expect(mockFsPromises.readFile).toHaveBeenCalledWith( + '/Users/test/.claude/projects/-Users-test-vault/session-abc/subagents/agent-a123.jsonl', + 'utf-8' + ); + expect(toolCalls).toHaveLength(1); + expect(toolCalls[0]).toEqual( + expect.objectContaining({ + id: 'sub-tool-1', + name: 'Bash', + input: { command: 'ls' }, + status: 'completed', + result: 'ok', + }) + ); + }); + + it('filters out entries that only have tool_result but no tool_use', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue( + '{"type":"user","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"missing","content":"done"}]}}' + ); + + const toolCalls = await loadSubagentToolCalls( + '/Users/test/vault', + 'session-abc', + 'a123' + ); + + expect(toolCalls).toEqual([]); + }); + + it('returns empty when agent id is invalid', async () => { + const toolCalls = await loadSubagentToolCalls( + '/Users/test/vault', + 'session-abc', + '../bad-agent' + ); + + expect(toolCalls).toEqual([]); + expect(mockFsPromises.readFile).not.toHaveBeenCalled(); + }); + }); + + describe('loadSubagentFinalResult', () => { + it('returns the latest assistant text from sidecar JSONL', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"First"}]}}', + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Final answer"}]}}', + ].join('\n')); + + const result = await loadSubagentFinalResult( + '/Users/test/vault', + 'session-abc', + 'a123' + ); + + expect(result).toBe('Final answer'); + expect(mockFsPromises.readFile).toHaveBeenCalledWith( + '/Users/test/.claude/projects/-Users-test-vault/session-abc/subagents/agent-a123.jsonl', + 'utf-8' + ); + }); + + it('falls back to top-level result when assistant text is absent', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"progress","result":"Intermediate result"}', + '{"type":"result","result":"Final result text"}', + ].join('\n')); + + const result = await loadSubagentFinalResult( + '/Users/test/vault', + 'session-abc', + 'a123' + ); + + expect(result).toBe('Final result text'); + }); + + it('returns null when sidecar file is missing or agent id is invalid', async () => { + mockExistsSync.mockReturnValue(false); + + const missing = await loadSubagentFinalResult( + '/Users/test/vault', + 'session-abc', + 'a123' + ); + expect(missing).toBeNull(); + + const invalid = await loadSubagentFinalResult( + '/Users/test/vault', + 'session-abc', + '../bad-agent' + ); + expect(invalid).toBeNull(); + expect(mockFsPromises.readFile).not.toHaveBeenCalled(); + }); + }); + + describe('parseSDKMessageToChat', () => { + it('converts user message with string content', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'user-123', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: 'What is the weather?', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.id).toBe('user-123'); + expect(chatMsg!.role).toBe('user'); + expect(chatMsg!.content).toBe('What is the weather?'); + expect(chatMsg!.timestamp).toBe(new Date('2024-01-15T10:30:00Z').getTime()); + }); + + it('sets userMessageId on user messages with uuid', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'user-rewind-123', + timestamp: '2024-01-15T10:30:00Z', + message: { content: 'Hello' }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg!.userMessageId).toBe('user-rewind-123'); + expect(chatMsg!.assistantMessageId).toBeUndefined(); + }); + + it('sets assistantMessageId on assistant messages with uuid', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-rewind-456', + timestamp: '2024-01-15T10:31:00Z', + message: { + content: [{ type: 'text', text: 'Hello back' }], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg!.assistantMessageId).toBe('asst-rewind-456'); + expect(chatMsg!.userMessageId).toBeUndefined(); + }); + + it('does not set SDK UUIDs when uuid is absent', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + timestamp: '2024-01-15T10:30:00Z', + message: { content: 'No uuid' }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg!.userMessageId).toBeUndefined(); + expect(chatMsg!.assistantMessageId).toBeUndefined(); + }); + + it('converts assistant message with text content blocks', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-456', + timestamp: '2024-01-15T10:31:00Z', + message: { + content: [ + { type: 'text', text: 'The weather is sunny.' }, + { type: 'text', text: 'Temperature is 72°F.' }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.id).toBe('asst-456'); + expect(chatMsg!.role).toBe('assistant'); + expect(chatMsg!.content).toBe('The weather is sunny.\nTemperature is 72°F.'); + }); + + it('extracts tool calls from content blocks', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-tool', + timestamp: '2024-01-15T10:32:00Z', + message: { + content: [ + { type: 'text', text: 'Let me search for that.' }, + { + type: 'tool_use', + id: 'tool-1', + name: 'WebSearch', + input: { query: 'weather today' }, + }, + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'Sunny, 72°F', + }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.toolCalls).toHaveLength(1); + expect(chatMsg!.toolCalls![0].id).toBe('tool-1'); + expect(chatMsg!.toolCalls![0].name).toBe('WebSearch'); + expect(chatMsg!.toolCalls![0].input).toEqual({ query: 'weather today' }); + expect(chatMsg!.toolCalls![0].status).toBe('completed'); + expect(chatMsg!.toolCalls![0].result).toBe('Sunny, 72°F'); + }); + + it('marks tool call as error when is_error is true', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-err', + timestamp: '2024-01-15T10:33:00Z', + message: { + content: [ + { + type: 'tool_use', + id: 'tool-err', + name: 'Bash', + input: { command: 'invalid' }, + }, + { + type: 'tool_result', + tool_use_id: 'tool-err', + content: 'Command not found', + is_error: true, + }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg!.toolCalls![0].status).toBe('error'); + }); + + it('keeps tool calls running when no matching tool_result exists yet', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-running', + timestamp: '2024-01-15T10:33:30Z', + message: { + content: [ + { + type: 'tool_use', + id: 'tool-running', + name: 'Read', + input: { file_path: 'notes/todo.md' }, + }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg!.toolCalls![0].status).toBe('running'); + expect(chatMsg!.toolCalls![0].result).toBeUndefined(); + }); + + it('extracts thinking content blocks', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-think', + timestamp: '2024-01-15T10:34:00Z', + message: { + content: [ + { type: 'thinking', thinking: 'Let me consider this...' }, + { type: 'text', text: 'Here is my answer.' }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg!.contentBlocks).toHaveLength(2); + + const thinkingBlock = chatMsg!.contentBlocks![0]; + expect(thinkingBlock.type).toBe('thinking'); + // Type narrowing for thinking block content check + expect(thinkingBlock.type === 'thinking' && thinkingBlock.content).toBe('Let me consider this...'); + + expect(chatMsg!.contentBlocks![1].type).toBe('text'); + }); + + it('preserves text block whitespace in contentBlocks', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-whitespace', + timestamp: '2024-01-15T10:34:30Z', + message: { + content: [ + { type: 'text', text: ' Preserve leading and trailing space ' }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg!.content).toBe(' Preserve leading and trailing space '); + expect(chatMsg!.contentBlocks).toEqual([ + { type: 'text', content: ' Preserve leading and trailing space ' }, + ]); + }); + + it('returns null for system messages', () => { + const sdkMsg: SDKNativeMessage = { + type: 'system', + uuid: 'sys-1', + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).toBeNull(); + }); + + it('returns synthetic assistant message for compact_boundary system messages', () => { + const sdkMsg: SDKNativeMessage = { + type: 'system', + subtype: 'compact_boundary', + uuid: 'compact-1', + timestamp: '2024-06-15T12:00:00Z', + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.id).toBe('compact-1'); + expect(chatMsg!.role).toBe('assistant'); + expect(chatMsg!.content).toBe(''); + expect(chatMsg!.timestamp).toBe(new Date('2024-06-15T12:00:00Z').getTime()); + expect(chatMsg!.contentBlocks).toEqual([{ type: 'context_compacted' }]); + }); + + it('generates ID for compact_boundary without uuid', () => { + const sdkMsg: SDKNativeMessage = { + type: 'system', + subtype: 'compact_boundary', + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.id).toMatch(/^compact-/); + }); + + it('returns null for result messages', () => { + const sdkMsg: SDKNativeMessage = { + type: 'result', + uuid: 'res-1', + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).toBeNull(); + }); + + it('returns null for file-history-snapshot messages', () => { + const sdkMsg: SDKNativeMessage = { + type: 'file-history-snapshot', + uuid: 'fhs-1', + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).toBeNull(); + }); + + it('generates ID when uuid is missing', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + timestamp: '2024-01-15T10:35:00Z', + message: { + content: 'No UUID message', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.id).toMatch(/^sdk-/); + }); + + it('uses current time when timestamp is missing', () => { + const before = Date.now(); + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'no-time', + message: { + content: 'No timestamp', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + const after = Date.now(); + + expect(chatMsg!.timestamp).toBeGreaterThanOrEqual(before); + expect(chatMsg!.timestamp).toBeLessThanOrEqual(after); + }); + + it('marks interrupt messages with isInterrupt flag', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'interrupt-1', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: [{ type: 'text', text: '[Request interrupted by user]' }], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.isInterrupt).toBe(true); + expect(chatMsg!.content).toBe('[Request interrupted by user]'); + }); + + it('does not mark non-canonical interrupt text variants', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'interrupt-non-canonical', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: 'prefix [Request interrupted by user]', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.isInterrupt).toBeUndefined(); + }); + + it('does not mark regular user messages as interrupt', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'user-regular', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: 'Hello, how are you?', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.isInterrupt).toBeUndefined(); + }); + + it('marks rebuilt context messages with isRebuiltContext flag', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'rebuilt-1', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: 'User: hi\n\nAssistant: Hello!\n\nUser: how are you?', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.isRebuiltContext).toBe(true); + }); + + it('marks rebuilt context messages starting with Assistant', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'rebuilt-2', + timestamp: '2024-01-15T10:31:00Z', + message: { + content: 'Assistant: Hello\n\nUser: Hi again', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.isRebuiltContext).toBe(true); + }); + + it('does not mark regular messages starting with User as rebuilt context', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'user-normal', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: 'User settings should be configurable', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.isRebuiltContext).toBeUndefined(); + }); + + it('extracts displayContent from user message with current_note tag', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'user-note', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: 'Explain this file\n\n\nnotes/test.md\n', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.content).toBe('Explain this file\n\n\nnotes/test.md\n'); + expect(chatMsg!.displayContent).toBe('Explain this file'); + }); + + it('extracts displayContent from user message with editor_selection tag', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'user-selection', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: 'Refactor this code\n\n\nfunction foo() {}\n', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.displayContent).toBe('Refactor this code'); + }); + + it('extracts displayContent from user message with multiple context tags', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'user-multi', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: 'Update this\n\n\ntest.md\n\n\n\nselected\n', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.displayContent).toBe('Update this'); + }); + + it('does not set displayContent for plain user messages without XML context', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'user-plain', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: 'Just a regular question', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.displayContent).toBeUndefined(); + }); + }); + + describe('loadSDKSessionMessages', () => { + it('loads and converts all messages from session file', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', + '{"type":"system","uuid":"s1"}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"Thanks"}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-full'); + + // Should have 3 messages (system skipped) + expect(result.messages).toHaveLength(3); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[0].content).toBe('Hello'); + expect(result.messages[1].role).toBe('assistant'); + expect(result.messages[1].content).toBe('Hi!'); + expect(result.messages[2].role).toBe('user'); + expect(result.messages[2].content).toBe('Thanks'); + }); + + it('sorts messages by timestamp ascending', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Second"}]}}', + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"First"}}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"Third"}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-unordered'); + + expect(result.messages[0].content).toBe('First'); + expect(result.messages[1].content).toBe('Second'); + expect(result.messages[2].content).toBe('Third'); + }); + + it('returns empty result when session does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'nonexistent'); + + expect(result.messages).toEqual([]); + }); + + it('matches tool_result from user message to tool_use in assistant message', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Search for cats"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Let me search"},{"type":"tool_use","id":"tool-1","name":"WebSearch","input":{"query":"cats"}}]}}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"Found 10 results"}]}}', + '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"I found 10 results about cats."}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-cross-tool'); + + // Should have 2 messages (tool_result-only user skipped, assistant messages merged) + expect(result.messages).toHaveLength(2); + expect(result.messages[0].content).toBe('Search for cats'); + // Merged assistant message has tool calls and combined content + expect(result.messages[1].toolCalls).toHaveLength(1); + expect(result.messages[1].toolCalls![0].id).toBe('tool-1'); + expect(result.messages[1].toolCalls![0].result).toBe('Found 10 results'); + expect(result.messages[1].toolCalls![0].status).toBe('completed'); + expect(result.messages[1].content).toContain('Let me search'); + expect(result.messages[1].content).toContain('I found 10 results about cats.'); + }); + + it('hydrates AskUserQuestion answers from result text when toolUseResult has no answers', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"ask-1","name":"AskUserQuestion","input":{"questions":[{"question":"Color?","options":["Blue","Red"]}]}}]}}', + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"ask-1","content":"\\"Color?\\"=\\"Blue\\""}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-ask-result-fallback'); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].toolCalls).toHaveLength(1); + expect(result.messages[0].toolCalls?.[0].resolvedAnswers).toEqual({ 'Color?': 'Blue' }); + }); + + it('skips user messages that are tool results', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"done"}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-skip-tool-result'); + + // Should have 2 messages (tool_result user skipped) + expect(result.messages).toHaveLength(2); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[0].content).toBe('Hello'); + expect(result.messages[1].role).toBe('assistant'); + }); + + it('skips skill prompt injection messages (sourceToolUseID)', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"/commit"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Skill","input":{"skill":"commit"}}]}}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"Launching skill: commit"}]}}', + '{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","sourceToolUseID":"t1","isMeta":true,"message":{"content":[{"type":"text","text":"## Your task\\n\\nCommit the changes..."}]}}', + '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"Committing the changes now."}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-skip-skill'); + + // Should have 2 messages: user query, merged assistant (tool_use + text merged together) + // Skill prompt injection (u3) and tool result (u2) should be skipped + // Consecutive assistant messages are merged + expect(result.messages).toHaveLength(2); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[0].content).toBe('/commit'); + expect(result.messages[1].role).toBe('assistant'); + expect(result.messages[1].toolCalls?.[0].name).toBe('Skill'); + expect(result.messages[1].content).toContain('Committing'); + }); + + it('skips meta messages without sourceToolUseID', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:00:01Z","isMeta":true,"message":{"content":"System context injection"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi there!"}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-skip-meta'); + + // Should have 2 messages (meta message u2 skipped) + expect(result.messages).toHaveLength(2); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[0].content).toBe('Hello'); + expect(result.messages[1].role).toBe('assistant'); + }); + + it('preserves /compact command as user message with clean displayContent', async () => { + // File ordering mirrors real SDK JSONL: compact_boundary written BEFORE /compact command. + // The timestamp sort must reorder so /compact (earlier) precedes boundary (later). + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', + '{"type":"system","subtype":"compact_boundary","uuid":"c1","timestamp":"2024-01-15T10:02:10Z"}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","isMeta":true,"message":{"content":"Caveat"}}', + '{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","message":{"content":"/compact\\ncompact\\n"}}', + '{"type":"user","uuid":"u4","timestamp":"2024-01-15T10:02:11Z","message":{"content":"Compacted "}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-compact'); + + // Should have: user "Hello", assistant "Hi!", user "/compact", assistant compact_boundary + // Meta (u2), stdout (u4) should be skipped + // /compact (10:02:01) sorted before compact_boundary (10:02:10) by timestamp + expect(result.messages).toHaveLength(4); + expect(result.messages[0].role).toBe('user'); + expect(result.messages[0].content).toBe('Hello'); + expect(result.messages[1].role).toBe('assistant'); + expect(result.messages[2].role).toBe('user'); + expect(result.messages[2].displayContent).toBe('/compact'); + expect(result.messages[3].role).toBe('assistant'); + expect(result.messages[3].contentBlocks).toEqual([{ type: 'context_compacted' }]); + }); + + it('renders compact cancellation stderr as interrupt (not filtered)', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"/compact\\ncompact\\n"}}', + '{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","message":{"content":"Error: Compaction canceled."}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-compact-cancel'); + + // Compact cancellation stderr should appear as interrupt, not be filtered + const interruptMsg = result.messages.find(m => m.isInterrupt); + expect(interruptMsg).toBeDefined(); + expect(interruptMsg!.isInterrupt).toBe(true); + }); + + it('does not treat embedded compaction stderr mentions as interrupt markers', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:01Z","message":{"content":"## Context\\nError: Compaction canceled."}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-compact-quoted-cancel'); + + expect(result.messages).toHaveLength(2); + expect(result.messages.some(m => m.isInterrupt)).toBe(false); + }); + + it('preserves slash command invocations with clean displayContent', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"md2docx\\n/md2docx"}}', + '{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:00Z","isMeta":true,"message":{"content":"Use bash command md2word..."}}', + '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"(no content)"}]}}', + '{"type":"assistant","uuid":"a3","timestamp":"2024-01-15T10:03:01Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Skill","input":{"skill":"md2docx"}}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-slash-cmd'); + + // user "Hello", assistant "Hi!", user "/md2docx", assistant with Skill tool + // META (u3) should be skipped; "(no content)" text should be filtered + expect(result.messages).toHaveLength(4); + expect(result.messages[2].role).toBe('user'); + expect(result.messages[2].displayContent).toBe('/md2docx'); + expect(result.messages[3].role).toBe('assistant'); + expect(result.messages[3].content).toBe(''); + expect(result.messages[3].toolCalls).toHaveLength(1); + expect(result.messages[3].toolCalls![0].name).toBe('Skill'); + }); + + it('handles tool_result with error flag', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"invalid"}}]}}', + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:01:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"Command not found","is_error":true}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-error-result'); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].toolCalls![0].status).toBe('error'); + expect(result.messages[0].toolCalls![0].result).toBe('Command not found'); + }); + + it('returns error pass-through from readSDKSession', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockRejectedValue(new Error('Disk failure')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-disk-err'); + + expect(result.messages).toEqual([]); + expect(result.error).toBe('Disk failure'); + }); + + it('merges tool calls from consecutive assistant messages', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Read","input":{"path":"a.ts"}}]}}', + '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_use","id":"t2","name":"Write","input":{"path":"b.ts"}}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-tools'); + + // Consecutive assistant messages should merge into one + expect(result.messages).toHaveLength(1); + expect(result.messages[0].toolCalls).toHaveLength(2); + expect(result.messages[0].toolCalls![0].name).toBe('Read'); + expect(result.messages[0].toolCalls![1].name).toBe('Write'); + }); + + it('updates assistantMessageId to last entry when merging assistant messages', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"hello"}}', + '{"type":"assistant","uuid":"a1-first","parentUuid":"u1","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"text","text":"thinking..."}]}}', + '{"type":"assistant","uuid":"a1-mid","parentUuid":"a1-first","timestamp":"2024-01-15T10:00:02Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Read","input":{"path":"a.ts"}}]}}', + '{"type":"assistant","uuid":"a1-last","parentUuid":"a1-mid","timestamp":"2024-01-15T10:00:03Z","message":{"content":[{"type":"text","text":"Done!"}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-uuid'); + + expect(result.messages).toHaveLength(2); + const assistant = result.messages[1]; + expect(assistant.role).toBe('assistant'); + // Must be the last UUID so rewind targets the end of the turn + expect(assistant.assistantMessageId).toBe('a1-last'); + }); + }); + + describe('parseSDKMessageToChat - image extraction', () => { + it('extracts image attachments from user message with image blocks', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'user-img', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: [ + { type: 'text', text: 'Check this image' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk', + }, + }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.images).toHaveLength(1); + expect(chatMsg!.images![0].mediaType).toBe('image/png'); + expect(chatMsg!.images![0].data).toContain('iVBORw0KGgo'); + expect(chatMsg!.images![0].source).toBe('paste'); + expect(chatMsg!.images![0].name).toBe('image-1'); + }); + + it('does not extract images from assistant messages', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-img', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: [ + { type: 'text', text: 'Here is a response' }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.images).toBeUndefined(); + }); + + it('returns null for user message with only tool_result content blocks', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'user-tool-only', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: [ + { type: 'tool_result', tool_use_id: 't1', content: 'result data' }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + // Array content bypasses the null-return guard even without text/tool_use/images + expect(chatMsg).not.toBeNull(); + }); + + it('returns null for user message with empty string content', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'user-empty', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: '', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + expect(chatMsg).toBeNull(); + }); + + it('returns null for user message with no content', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'user-nocontent', + timestamp: '2024-01-15T10:30:00Z', + message: {}, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + expect(chatMsg).toBeNull(); + }); + + it('returns null for queue-operation messages', () => { + const sdkMsg: SDKNativeMessage = { + type: 'queue-operation', + uuid: 'queue-1', + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + expect(chatMsg).toBeNull(); + }); + }); + + describe('parseSDKMessageToChat - content block edge cases', () => { + it('skips text blocks that are whitespace-only in contentBlocks', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-whitespace', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: [ + { type: 'text', text: ' ' }, + { type: 'text', text: 'Actual content' }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + expect(chatMsg).not.toBeNull(); + // The whitespace-only text block should be skipped in contentBlocks + expect(chatMsg!.contentBlocks).toHaveLength(1); + expect(chatMsg!.contentBlocks![0].type).toBe('text'); + }); + + it('skips thinking blocks with empty thinking field', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-empty-think', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: [ + { type: 'thinking', thinking: '' }, + { type: 'text', text: 'Some answer' }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + expect(chatMsg).not.toBeNull(); + // Empty thinking block should be skipped + expect(chatMsg!.contentBlocks).toHaveLength(1); + expect(chatMsg!.contentBlocks![0].type).toBe('text'); + }); + + it('skips tool_use blocks without id in contentBlocks', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-no-id-tool', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: [ + { type: 'tool_use', name: 'Bash', input: {} }, + { type: 'text', text: 'After tool' }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + expect(chatMsg).not.toBeNull(); + // tool_use without id should be skipped in contentBlocks + expect(chatMsg!.contentBlocks).toHaveLength(1); + expect(chatMsg!.contentBlocks![0].type).toBe('text'); + }); + + it('returns undefined contentBlocks when all blocks are filtered out', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-all-filtered', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: [ + { type: 'tool_result', tool_use_id: 't1', content: 'result' }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + // Content is array (so not null), but all blocks filtered → undefined contentBlocks + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.contentBlocks).toBeUndefined(); + }); + + it('handles tool_use without input field', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-no-input', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: [ + { type: 'tool_use', id: 'tool-noinput', name: 'SomeTool' }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.toolCalls).toHaveLength(1); + expect(chatMsg!.toolCalls![0].input).toEqual({}); + }); + + it('handles tool_result with non-string content (JSON object)', () => { + const sdkMsg: SDKNativeMessage = { + type: 'assistant', + uuid: 'asst-json-result', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: [ + { type: 'tool_use', id: 'tool-json', name: 'Read', input: {} }, + { + type: 'tool_result', + tool_use_id: 'tool-json', + content: { file: 'test.ts', lines: 42 }, + }, + ], + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.toolCalls).toHaveLength(1); + // Non-string content should be JSON.stringified + expect(chatMsg!.toolCalls![0].result).toBe('{"file":"test.ts","lines":42}'); + }); + }); + + describe('parseSDKMessageToChat - rebuilt context with A: shorthand', () => { + it('detects rebuilt context using A: shorthand marker', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'rebuilt-short', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: 'User: hello\n\nA: hi there', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.isRebuiltContext).toBe(true); + }); + }); + + describe('parseSDKMessageToChat - interrupt tool use variant', () => { + it('marks tool use interrupt messages', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'interrupt-tool', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: '[Request interrupted by user for tool use]', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.isInterrupt).toBe(true); + }); + + it('does not mark quoted compact cancellation mention as interrupt', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'interrupt-compact-quoted', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: '## Context\nError: Compaction canceled.', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.isInterrupt).toBeUndefined(); + }); + + it('marks compact cancellation stderr as interrupt', () => { + const sdkMsg: SDKNativeMessage = { + type: 'user', + uuid: 'interrupt-compact', + timestamp: '2024-01-15T10:30:00Z', + message: { + content: 'Error: Compaction canceled.', + }, + }; + + const chatMsg = parseSDKMessageToChat(sdkMsg); + expect(chatMsg).not.toBeNull(); + expect(chatMsg!.isInterrupt).toBe(true); + }); + }); + + describe('loadSDKSessionMessages - merge edge cases', () => { + it('merges assistant content blocks when first has no content blocks', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}', + '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"thinking","thinking":"hmm"},{"type":"text","text":"Result here"}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-blocks'); + + expect(result.messages).toHaveLength(1); + // Merged: tool call from first + content blocks from both + expect(result.messages[0].toolCalls).toHaveLength(1); + expect(result.messages[0].contentBlocks!.length).toBeGreaterThanOrEqual(2); + }); + + it('merges assistant with empty target content', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + // First assistant: only tool_use, no text + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}', + // Second assistant: has text content + '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"text","text":"Here is the result"}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-empty-target'); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].content).toBe('Here is the result'); + }); + + it('handles multiple user images in a message', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + JSON.stringify({ + type: 'user', + uuid: 'u-imgs', + timestamp: '2024-01-15T10:00:00Z', + message: { + content: [ + { type: 'text', text: 'Check these images' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'abc123' } }, + { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'def456' } }, + ], + }, + }), + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-multi-images'); + + expect(result.messages).toHaveLength(1); + expect(result.messages[0].images).toHaveLength(2); + expect(result.messages[0].images![0].mediaType).toBe('image/png'); + expect(result.messages[0].images![1].mediaType).toBe('image/jpeg'); + expect(result.messages[0].images![1].name).toBe('image-2'); + }); + + it('extracts text from Agent tool results with array content', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + JSON.stringify({ + type: 'user', uuid: 'u1', timestamp: '2024-01-15T10:00:00Z', + message: { content: 'Use an agent' }, + }), + JSON.stringify({ + type: 'assistant', uuid: 'a1', parentUuid: 'u1', timestamp: '2024-01-15T10:00:01Z', + message: { content: [{ type: 'tool_use', id: 'agent-1', name: 'Agent', input: { description: 'test', prompt: 'do stuff' } }] }, + }), + // Agent tool result has array content (not string) + JSON.stringify({ + type: 'user', uuid: 'tr1', parentUuid: 'a1', timestamp: '2024-01-15T10:00:30Z', + toolUseResult: { status: 'completed', agentId: 'abc123' }, + message: { content: [{ + type: 'tool_result', tool_use_id: 'agent-1', is_error: false, + content: [ + { type: 'text', text: 'Agent completed the task successfully.' }, + { type: 'text', text: 'agentId: abc123\ntotal_tokens: 500' }, + ], + }] }, + }), + JSON.stringify({ + type: 'assistant', uuid: 'a2', parentUuid: 'tr1', timestamp: '2024-01-15T10:00:31Z', + message: { content: [{ type: 'text', text: 'Done.' }] }, + }), + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-agent-result'); + + // The Agent tool call should have extracted text, not JSON.stringify'd array + const assistantMsg = result.messages.find(m => m.role === 'assistant' && m.toolCalls?.length); + expect(assistantMsg).toBeDefined(); + const agentToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Agent'); + expect(agentToolCall).toBeDefined(); + expect(agentToolCall!.result).toBe( + 'Agent completed the task successfully.\nagentId: abc123\ntotal_tokens: 500' + ); + // Must NOT contain JSON artifacts + expect(agentToolCall!.result).not.toContain('"type":"text"'); + }); + }); + + describe('filterActiveBranch', () => { + it('returns all entries for linear chain without resumeAtMessageId', () => { + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + { type: 'user', uuid: 'u2', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, + ]; + + const result = filterActiveBranch(entries); + + expect(result).toHaveLength(4); + expect(result).toEqual(entries); + }); + + it('truncates linear chain at resumeAtMessageId UUID', () => { + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + { type: 'user', uuid: 'u2', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, + ]; + + const result = filterActiveBranch(entries, 'a1'); + + expect(result).toHaveLength(2); + expect(result.map(e => e.uuid)).toEqual(['u1', 'a1']); + }); + + it('returns only new branch after rewind + follow-up', () => { + // Original: u1 → a1 → u2 → a2 + // Rewind to a1, follow-up: u3 → a3 (u3.parentUuid = a1) + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + { type: 'user', uuid: 'u2', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, + { type: 'user', uuid: 'u3', parentUuid: 'a1' }, // Branch point: a1 has 2 children + { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, + ]; + + const result = filterActiveBranch(entries); + + // Should include: u1, a1, u3, a3 (new branch), not u2, a2 + expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u3', 'a3']); + }); + + it('returns latest branch after multiple rewinds', () => { + // Original: u1 → a1 → u2 → a2 + // Rewind 1: u3 → a3 (parent a1) + // Rewind 2: u4 → a4 (parent a1) — third child of a1 + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + { type: 'user', uuid: 'u2', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, + { type: 'user', uuid: 'u3', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, + { type: 'user', uuid: 'u4', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a4', parentUuid: 'u4' }, + ]; + + const result = filterActiveBranch(entries); + + // Last entry with uuid is a4, walk back: a4 → u4 → a1 → u1 + expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u4', 'a4']); + }); + + it('returns all entries when resumeAtMessageId UUID not found (safety)', () => { + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + ]; + + const result = filterActiveBranch(entries, 'nonexistent-uuid'); + + expect(result).toHaveLength(2); + expect(result).toEqual(entries); + }); + + it('returns empty for empty entries', () => { + const result = filterActiveBranch([]); + expect(result).toEqual([]); + }); + + it('does not misdetect branching when duplicate uuid entries exist', () => { + // SDK may write the same message twice (e.g., around compaction). + // Without dedup, duplicate entries inflate childCount causing false branch detection. + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + { type: 'user', uuid: 'u2', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, + // Duplicate of u2 — SDK writes this again + { type: 'user', uuid: 'u2', parentUuid: 'a1' }, + ]; + + // Without dedup fix, a1 would have childCount=2 (u2 counted twice), + // triggering branch detection and excluding u2/a2. + const result = filterActiveBranch(entries); + + // Should be a no-op (linear chain, no branching) + expect(result).toHaveLength(4); + expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u2', 'a2']); + }); + + it('correctly truncates at resumeAtMessageId when duplicates exist', () => { + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + { type: 'user', uuid: 'u2', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, + // Duplicate of u2 + { type: 'user', uuid: 'u2', parentUuid: 'a1' }, + ]; + + const result = filterActiveBranch(entries, 'a1'); + + // Should truncate at a1, including only u1 and a1 + expect(result).toHaveLength(2); + expect(result.map(e => e.uuid)).toEqual(['u1', 'a1']); + }); + + it('preserves no-uuid entries within active branch region', () => { + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'queue-operation' }, // No uuid — between u1 (active) and a1 (active) + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + { type: 'user', uuid: 'u2', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, + { type: 'user', uuid: 'u3', parentUuid: 'a1' }, // Branch + { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, + ]; + + const result = filterActiveBranch(entries); + + const uuids = result.filter(e => e.uuid).map(e => e.uuid); + expect(uuids).toEqual(['u1', 'a1', 'u3', 'a3']); + // queue-operation is between u1 (active) and a1 (active), so preserved + expect(result.some(e => e.type === 'queue-operation')).toBe(true); + }); + + it('truncates at resumeAtMessageId on latest branch when branching exists', () => { + // Rewind 1 + follow-up created a branch: u3/a3 branch off a1 + // Rewind 2 on the new branch (no follow-up): resumeAtMessageId = a1 + // On reload, should truncate at a1, not show u3/a3 + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + { type: 'user', uuid: 'u2', parentUuid: 'a1' }, // old branch + { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, // old branch + { type: 'user', uuid: 'u3', parentUuid: 'a1' }, // new branch (from rewind 1) + { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, // new branch + ]; + + // Rewind 2 on new branch: truncate at a1 + const result = filterActiveBranch(entries, 'a1'); + + expect(result.map(e => e.uuid)).toEqual(['u1', 'a1']); + }); + + it('truncates at resumeAtMessageId mid-branch when branching exists', () => { + // Branch from a1: old (u2→a2) and new (u3→a3→u4→a4) + // Rewind on new branch to u4: resumeAtMessageId = a3 + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + { type: 'user', uuid: 'u2', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, + { type: 'user', uuid: 'u3', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, + { type: 'user', uuid: 'u4', parentUuid: 'a3' }, + { type: 'assistant', uuid: 'a4', parentUuid: 'u4' }, + ]; + + const result = filterActiveBranch(entries, 'a3'); + + expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u3', 'a3']); + }); + + it('ignores resumeAtMessageId not on latest branch', () => { + // Branch from a1: old (u2→a2) and new (u3→a3) + // resumeAtMessageId points to a2 (on the OLD branch) — should be ignored + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + { type: 'user', uuid: 'u2', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, + { type: 'user', uuid: 'u3', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, + ]; + + // a2 is on old branch, not an ancestor of leaf a3 + const result = filterActiveBranch(entries, 'a2'); + + // Should return full latest branch (ignoring stale resumeAtMessageId) + expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u3', 'a3']); + }); + + it('drops no-uuid entries in old branch region', () => { + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + { type: 'user', uuid: 'u2', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, + { type: 'queue-operation' }, // No uuid — between a2 (old) and u3 (active) + { type: 'user', uuid: 'u3', parentUuid: 'a1' }, // Branch + { type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, + ]; + + const result = filterActiveBranch(entries); + + const uuids = result.filter(e => e.uuid).map(e => e.uuid); + expect(uuids).toEqual(['u1', 'a1', 'u3', 'a3']); + // queue-operation between a2 (not active) and u3 (active) — should be dropped + expect(result.some(e => e.type === 'queue-operation')).toBe(false); + }); + + it('excludes progress entries and does not treat them as branches', () => { + // Simulates Agent tool call: assistant issues tool_use, SDK writes progress chain, + // then next user message is parented to end of progress chain. + // Without fix: progress creates false branching, losing the conversation branch. + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + // a1 is a tool_use (Agent). SDK writes tool_result + progress chain as siblings: + { type: 'user', uuid: 'tr1', parentUuid: 'a1', toolUseResult: {} }, // tool result + { type: 'assistant', uuid: 'a2', parentUuid: 'tr1' }, // response after tool + // Progress chain branching off a1 (subagent execution logs): + { type: 'progress' as SDKNativeMessage['type'], uuid: 'p1', parentUuid: 'a1' }, + { type: 'progress' as SDKNativeMessage['type'], uuid: 'p2', parentUuid: 'p1' }, + { type: 'progress' as SDKNativeMessage['type'], uuid: 'p3', parentUuid: 'p2' }, + // Next conversation message parented to end of progress chain: + { type: 'user', uuid: 'u2', parentUuid: 'p3' }, + { type: 'assistant', uuid: 'a3', parentUuid: 'u2' }, + ]; + + const result = filterActiveBranch(entries); + const uuids = result.filter(e => e.uuid).map(e => e.uuid); + + // All conversation entries should be present, progress entries excluded + expect(uuids).toEqual(['u1', 'a1', 'tr1', 'a2', 'u2', 'a3']); + expect(result.every(e => (e.type as string) !== 'progress')).toBe(true); + }); + + it('reparents through long progress chains to preserve full conversation', () => { + // Two turns, each with Agent tool calls generating progress entries. + // The second turn's user message is parented to the end of the first progress chain. + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1-think', parentUuid: 'u1' }, + { type: 'assistant', uuid: 'a1-tool', parentUuid: 'a1-think' }, // Agent tool_use + // Conversation branch: tool result → assistant response + { type: 'user', uuid: 'tr1', parentUuid: 'a1-tool', toolUseResult: {} }, + { type: 'assistant', uuid: 'a1-think2', parentUuid: 'tr1' }, + { type: 'assistant', uuid: 'a1-text', parentUuid: 'a1-think2' }, + // Progress chain off a1-tool: + { type: 'progress' as SDKNativeMessage['type'], uuid: 'p1', parentUuid: 'a1-tool' }, + { type: 'progress' as SDKNativeMessage['type'], uuid: 'p2', parentUuid: 'p1' }, + // System entry chained to progress: + { type: 'system', uuid: 'sys1', parentUuid: 'p2' }, + // Second turn parented to system (which is parented to progress chain): + { type: 'user', uuid: 'u2', parentUuid: 'sys1' }, + { type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, + ]; + + const result = filterActiveBranch(entries); + const uuids = result.filter(e => e.uuid).map(e => e.uuid); + + // Must include BOTH turns' content — nothing lost + expect(uuids).toContain('a1-text'); // First turn's response + expect(uuids).toContain('u2'); // Second turn's input + expect(uuids).toContain('a2'); // Second turn's response + // Progress entries must be excluded + expect(uuids).not.toContain('p1'); + expect(uuids).not.toContain('p2'); + }); + + it('does not treat parallel tool calls as branches', () => { + // Assistant sends two tool_use blocks in parallel. SDK writes them as + // separate entries. Their tool results are parented to respective entries. + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1-text', parentUuid: 'u1' }, + { type: 'assistant', uuid: 'a1-tool1', parentUuid: 'a1-text' }, // first tool_use + { type: 'assistant', uuid: 'a1-tool2', parentUuid: 'a1-text' }, // second tool_use (parallel) + // Tool results: + { type: 'user', uuid: 'tr1', parentUuid: 'a1-tool1', toolUseResult: {} }, + { type: 'user', uuid: 'tr2', parentUuid: 'a1-tool2', toolUseResult: {} }, + // Assistant continues after both results: + { type: 'assistant', uuid: 'a2', parentUuid: 'tr2' }, + ]; + + const result = filterActiveBranch(entries); + const uuids = result.filter(e => e.uuid).map(e => e.uuid); + + // Both tool calls and their results should be present + expect(uuids).toContain('a1-tool1'); + expect(uuids).toContain('a1-tool2'); + expect(uuids).toContain('tr1'); + expect(uuids).toContain('tr2'); + expect(uuids).toContain('a2'); + }); + + it('handles real rewind alongside progress entries', () => { + // Turn 1 with Agent tool (progress entries), then a real rewind at a1. + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + // Original continuation: + { type: 'user', uuid: 'u2-old', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a2-old', parentUuid: 'u2-old' }, + // Progress entries off a1: + { type: 'progress' as SDKNativeMessage['type'], uuid: 'p1', parentUuid: 'a1' }, + { type: 'progress' as SDKNativeMessage['type'], uuid: 'p2', parentUuid: 'p1' }, + // Rewind: new user message also branching off a1 + { type: 'user', uuid: 'u2-new', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a2-new', parentUuid: 'u2-new' }, + ]; + + const result = filterActiveBranch(entries); + const uuids = result.filter(e => e.uuid).map(e => e.uuid); + + // Should follow the latest branch (u2-new), not the old one or progress + expect(uuids).toEqual(['u1', 'a1', 'u2-new', 'a2-new']); + }); + + it('detects rewind when abandoned path continues through assistant/tool nodes', () => { + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1', parentUuid: 'u1' }, + { type: 'assistant', uuid: 'a1-tool', parentUuid: 'a1' }, + { type: 'user', uuid: 'tr1', parentUuid: 'a1-tool', toolUseResult: {} }, + { type: 'assistant', uuid: 'a2', parentUuid: 'tr1' }, + { type: 'user', uuid: 'u2-new', parentUuid: 'a1' }, + { type: 'assistant', uuid: 'a3-new', parentUuid: 'u2-new' }, + ]; + + const result = filterActiveBranch(entries); + const uuids = result.filter(e => e.uuid).map(e => e.uuid); + + expect(uuids).toEqual(['u1', 'a1', 'u2-new', 'a3-new']); + }); + + it('preserves earlier parallel tool-result descendants when a later rewind exists', () => { + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', parentUuid: null }, + { type: 'assistant', uuid: 'a1-text', parentUuid: 'u1' }, + { type: 'assistant', uuid: 'a1-tool1', parentUuid: 'a1-text' }, + { type: 'assistant', uuid: 'a1-tool2', parentUuid: 'a1-text' }, + { type: 'user', uuid: 'tr1', parentUuid: 'a1-tool1', toolUseResult: {} }, + { type: 'user', uuid: 'tr2', parentUuid: 'a1-tool2', toolUseResult: {} }, + { type: 'assistant', uuid: 'a2', parentUuid: 'tr2' }, + { type: 'user', uuid: 'u3-old', parentUuid: 'a2' }, + { type: 'assistant', uuid: 'a3-old', parentUuid: 'u3-old' }, + { type: 'user', uuid: 'u3-new', parentUuid: 'a2' }, + { type: 'assistant', uuid: 'a3-new', parentUuid: 'u3-new' }, + ]; + + const result = filterActiveBranch(entries); + const uuids = result.filter(e => e.uuid).map(e => e.uuid); + + expect(uuids).toEqual([ + 'u1', + 'a1-text', + 'a1-tool1', + 'a1-tool2', + 'tr1', + 'tr2', + 'a2', + 'u3-new', + 'a3-new', + ]); + }); + }); + + describe('loadSDKSessionMessages with resumeAtMessageId', () => { + it('returns identical behavior without resumeAtMessageId', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', + '{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-no-resume'); + + expect(result.messages).toHaveLength(2); + }); + + it('truncates messages at resumeAtMessageId on linear JSONL', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', + '{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', + '{"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"2024-01-15T10:02:00Z","message":{"content":"More"}}', + '{"type":"assistant","uuid":"a2","parentUuid":"u2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"More response"}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-truncate', 'a1'); + + // Should only have u1 and a1 (truncated at a1) + expect(result.messages).toHaveLength(2); + expect(result.messages[0].content).toBe('Hello'); + expect(result.messages[1].content).toBe('Hi!'); + }); + + it('returns correct active branch on branched JSONL', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockResolvedValue([ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}', + '{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}', + '{"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"2024-01-15T10:02:00Z","message":{"content":"Old branch"}}', + '{"type":"assistant","uuid":"a2","parentUuid":"u2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"Old response"}]}}', + '{"type":"user","uuid":"u3","parentUuid":"a1","timestamp":"2024-01-15T10:04:00Z","message":{"content":"New branch"}}', + '{"type":"assistant","uuid":"a3","parentUuid":"u3","timestamp":"2024-01-15T10:05:00Z","message":{"content":[{"type":"text","text":"New response"}]}}', + ].join('\n')); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-branched'); + + // Should have: u1 "Hello", a1 "Hi!", u3 "New branch", a3 "New response" + // Old branch (u2, a2) should be excluded + expect(result.messages).toHaveLength(4); + expect(result.messages[0].content).toBe('Hello'); + expect(result.messages[1].content).toBe('Hi!'); + expect(result.messages[2].content).toBe('New branch'); + expect(result.messages[3].content).toBe('New response'); + }); + }); + + describe('extractToolResultContent', () => { + it('passes through string content unchanged', () => { + expect(extractToolResultContent('hello world')).toBe('hello world'); + }); + + it('extracts text from array of content blocks (Agent results)', () => { + const content = [ + { type: 'text', text: 'First block of output.' }, + { type: 'text', text: 'agentId: abc123\ntotal_tokens: 1000' }, + ]; + expect(extractToolResultContent(content)).toBe( + 'First block of output.\nagentId: abc123\ntotal_tokens: 1000' + ); + }); + + it('skips non-text blocks in array content', () => { + const content = [ + { type: 'image', source: { type: 'base64', data: 'abc' } }, + { type: 'text', text: 'The only text.' }, + ]; + expect(extractToolResultContent(content)).toBe('The only text.'); + }); + + it('returns empty string for null/undefined content', () => { + expect(extractToolResultContent(null)).toBe(''); + expect(extractToolResultContent(undefined)).toBe(''); + }); + + it('JSON-stringifies unknown content types as fallback', () => { + expect(extractToolResultContent({ custom: 'value' })).toBe('{"custom":"value"}'); + }); + + it('handles empty array content', () => { + expect(extractToolResultContent([])).toBe(''); + }); + + it('JSON-stringifies non-empty array with no text blocks (e.g. tool_reference)', () => { + const content = [ + { type: 'tool_reference', tool_name: 'WebSearch' }, + { type: 'tool_reference', tool_name: 'Grep' }, + ]; + expect(extractToolResultContent(content)).toBe(JSON.stringify(content)); + }); + + it('JSON-stringifies non-empty array with no text blocks using fallbackIndent', () => { + const content = [ + { type: 'tool_reference', tool_name: 'Read' }, + ]; + expect(extractToolResultContent(content, { fallbackIndent: 2 })).toBe( + JSON.stringify(content, null, 2) + ); + }); + }); + + describe('collectAsyncSubagentResults', () => { + it('extracts task-notification data from queue-operation enqueue entries', () => { + const entries: SDKNativeMessage[] = [ + { + type: 'queue-operation', + operation: 'enqueue', + content: ` +ae5eb9a +completed +Agent "Review code" completed +Found 3 issues in the codebase. + +1. Missing error handling in auth module. +2. Unused import in utils.ts. +3. Race condition in fetchData. +`, + }, + ]; + + const results = collectAsyncSubagentResults(entries); + + expect(results.size).toBe(1); + const entry = results.get('ae5eb9a')!; + expect(entry.status).toBe('completed'); + expect(entry.result).toContain('Found 3 issues'); + expect(entry.result).toContain('Race condition in fetchData.'); + }); + + it('collects multiple queue-operation entries', () => { + const entries: SDKNativeMessage[] = [ + { + type: 'queue-operation', + operation: 'enqueue', + content: 'agent-1completedResult 1', + }, + { + type: 'queue-operation', + operation: 'enqueue', + content: 'agent-2errorTask failed', + }, + ]; + + const results = collectAsyncSubagentResults(entries); + + expect(results.size).toBe(2); + expect(results.get('agent-1')!.status).toBe('completed'); + expect(results.get('agent-2')!.status).toBe('error'); + expect(results.get('agent-2')!.result).toBe('Task failed'); + }); + + it('skips dequeue operations', () => { + const entries: SDKNativeMessage[] = [ + { + type: 'queue-operation', + operation: 'dequeue', + sessionId: 'session-1', + }, + ]; + + const results = collectAsyncSubagentResults(entries); + expect(results.size).toBe(0); + }); + + it('skips entries without task-notification content', () => { + const entries: SDKNativeMessage[] = [ + { + type: 'queue-operation', + operation: 'enqueue', + content: 'some other content', + }, + ]; + + const results = collectAsyncSubagentResults(entries); + expect(results.size).toBe(0); + }); + + it('skips entries without task-id or result', () => { + const entries: SDKNativeMessage[] = [ + { + type: 'queue-operation', + operation: 'enqueue', + content: 'completedNo task-id', + }, + { + type: 'queue-operation', + operation: 'enqueue', + content: 'has-idcompleted', + }, + ]; + + const results = collectAsyncSubagentResults(entries); + expect(results.size).toBe(0); + }); + + it('defaults status to completed when status tag is missing', () => { + const entries: SDKNativeMessage[] = [ + { + type: 'queue-operation', + operation: 'enqueue', + content: 'no-statusDone', + }, + ]; + + const results = collectAsyncSubagentResults(entries); + expect(results.get('no-status')!.status).toBe('completed'); + }); + + it('ignores non-queue-operation messages', () => { + const entries: SDKNativeMessage[] = [ + { type: 'user', uuid: 'u1', message: { content: 'hello' } }, + { type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'hi' }] } }, + ]; + + const results = collectAsyncSubagentResults(entries); + expect(results.size).toBe(0); + }); + }); + + describe('resolveToolUseResultStatus', () => { + it('preserves orphaned fallback when the tool result has no stronger signal', () => { + expect(resolveToolUseResultStatus(undefined, 'orphaned')).toBe('orphaned'); + expect(resolveToolUseResultStatus({ status: 'unknown' }, 'orphaned')).toBe('orphaned'); + }); + + it('maps task notification failure statuses to error', () => { + expect(resolveToolUseResultStatus({ status: 'failed' }, 'completed')).toBe('error'); + expect(resolveToolUseResultStatus({ status: 'stopped' }, 'completed')).toBe('error'); + expect(resolveToolUseResultStatus({ status: 'killed' }, 'completed')).toBe('error'); + }); + }); + + describe('loadSDKSessionMessages - async subagent hydration', () => { + it('populates toolCall.subagent for async Task tools from queue-operation results', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockImplementation(async (filePath: any) => { + const p = String(filePath); + if (p.endsWith('.jsonl') && !p.includes('subagents')) { + return [ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run background task"}}', + // Assistant spawns async Task + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review code","prompt":"Check for bugs","run_in_background":true}}]}}', + // Task tool_result with agentId (SDK launch shape) + `{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a","status":"async_launched","description":"Review code","prompt":"Check for bugs","outputFile":"/tmp/agent.output"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background."}]}}`, + // Queue-operation with full result + `{"type":"queue-operation","operation":"enqueue","content":"ae5eb9acompletedAgent completedFound 3 issues:\\n1. Missing error handling\\n2. Unused import\\n3. Race condition"}`, + // Assistant continues after + '{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:05:00Z","message":{"content":[{"type":"text","text":"The review found 3 issues."}]}}', + ].join('\n'); + } + // Subagent sidecar file + return ''; + }); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-async-hydrate'); + + // Should have: user message, merged assistant with Task tool, assistant follow-up + expect(result.messages.length).toBeGreaterThanOrEqual(2); + + const assistantMsg = result.messages.find(m => m.role === 'assistant' && m.toolCalls?.some(tc => tc.name === 'Task')); + expect(assistantMsg).toBeDefined(); + + const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; + expect(taskToolCall.subagent).toBeDefined(); + expect(taskToolCall.subagent!.mode).toBe('async'); + expect(taskToolCall.subagent!.agentId).toBe('ae5eb9a'); + expect(taskToolCall.subagent!.status).toBe('completed'); + expect(taskToolCall.subagent!.asyncStatus).toBe('completed'); + expect(taskToolCall.subagent!.result).toContain('Found 3 issues'); + expect(taskToolCall.subagent!.result).toContain('Race condition'); + // toolCall.result should also be updated + expect(taskToolCall.result).toContain('Found 3 issues'); + expect(taskToolCall.status).toBe('completed'); + }); + + it('prefers queue-operation completion over misleading async tool_result error flags', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockImplementation(async (filePath: any) => { + const p = String(filePath); + if (p.endsWith('.jsonl') && !p.includes('subagents')) { + return [ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run background task"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review code","prompt":"Check for bugs","run_in_background":true}}]}}', + `{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background.","is_error":true}]}}`, + `{"type":"queue-operation","operation":"enqueue","content":"ae5eb9acompletedAgent completedBackground work finished cleanly"}`, + ].join('\n'); + } + return ''; + }); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-async-error-flag'); + + const assistantMsg = result.messages.find(m => m.role === 'assistant' && m.toolCalls?.some(tc => tc.name === 'Task')); + const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; + + expect(taskToolCall.subagent).toBeDefined(); + expect(taskToolCall.subagent!.status).toBe('completed'); + expect(taskToolCall.subagent!.asyncStatus).toBe('completed'); + expect(taskToolCall.subagent!.result).toBe('Background work finished cleanly'); + expect(taskToolCall.status).toBe('completed'); + expect(taskToolCall.result).toBe('Background work finished cleanly'); + }); + + it('uses truncated API result when no queue-operation exists', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockImplementation(async (filePath: any) => { + const p = String(filePath); + if (p.endsWith('.jsonl') && !p.includes('subagents')) { + return [ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run task"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Test task","prompt":"test","run_in_background":true}}]}}', + `{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"abc123"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched."}]}}`, + // No queue-operation entry + ].join('\n'); + } + return ''; + }); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-no-queue-op'); + + const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); + const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; + + expect(taskToolCall.subagent).toBeDefined(); + expect(taskToolCall.subagent!.agentId).toBe('abc123'); + expect(taskToolCall.subagent!.status).toBe('running'); + expect(taskToolCall.subagent!.asyncStatus).toBe('running'); + expect(taskToolCall.status).toBe('running'); + // Falls back to the API content (truncated) + expect(taskToolCall.subagent!.result).toBe('Task launched.'); + }); + + it('treats async launch tool results as running even when the content block is flagged as error', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockImplementation(async (filePath: any) => { + const p = String(filePath); + if (p.endsWith('.jsonl') && !p.includes('subagents')) { + return [ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run task"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Test task","prompt":"test","run_in_background":true}}]}}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"abc123","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background.","is_error":true}]}}', + ].join('\n'); + } + return ''; + }); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-async-launch-error-flag'); + + const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); + const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; + + expect(taskToolCall.subagent).toBeDefined(); + expect(taskToolCall.subagent!.status).toBe('running'); + expect(taskToolCall.subagent!.asyncStatus).toBe('running'); + expect(taskToolCall.status).toBe('running'); + expect(taskToolCall.result).toBe('Task launched in background.'); + }); + + it('treats queue-operation success status as completed', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockImplementation(async (filePath: any) => { + const p = String(filePath); + if (p.endsWith('.jsonl') && !p.includes('subagents')) { + return [ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run task"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Test task","prompt":"test","run_in_background":true}}]}}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"abc123","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background."}]}}', + '{"type":"queue-operation","operation":"enqueue","content":"abc123successBackground task succeeded"}', + ].join('\n'); + } + return ''; + }); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-queue-success'); + + const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); + const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; + + expect(taskToolCall.subagent).toBeDefined(); + expect(taskToolCall.subagent!.status).toBe('completed'); + expect(taskToolCall.subagent!.asyncStatus).toBe('completed'); + expect(taskToolCall.status).toBe('completed'); + expect(taskToolCall.result).toBe('Background task succeeded'); + }); + + it('does not build SubagentInfo for sync Task tools', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockImplementation(async (filePath: any) => { + const p = String(filePath); + if (p.endsWith('.jsonl') && !p.includes('subagents')) { + return [ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run sync task"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Sync task","prompt":"test","run_in_background":false}}]}}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Sync result"}]}}', + ].join('\n'); + } + return ''; + }); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-sync-task'); + + const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); + const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; + + // Sync tasks should NOT get SubagentInfo from this pass + expect(taskToolCall.subagent).toBeUndefined(); + }); + + it('loads subagent tool calls from sidecar JSONL', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockImplementation(async (filePath: any) => { + const p = String(filePath); + if (p.includes('subagents/agent-ae5eb9a.jsonl')) { + return [ + '{"type":"assistant","timestamp":"2024-01-15T10:02:00Z","message":{"content":[{"type":"tool_use","id":"sub-tool-1","name":"Grep","input":{"pattern":"TODO"}}]}}', + '{"type":"user","timestamp":"2024-01-15T10:02:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"sub-tool-1","content":"3 matches found"}]}}', + ].join('\n'); + } + if (p.endsWith('.jsonl')) { + return [ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Review"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review","prompt":"check","run_in_background":true}}]}}', + `{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Launched"}]}}`, + `{"type":"queue-operation","operation":"enqueue","content":"ae5eb9acompletedDone reviewing"}`, + ].join('\n'); + } + return ''; + }); + + const result = await loadSDKSessionMessages('/Users/test/vault', 'session-sidecar'); + + const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); + const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; + + expect(taskToolCall.subagent).toBeDefined(); + expect(taskToolCall.subagent!.toolCalls).toHaveLength(1); + expect(taskToolCall.subagent!.toolCalls[0].name).toBe('Grep'); + expect(taskToolCall.subagent!.toolCalls[0].result).toBe('3 matches found'); + }); + + it('loads subagent tool calls beside a relocated session transcript', async () => { + mockExistsSync.mockReturnValue(true); + mockFsPromises.readFile.mockImplementation(async (filePath: any) => { + const p = String(filePath); + if (p === '/old-project/session-sidecar.jsonl') { + return [ + '{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Review"}}', + '{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review","prompt":"check","run_in_background":true}}]}}', + '{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Launched"}]}}', + ].join('\n'); + } + if (p === '/old-project/session-sidecar/subagents/agent-ae5eb9a.jsonl') { + return [ + '{"type":"assistant","timestamp":"2024-01-15T10:02:00Z","message":{"content":[{"type":"tool_use","id":"sub-tool-1","name":"Grep","input":{"pattern":"TODO"}}]}}', + '{"type":"user","timestamp":"2024-01-15T10:02:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"sub-tool-1","content":"3 matches found"}]}}', + ].join('\n'); + } + return ''; + }); + + const result = await loadSDKSessionMessages( + '/Users/test/vault', + 'session-sidecar', + undefined, + '/old-project/session-sidecar.jsonl', + ); + + const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task')); + const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!; + expect(mockFsPromises.readFile).toHaveBeenCalledWith( + '/old-project/session-sidecar/subagents/agent-ae5eb9a.jsonl', + 'utf-8', + ); + expect(taskToolCall.subagent!.toolCalls).toEqual([ + expect.objectContaining({ name: 'Grep', result: '3 matches found' }), + ]); + }); + }); +}); diff --git a/tests/unit/utils/session.test.ts b/tests/unit/utils/session.test.ts new file mode 100644 index 0000000..bdf14fe --- /dev/null +++ b/tests/unit/utils/session.test.ts @@ -0,0 +1,985 @@ +import type { ChatMessage, ToolCallInfo } from '@/core/types'; +import { + buildContextFromHistory, + buildPromptWithHistoryContext, + formatContextLine, + formatToolCallForContext, + getLastUserMessage, + isSessionExpiredError, + isSessionMissingError, + truncateToolResult, +} from '@/utils/session'; + +describe('session utilities', () => { + describe('isSessionExpiredError', () => { + it('returns true for "session expired" error', () => { + const error = new Error('Session expired'); + expect(isSessionExpiredError(error)).toBe(true); + }); + + it('returns true for "session not found" error', () => { + const error = new Error('Session not found'); + expect(isSessionExpiredError(error)).toBe(true); + }); + + it('returns true for the Claude missing-conversation error', () => { + const error = new Error('No conversation found with session ID: session-123'); + expect(isSessionExpiredError(error)).toBe(true); + expect(isSessionMissingError(error)).toBe(true); + expect(isSessionMissingError(error, 'session-123')).toBe(true); + expect(isSessionMissingError(error, 'different-session')).toBe(false); + }); + + it('does not classify generic not-found wording as confirmed provider deletion', () => { + expect(isSessionMissingError(new Error('Session not found'))).toBe(false); + expect(isSessionMissingError(new Error('No conversation found'))).toBe(false); + }); + + it('returns true for "invalid session" error', () => { + const error = new Error('Invalid session'); + expect(isSessionExpiredError(error)).toBe(true); + }); + + it('returns true for "session invalid" error', () => { + const error = new Error('Session invalid'); + expect(isSessionExpiredError(error)).toBe(true); + }); + + it('returns true for "process exited with code" error', () => { + const error = new Error('Process exited with code 1'); + expect(isSessionExpiredError(error)).toBe(true); + }); + + it('returns true for compound pattern "session" + "expired"', () => { + const error = new Error('The session has expired'); + expect(isSessionExpiredError(error)).toBe(true); + }); + + it('returns true for compound pattern "resume" + "failed"', () => { + const error = new Error('Failed to resume session'); + expect(isSessionExpiredError(error)).toBe(true); + }); + + it('returns true for compound pattern "resume" + "error"', () => { + const error = new Error('Resume error occurred'); + expect(isSessionExpiredError(error)).toBe(true); + }); + + it('returns false for unrelated errors', () => { + const error = new Error('Network timeout'); + expect(isSessionExpiredError(error)).toBe(false); + }); + + it('returns false for non-Error values', () => { + expect(isSessionExpiredError('string error')).toBe(false); + expect(isSessionExpiredError(null)).toBe(false); + expect(isSessionExpiredError(undefined)).toBe(false); + expect(isSessionExpiredError(42)).toBe(false); + }); + + it('is case-insensitive', () => { + const error = new Error('SESSION EXPIRED'); + expect(isSessionExpiredError(error)).toBe(true); + }); + }); + + describe('formatToolCallForContext', () => { + it('formats successful tool call with input but without result', () => { + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Read', + input: { file_path: '/path/to/file.md' }, + status: 'completed', + result: 'File contents here - this should NOT be included', + }; + + const result = formatToolCallForContext(toolCall); + + // Successful tools show input but no result (Claude can re-execute if needed) + expect(result).toBe('[Tool Read input: file_path=/path/to/file.md status=completed]'); + expect(result).not.toContain('File contents'); + }); + + it('formats tool call without input', () => { + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Read', + input: {}, + status: 'completed', + }; + + const result = formatToolCallForContext(toolCall); + + expect(result).toBe('[Tool Read status=completed]'); + }); + + it('formats failed tool call with input and error message', () => { + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Read', + input: { file_path: '/path/to/missing.txt' }, + status: 'error', + result: 'File not found', + }; + + const result = formatToolCallForContext(toolCall); + + expect(result).toBe('[Tool Read input: file_path=/path/to/missing.txt status=error] error: File not found'); + }); + + it('formats blocked tool call with input and error message', () => { + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Bash', + input: { command: 'rm -rf /' }, + status: 'blocked', + result: 'Access denied by user approval', + }; + + const result = formatToolCallForContext(toolCall); + + expect(result).toBe('[Tool Bash input: command=rm -rf / status=blocked] error: Access denied by user approval'); + }); + + it('truncates long input values', () => { + const longPath = '/very/long/path/' + 'x'.repeat(150); + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Read', + input: { file_path: longPath }, + status: 'completed', + }; + + const result = formatToolCallForContext(toolCall); + + // Long values truncated to 100 chars (/very/long/path/ = 16 chars, so 84 x's + ...) + expect(result).toContain('file_path=/very/long/path/' + 'x'.repeat(84) + '...'); + expect(result).not.toContain(longPath); + }); + + it('truncates long error messages to default 500 chars', () => { + const longError = 'x'.repeat(700); + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Bash', + input: {}, + status: 'error', + result: longError, + }; + + const result = formatToolCallForContext(toolCall); + + expect(result).toContain('x'.repeat(500)); + expect(result).toContain('(truncated)'); + }); + + it('truncates to custom max length for errors', () => { + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Bash', + input: {}, + status: 'error', + result: 'x'.repeat(500), + }; + + const result = formatToolCallForContext(toolCall, 100); + + expect(result).toContain('x'.repeat(100)); + expect(result).toContain('(truncated)'); + }); + + it('defaults to "completed" status when status is undefined', () => { + const toolCall = { + id: 'tool-1', + name: 'Write', + input: {}, + status: 'completed', + } as ToolCallInfo; + + const result = formatToolCallForContext(toolCall); + + expect(result).toBe('[Tool Write status=completed]'); + }); + + it('handles empty result string for successful tool', () => { + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Edit', + input: {}, + status: 'completed', + result: '', + }; + + const result = formatToolCallForContext(toolCall); + + expect(result).toBe('[Tool Edit status=completed]'); + }); + + it('handles empty result string for failed tool', () => { + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Edit', + input: {}, + status: 'error', + result: '', + }; + + const result = formatToolCallForContext(toolCall); + + expect(result).toBe('[Tool Edit status=error]'); + }); + + it('handles whitespace-only result for successful tool', () => { + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Glob', + input: {}, + status: 'completed', + result: ' \n\t ', + }; + + const result = formatToolCallForContext(toolCall); + + expect(result).toBe('[Tool Glob status=completed]'); + }); + }); + + describe('truncateToolResult', () => { + it('returns unchanged result when under max length', () => { + const result = truncateToolResult('short result', 100); + expect(result).toBe('short result'); + }); + + it('returns unchanged result when exactly at max length', () => { + const result = truncateToolResult('x'.repeat(500), 500); + expect(result).toBe('x'.repeat(500)); + }); + + it('truncates and adds indicator when over max length', () => { + const longResult = 'x'.repeat(700); + const result = truncateToolResult(longResult, 500); + + expect(result).toBe('x'.repeat(500) + '... (truncated)'); + }); + + it('uses default max length of 500', () => { + const longResult = 'x'.repeat(700); + const result = truncateToolResult(longResult); + + expect(result).toBe('x'.repeat(500) + '... (truncated)'); + }); + }); + + describe('formatContextLine', () => { + it('returns formatted context line for message with currentNote', () => { + const message: ChatMessage = { + id: 'msg-1', + role: 'user', + content: 'Hello', + timestamp: Date.now(), + currentNote: 'notes/test.md', + }; + + const result = formatContextLine(message); + + expect(result).toContain('notes/test.md'); + }); + + it('returns null when currentNote is undefined', () => { + const message: ChatMessage = { + id: 'msg-1', + role: 'user', + content: 'Hello', + timestamp: Date.now(), + }; + + const result = formatContextLine(message); + + expect(result).toBeNull(); + }); + + it('returns null when currentNote is empty', () => { + const message: ChatMessage = { + id: 'msg-1', + role: 'user', + content: 'Hello', + timestamp: Date.now(), + currentNote: '', + }; + + const result = formatContextLine(message); + + expect(result).toBeNull(); + }); + }); + + describe('buildContextFromHistory', () => { + it('builds context from simple user/assistant exchange', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1000 }, + { id: 'msg-2', role: 'assistant', content: 'Hi there!', timestamp: 2000 }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('User: Hello'); + expect(result).toContain('Assistant: Hi there!'); + }); + + it('includes tool calls without results for successful tools', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'Read file', timestamp: 1000 }, + { + id: 'msg-2', + role: 'assistant', + content: 'Let me read that file.', + timestamp: 2000, + toolCalls: [ + { id: 'tool-1', name: 'Read', input: {}, status: 'completed', result: 'file contents' }, + ], + }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('User: Read file'); + expect(result).toContain('Assistant: Let me read that file.'); + expect(result).toContain('[Tool Read status=completed]'); + // Successful tools don't include results (Claude can re-execute if needed) + expect(result).not.toContain('file contents'); + }); + + it('includes error messages for failed tool calls', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'Read file', timestamp: 1000 }, + { + id: 'msg-2', + role: 'assistant', + content: 'Let me read that file.', + timestamp: 2000, + toolCalls: [ + { id: 'tool-1', name: 'Read', input: {}, status: 'error', result: 'File not found' }, + ], + }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('[Tool Read status=error] error: File not found'); + }); + + it('includes currentNote context for user messages', () => { + const messages: ChatMessage[] = [ + { + id: 'msg-1', + role: 'user', + content: 'Analyze this note', + timestamp: 1000, + currentNote: 'notes/important.md', + }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('notes/important.md'); + expect(result).toContain('Analyze this note'); + }); + + it('skips non-user/assistant messages', () => { + // buildContextFromHistory only processes 'user' and 'assistant' roles + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'User message', timestamp: 2000 }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('User: User message'); + }); + + it('skips assistant messages with no content and no tool results', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1000 }, + { id: 'msg-2', role: 'assistant', content: '', timestamp: 2000 }, + { id: 'msg-3', role: 'assistant', content: 'Response', timestamp: 3000 }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('User: Hello'); + expect(result).toContain('Assistant: Response'); + // Should not have an empty assistant entry + expect(result.match(/Assistant:/g)?.length).toBe(1); + }); + + it('includes assistant message with only tool results (no text content)', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'Do something', timestamp: 1000 }, + { + id: 'msg-2', + role: 'assistant', + content: '', + timestamp: 2000, + toolCalls: [ + { id: 'tool-1', name: 'Bash', input: {}, status: 'completed', result: 'done' }, + ], + }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('[Tool Bash status=completed]'); + }); + + it('returns empty string for empty messages array', () => { + const result = buildContextFromHistory([]); + expect(result).toBe(''); + }); + + it('handles messages with only whitespace content', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: ' \n ', timestamp: 1000 }, + { id: 'msg-2', role: 'assistant', content: ' \t ', timestamp: 2000 }, + ]; + + const result = buildContextFromHistory(messages); + + // Whitespace content should still be processed (trimmed) + expect(result).toContain('User:'); + }); + + it('separates messages with double newlines', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'First', timestamp: 1000 }, + { id: 'msg-2', role: 'assistant', content: 'Second', timestamp: 2000 }, + { id: 'msg-3', role: 'user', content: 'Third', timestamp: 3000 }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('\n\n'); + }); + + it('shows all tool calls but only error results', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'Test', timestamp: 1000 }, + { + id: 'msg-2', + role: 'assistant', + content: 'Response', + timestamp: 2000, + toolCalls: [ + { id: 'tool-1', name: 'Success', input: {}, status: 'completed', result: 'data' }, + { id: 'tool-2', name: 'Failed', input: {}, status: 'error', result: 'error msg' }, + ], + }, + ]; + + const result = buildContextFromHistory(messages); + + // Successful tool shows status only (no result) + expect(result).toContain('[Tool Success status=completed]'); + expect(result).not.toContain('data'); + // Failed tool shows error message + expect(result).toContain('[Tool Failed status=error] error: error msg'); + }); + + it('includes thinking block summary', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'Think about this', timestamp: 1000 }, + { + id: 'msg-2', + role: 'assistant', + content: 'Here is my response', + timestamp: 2000, + contentBlocks: [ + { type: 'thinking', content: 'Let me think...', durationSeconds: 5.5 }, + { type: 'text', content: 'Here is my response' }, + ], + }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('[Thinking: 1 block(s), 5.5s total]'); + // Thinking content is NOT included (Claude will think anew) + expect(result).not.toContain('Let me think'); + }); + + it('includes thinking summary for multiple blocks', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'Complex problem', timestamp: 1000 }, + { + id: 'msg-2', + role: 'assistant', + content: 'Response', + timestamp: 2000, + contentBlocks: [ + { type: 'thinking', content: 'First thought', durationSeconds: 3.0 }, + { type: 'thinking', content: 'Second thought', durationSeconds: 2.5 }, + ], + }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('[Thinking: 2 block(s), 5.5s total]'); + }); + + it('includes thinking summary without duration if not available', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'Question', timestamp: 1000 }, + { + id: 'msg-2', + role: 'assistant', + content: 'Answer', + timestamp: 2000, + contentBlocks: [ + { type: 'thinking', content: 'Thinking...' }, + ], + }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('[Thinking: 1 block(s)]'); + expect(result).not.toContain('total]'); + }); + + it('includes tool input in history', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'Read my file', timestamp: 1000 }, + { + id: 'msg-2', + role: 'assistant', + content: 'Let me read it', + timestamp: 2000, + toolCalls: [ + { id: 'tool-1', name: 'Read', input: { file_path: '/notes/todo.md' }, status: 'completed', result: 'file contents' }, + ], + }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('[Tool Read input: file_path=/notes/todo.md status=completed]'); + }); + }); + + describe('getLastUserMessage', () => { + it('returns last user message from history', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'First', timestamp: 1000 }, + { id: 'msg-2', role: 'assistant', content: 'Response', timestamp: 2000 }, + { id: 'msg-3', role: 'user', content: 'Second', timestamp: 3000 }, + { id: 'msg-4', role: 'assistant', content: 'Response 2', timestamp: 4000 }, + ]; + + const result = getLastUserMessage(messages); + + expect(result?.id).toBe('msg-3'); + expect(result?.content).toBe('Second'); + }); + + it('returns undefined when no user messages exist', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'assistant', content: 'Response', timestamp: 1000 }, + ]; + + const result = getLastUserMessage(messages); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for empty messages array', () => { + const result = getLastUserMessage([]); + + expect(result).toBeUndefined(); + }); + + it('returns the only user message when there is just one', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'assistant', content: 'Welcome', timestamp: 1000 }, + { id: 'msg-2', role: 'user', content: 'Only user msg', timestamp: 2000 }, + { id: 'msg-3', role: 'assistant', content: 'Response', timestamp: 3000 }, + ]; + + const result = getLastUserMessage(messages); + + expect(result?.id).toBe('msg-2'); + }); + + it('finds user message among assistant messages', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'assistant', content: 'Welcome', timestamp: 1000 }, + { id: 'msg-2', role: 'user', content: 'User', timestamp: 2000 }, + { id: 'msg-3', role: 'assistant', content: 'Response', timestamp: 3000 }, + ]; + + const result = getLastUserMessage(messages); + + expect(result?.id).toBe('msg-2'); + }); + }); + + describe('buildPromptWithHistoryContext', () => { + it('returns prompt unchanged when historyContext is null', () => { + const prompt = '\nhello\n'; + const result = buildPromptWithHistoryContext(null, prompt, 'hello', []); + + expect(result).toBe(prompt); + }); + + it('returns only history when actualPrompt matches last user message', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'hello', timestamp: 1000 }, + { id: 'msg-2', role: 'assistant', content: 'hi', timestamp: 2000 }, + ]; + const historyContext = 'User: hello\n\nAssistant: hi'; + const prompt = '\nhello\n'; + const actualPrompt = 'hello'; + + const result = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, messages); + + // Should NOT append prompt since actualPrompt matches last user message + expect(result).toBe(historyContext); + }); + + it('appends prompt when actualPrompt differs from last user message', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'first message', timestamp: 1000 }, + { id: 'msg-2', role: 'assistant', content: 'response', timestamp: 2000 }, + ]; + const historyContext = 'User: first message\n\nAssistant: response'; + const prompt = '\nsecond message\n'; + const actualPrompt = 'second message'; + + const result = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, messages); + + expect(result).toContain(historyContext); + expect(result).toContain('User: '); + expect(result).toContain('second message'); + }); + + it('returns prompt unchanged when history context is empty string', () => { + const historyContext = ''; + const prompt = '\nhello\n'; + + const result = buildPromptWithHistoryContext(historyContext, prompt, 'hello', []); + + // Empty string is falsy, so returns original prompt + expect(result).toBe(prompt); + }); + + it('appends prompt when no user messages in history', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'assistant', content: 'welcome', timestamp: 1000 }, + ]; + const historyContext = 'Assistant: welcome'; + const prompt = '\nhello\n'; + const actualPrompt = 'hello'; + + const result = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, messages); + + expect(result).toContain(historyContext); + expect(result).toContain('User: '); + }); + + it('handles whitespace in comparison', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: ' hello world ', timestamp: 1000 }, + ]; + const historyContext = 'User: hello world'; + const prompt = '\nhello world\n'; + const actualPrompt = 'hello world'; + + const result = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, messages); + + // Should match after trimming + expect(result).toBe(historyContext); + }); + + it('avoids duplication when legacy XML-wrapped content matches display content', () => { + const prompt = [ + '', + 'notes/file.md', + '', + '', + '', + 'selected text', + '', + '', + '', + 'Follow up', + '', + ].join('\n'); + + const actualPrompt = [ + '', + 'selected text', + '', + '', + '', + 'Follow up', + '', + ].join('\n'); + + const messages: ChatMessage[] = [ + { + id: 'msg-1', + role: 'user', + content: prompt, + displayContent: 'Follow up', + timestamp: 1000, + }, + ]; + + const historyContext = 'User: Follow up'; + + const result = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, messages); + + expect(result).toBe(historyContext); + }); + + describe('new format (user content before XML context)', () => { + it('avoids duplication when actualPrompt matches last user message', () => { + const prompt = 'Explain this\n\n\ntest.md\n'; + const actualPrompt = 'Explain this\n\n\ntest.md\n'; + const messages: ChatMessage[] = [ + { + id: 'msg-1', + role: 'user', + content: prompt, + displayContent: 'Explain this', + timestamp: 1000, + }, + ]; + const historyContext = 'User: Explain this'; + + const result = buildPromptWithHistoryContext(historyContext, prompt, actualPrompt, messages); + + expect(result).toBe(historyContext); + }); + + it('appends prompt when actualPrompt differs from last user message', () => { + const oldPrompt = 'First question\n\n\nold.md\n'; + const newPrompt = 'Second question\n\n\nnew.md\n'; + const messages: ChatMessage[] = [ + { + id: 'msg-1', + role: 'user', + content: oldPrompt, + displayContent: 'First question', + timestamp: 1000, + }, + ]; + const historyContext = 'User: First question\n\nAssistant: response'; + + const result = buildPromptWithHistoryContext(historyContext, newPrompt, newPrompt, messages); + + expect(result).toContain(historyContext); + expect(result).toContain('User: Second question'); + }); + + it('extracts user query from editor_selection format', () => { + const prompt = 'Refactor this\n\n\ncode here\n'; + const messages: ChatMessage[] = [ + { + id: 'msg-1', + role: 'user', + content: prompt, + displayContent: 'Refactor this', + timestamp: 1000, + }, + ]; + const historyContext = 'User: Refactor this'; + + const result = buildPromptWithHistoryContext(historyContext, prompt, prompt, messages); + + expect(result).toBe(historyContext); + }); + + it('extracts user query from content with multiple XML context tags', () => { + const prompt = 'Update code\n\n\ntest.md\n\n\n\nselected\n'; + const messages: ChatMessage[] = [ + { + id: 'msg-1', + role: 'user', + content: prompt, + displayContent: 'Update code', + timestamp: 1000, + }, + ]; + const historyContext = 'User: Update code'; + + const result = buildPromptWithHistoryContext(historyContext, prompt, prompt, messages); + + expect(result).toBe(historyContext); + }); + + it('falls back to extractUserQuery when displayContent is not available', () => { + const prompt = 'Help me\n\n\nfile.md\n'; + const messages: ChatMessage[] = [ + { + id: 'msg-1', + role: 'user', + content: prompt, + // No displayContent - should extract from content + timestamp: 1000, + }, + ]; + const historyContext = 'User: Help me'; + + const result = buildPromptWithHistoryContext(historyContext, prompt, prompt, messages); + + expect(result).toBe(historyContext); + }); + }); + }); + + describe('formatToolCallForContext edge cases', () => { + it('formats tool call with object input value', () => { + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Write', + input: { config: { nested: true }, count: 42 }, + status: 'completed', + }; + + const result = formatToolCallForContext(toolCall); + + expect(result).toContain('config=[object]'); + expect(result).toContain('count=42'); + }); + + it('skips null and undefined input values', () => { + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Read', + input: { path: '/file.md', optional: null, missing: undefined }, + status: 'completed', + }; + + const result = formatToolCallForContext(toolCall); + + expect(result).toContain('path=/file.md'); + expect(result).not.toContain('optional'); + expect(result).not.toContain('missing'); + }); + + it('truncates long overall input string', () => { + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Bash', + input: { + a: 'x'.repeat(80), + b: 'y'.repeat(80), + c: 'z'.repeat(80), + }, + status: 'completed', + }; + + const result = formatToolCallForContext(toolCall); + + // Total input string truncated to 200 chars + expect(result).toContain('...'); + }); + + it('formats whitespace-only result for failed tool without error detail', () => { + const toolCall: ToolCallInfo = { + id: 'tool-1', + name: 'Bash', + input: {}, + status: 'error', + result: ' \n ', + }; + + const result = formatToolCallForContext(toolCall); + + expect(result).toBe('[Tool Bash status=error]'); + }); + }); + + describe('buildContextFromHistory edge cases', () => { + it('skips interrupt messages', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'Start task', timestamp: 1000 }, + { + id: 'msg-2', + role: 'assistant', + content: 'Working on it...', + timestamp: 2000, + }, + { + id: 'msg-3', + role: 'user', + content: '', + timestamp: 3000, + isInterrupt: true, + }, + { + id: 'msg-4', + role: 'assistant', + content: 'Stopped.', + timestamp: 4000, + }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('User: Start task'); + expect(result).toContain('Assistant: Working on it...'); + expect(result).toContain('Assistant: Stopped.'); + // Interrupt message should not appear as a user message + expect(result.match(/User:/g)?.length).toBe(1); + }); + + it('includes assistant message with only thinking blocks and no text', () => { + const messages: ChatMessage[] = [ + { id: 'msg-1', role: 'user', content: 'Think hard', timestamp: 1000 }, + { + id: 'msg-2', + role: 'assistant', + content: '', + timestamp: 2000, + contentBlocks: [ + { type: 'thinking', content: 'Deep thought...', durationSeconds: 10 }, + ], + }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('[Thinking: 1 block(s), 10.0s total]'); + }); + + it('handles user message with currentNote but no content', () => { + const messages: ChatMessage[] = [ + { + id: 'msg-1', + role: 'user', + content: '', + timestamp: 1000, + currentNote: 'notes/active.md', + }, + ]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('notes/active.md'); + }); + + it('skips messages with unknown roles', () => { + const messages = [ + { id: 'msg-1', role: 'user', content: 'Hello', timestamp: 1000 }, + { id: 'msg-2', role: 'system' as any, content: 'System msg', timestamp: 1500 }, + { id: 'msg-3', role: 'assistant', content: 'Response', timestamp: 2000 }, + ] as ChatMessage[]; + + const result = buildContextFromHistory(messages); + + expect(result).toContain('User: Hello'); + expect(result).toContain('Assistant: Response'); + expect(result).not.toContain('System msg'); + }); + }); +}); diff --git a/tests/unit/utils/slashCommand.test.ts b/tests/unit/utils/slashCommand.test.ts new file mode 100644 index 0000000..ca1d6c3 --- /dev/null +++ b/tests/unit/utils/slashCommand.test.ts @@ -0,0 +1,778 @@ +import { extractFirstParagraph, parseSlashCommandContent, serializeCommand, serializeSlashCommandMarkdown, validateCommandName, yamlString } from '@/utils/slashCommand'; + +describe('parseSlashCommandContent', () => { + describe('basic parsing', () => { + it('should parse command with full frontmatter', () => { + const content = `--- +description: Review code for issues +argument-hint: "[file] [focus]" +allowed-tools: + - Read + - Grep +model: claude-sonnet-4-5 +--- +Review this code: $ARGUMENTS`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Review code for issues'); + expect(parsed.argumentHint).toBe('[file] [focus]'); + expect(parsed.allowedTools).toEqual(['Read', 'Grep']); + expect(parsed.model).toBe('claude-sonnet-4-5'); + expect(parsed.promptContent).toBe('Review this code: $ARGUMENTS'); + }); + + it('should parse command with minimal frontmatter', () => { + const content = `--- +description: Simple command +--- +Do something`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Simple command'); + expect(parsed.argumentHint).toBeUndefined(); + expect(parsed.allowedTools).toBeUndefined(); + expect(parsed.model).toBeUndefined(); + expect(parsed.promptContent).toBe('Do something'); + }); + + it('should handle content without frontmatter', () => { + const content = 'Just a prompt without frontmatter'; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBeUndefined(); + expect(parsed.promptContent).toBe('Just a prompt without frontmatter'); + }); + + it('should handle inline array syntax for allowed-tools', () => { + const content = `--- +allowed-tools: [Read, Write, Bash] +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + expect(parsed.allowedTools).toEqual(['Read', 'Write', 'Bash']); + }); + + it('should handle quoted values', () => { + const content = `--- +description: "Value with: colon" +argument-hint: 'Single quoted' +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Value with: colon'); + expect(parsed.argumentHint).toBe('Single quoted'); + }); + }); + + describe('block scalar support', () => { + it('should parse literal block scalar (|) for description', () => { + const content = `--- +description: | + Records a checkpoint of progress in the daily note. + Includes timestamp and current task status. +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Records a checkpoint of progress in the daily note.\nIncludes timestamp and current task status.'); + expect(parsed.promptContent).toBe('Prompt'); + }); + + it('should parse folded block scalar (>) for description', () => { + const content = `--- +description: > + Records a checkpoint of progress + in the daily note. +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Records a checkpoint of progress in the daily note.'); + expect(parsed.promptContent).toBe('Prompt'); + }); + + it('should produce different output for | vs > block scalars', () => { + const literalContent = `--- +description: | + Line one + Line two +--- +Prompt`; + const foldedContent = `--- +description: > + Line one + Line two +--- +Prompt`; + + const literal = parseSlashCommandContent(literalContent); + const folded = parseSlashCommandContent(foldedContent); + + expect(literal.description).toBe('Line one\nLine two'); + expect(folded.description).toBe('Line one Line two'); + expect(literal.description).not.toBe(folded.description); + }); + + it('should preserve paragraph breaks in folded block scalar (>)', () => { + const content = `--- +description: > + First paragraph here. + + Second paragraph after empty line. +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('First paragraph here.\n\nSecond paragraph after empty line.'); + expect(parsed.promptContent).toBe('Prompt'); + }); + + it('should parse literal block scalar for argument-hint', () => { + const content = `--- +argument-hint: | + [task-name] + [optional-notes] +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.argumentHint).toBe('[task-name]\n[optional-notes]'); + }); + + it('should handle empty lines in literal block scalar', () => { + const content = `--- +description: | + First paragraph here. + + Second paragraph after empty line. +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('First paragraph here.\n\nSecond paragraph after empty line.'); + }); + + it('should parse block scalar with other fields', () => { + const content = `--- +description: | + Multi-line description + with multiple lines +model: claude-sonnet-4-5 +allowed-tools: + - Read + - Write +--- +Prompt content`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Multi-line description\nwith multiple lines'); + expect(parsed.model).toBe('claude-sonnet-4-5'); + expect(parsed.allowedTools).toEqual(['Read', 'Write']); + expect(parsed.promptContent).toBe('Prompt content'); + }); + + it('should handle block scalar at end of frontmatter', () => { + const content = `--- +model: claude-haiku-4-5 +description: | + Last field in frontmatter + with multiple lines +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Last field in frontmatter\nwith multiple lines'); + expect(parsed.model).toBe('claude-haiku-4-5'); + }); + + it('should preserve indentation within block scalar content', () => { + const content = `--- +description: | + Code example: + - Step 1 + - Step 2 +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Code example:\n - Step 1\n - Step 2'); + }); + + it('should handle single-line block scalar', () => { + const content = `--- +description: | + Just one line +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Just one line'); + }); + + it('should not confuse pipe in quoted string with block scalar', () => { + const content = `--- +description: "Contains | pipe character" +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Contains | pipe character'); + }); + + it('should handle multiple block scalars in same frontmatter', () => { + const content = `--- +description: | + First block scalar + with multiple lines +argument-hint: | + Second block scalar + also multi-line +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('First block scalar\nwith multiple lines'); + expect(parsed.argumentHint).toBe('Second block scalar\nalso multi-line'); + }); + + it('should handle CRLF line endings in block scalar', () => { + const content = '---\r\ndescription: |\r\n Line one\r\n Line two\r\n---\r\nPrompt'; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Line one\nLine two'); + expect(parsed.promptContent).toBe('Prompt'); + }); + }); + + describe('block scalar edge cases', () => { + it('should handle empty block scalar followed by another field', () => { + const content = `--- +description: | +model: claude-sonnet-4-5 +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + // Empty block scalar yields no description (semantically same as absent) + expect(parsed.description).toBeUndefined(); + expect(parsed.model).toBe('claude-sonnet-4-5'); + }); + + it('should handle block scalar with only empty lines before next field', () => { + const content = `--- +description: | + +model: claude-sonnet-4-5 +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + // Empty lines followed by unindented field should end the block scalar + expect(parsed.model).toBe('claude-sonnet-4-5'); + }); + + it('should handle strip chomping indicator (|-)', () => { + const content = `--- +description: |- + No trailing newline here +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + // Chomping indicator is recognized and parsed as block scalar + expect(parsed.description).toBe('No trailing newline here'); + }); + + it('should handle keep chomping indicator (|+)', () => { + const content = `--- +description: |+ + Keep indicator recognized +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + // Keep chomping indicator is recognized + expect(parsed.description).toBe('Keep indicator recognized'); + }); + + it('should handle folded with strip chomping (>-)', () => { + const content = `--- +description: >- + Folded with strip + chomping indicator +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Folded with strip chomping indicator'); + }); + + it('should not enable block scalar for unsupported keys', () => { + const content = `--- +notes: | + This should not be parsed as block scalar +description: Regular description +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + // notes is not a supported key, so | is treated as the value + // description should be parsed normally + expect(parsed.description).toBe('Regular description'); + }); + + it('should handle allowed-tools with block scalar indicator gracefully', () => { + const content = `--- +allowed-tools: | + Read + Write +description: Test +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + // allowed-tools doesn't support block scalar, so | becomes the value + // The Read/Write lines are ignored as they're not valid YAML keys + expect(parsed.description).toBe('Test'); + }); + + it('should preserve unicode content in block scalar', () => { + const content = `--- +description: | + Hello 世界 + Émoji: 🎉 +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Hello 世界\nÉmoji: 🎉'); + }); + + it('should preserve relative indentation in deeply nested content', () => { + const content = `--- +description: | + Level 1 + Level 2 + Level 3 + Level 4 +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Level 1\n Level 2\n Level 3\n Level 4'); + }); + + it('should preserve colons in block scalar content', () => { + const content = `--- +description: | + key: value + another: pair +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('key: value\nanother: pair'); + }); + + it('should preserve comment-like content (# lines)', () => { + const content = `--- +description: | + # This looks like a YAML comment + But it is preserved as content +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('# This looks like a YAML comment\nBut it is preserved as content'); + }); + + it('should preserve trailing whitespace in block scalar lines', () => { + // Use explicit string to ensure trailing spaces are preserved + const content = '---\ndescription: |\n Line with trailing spaces \n Normal line\n---\nPrompt'; + + const parsed = parseSlashCommandContent(content); + + expect(parsed.description).toBe('Line with trailing spaces \nNormal line'); + }); + + it('should preserve leading empty lines in block scalar', () => { + const content = `--- +description: | + + Content after empty line +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + + // Leading empty lines are preserved per YAML spec + expect(parsed.description).toBe('\nContent after empty line'); + }); + }); + + describe('skill fields', () => { + it('should parse kebab-case disable-model-invocation', () => { + const content = `--- +description: A skill +disable-model-invocation: true +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + expect(parsed.disableModelInvocation).toBe(true); + }); + + it('should parse kebab-case user-invocable', () => { + const content = `--- +description: A skill +user-invocable: false +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + expect(parsed.userInvocable).toBe(false); + }); + + it('should parse camelCase disableModelInvocation (backwards compat)', () => { + const content = `--- +description: A skill +disableModelInvocation: true +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + expect(parsed.disableModelInvocation).toBe(true); + }); + + it('should parse camelCase userInvocable (backwards compat)', () => { + const content = `--- +description: A skill +userInvocable: false +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + expect(parsed.userInvocable).toBe(false); + }); + + it('should prefer kebab-case over camelCase when both present', () => { + const content = `--- +disable-model-invocation: true +disableModelInvocation: false +user-invocable: false +userInvocable: true +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + expect(parsed.disableModelInvocation).toBe(true); + expect(parsed.userInvocable).toBe(false); + }); + + it('should parse context string', () => { + const content = `--- +description: A skill +context: fork +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + expect(parsed.context).toBe('fork'); + }); + + it('should parse agent string', () => { + const content = `--- +description: A skill +agent: code-reviewer +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + expect(parsed.agent).toBe('code-reviewer'); + }); + + it('should parse all skill fields together', () => { + const content = `--- +description: Full skill +disableModelInvocation: true +userInvocable: true +context: fork +agent: code-reviewer +model: sonnet +--- +Do the thing`; + + const parsed = parseSlashCommandContent(content); + expect(parsed.description).toBe('Full skill'); + expect(parsed.disableModelInvocation).toBe(true); + expect(parsed.userInvocable).toBe(true); + expect(parsed.context).toBe('fork'); + expect(parsed.agent).toBe('code-reviewer'); + expect(parsed.model).toBe('sonnet'); + expect(parsed.promptContent).toBe('Do the thing'); + }); + + it('should return undefined for missing skill fields', () => { + const content = `--- +description: Simple command +--- +Prompt`; + + const parsed = parseSlashCommandContent(content); + expect(parsed.disableModelInvocation).toBeUndefined(); + expect(parsed.userInvocable).toBeUndefined(); + expect(parsed.context).toBeUndefined(); + expect(parsed.agent).toBeUndefined(); + expect(parsed.hooks).toBeUndefined(); + }); + }); +}); + +describe('yamlString', () => { + it('returns plain value for simple strings', () => { + expect(yamlString('hello world')).toBe('hello world'); + }); + + it('quotes strings with colons', () => { + expect(yamlString('key: value')).toBe('"key: value"'); + }); + + it('quotes strings with hash', () => { + expect(yamlString('has # comment')).toBe('"has # comment"'); + }); + + it('quotes strings with newlines', () => { + expect(yamlString('line1\nline2')).toBe('"line1\nline2"'); + }); + + it('quotes strings starting with space', () => { + expect(yamlString(' leading')).toBe('" leading"'); + }); + + it('quotes strings ending with space', () => { + expect(yamlString('trailing ')).toBe('"trailing "'); + }); + + it('escapes double quotes inside quoted strings', () => { + expect(yamlString('has "quotes" inside: yes')).toBe('"has \\"quotes\\" inside: yes"'); + }); +}); + +describe('serializeCommand', () => { + it('strips frontmatter from content before serializing', () => { + const result = serializeCommand({ + id: 'cmd-test', + name: 'test', + description: 'Test', + content: '---\ndescription: old\n---\nBody text', + }); + + // Should use the SlashCommand's description, not the one in content frontmatter + expect(result).toContain('description: Test'); + expect(result).toContain('Body text'); + expect(result).not.toContain('description: old'); + }); + + it('handles content without frontmatter', () => { + const result = serializeCommand({ + id: 'cmd-test', + name: 'test', + description: 'Simple', + content: 'Just a prompt', + }); + + expect(result).toContain('description: Simple'); + expect(result).toContain('Just a prompt'); + }); +}); + +describe('serializeSlashCommandMarkdown', () => { + it('serializes all fields in kebab-case', () => { + const result = serializeSlashCommandMarkdown({ + name: 'my-skill', + description: 'Test command', + argumentHint: '[file]', + allowedTools: ['Read', 'Grep'], + model: 'claude-sonnet-4-5', + disableModelInvocation: true, + userInvocable: false, + context: 'fork', + agent: 'code-reviewer', + }, 'Do the thing'); + + expect(result).toContain('name: my-skill'); + expect(result).toContain('description: Test command'); + expect(result).toContain('argument-hint: "[file]"'); + expect(result).toContain('allowed-tools:'); + expect(result).toContain(' - Read'); + expect(result).toContain(' - Grep'); + expect(result).toContain('model: claude-sonnet-4-5'); + expect(result).toContain('disable-model-invocation: true'); + expect(result).toContain('user-invocable: false'); + expect(result).toContain('context: fork'); + expect(result).toContain('agent: code-reviewer'); + expect(result).toContain('Do the thing'); + }); + + it('omits undefined fields', () => { + const result = serializeSlashCommandMarkdown({ + description: 'Minimal', + }, 'Prompt'); + + expect(result).toContain('description: Minimal'); + expect(result).not.toContain('name'); + expect(result).not.toContain('argument-hint'); + expect(result).not.toContain('allowed-tools'); + expect(result).not.toContain('model'); + expect(result).not.toContain('disable-model-invocation'); + expect(result).not.toContain('user-invocable'); + expect(result).not.toContain('context'); + expect(result).not.toContain('agent'); + expect(result).not.toContain('hooks'); + }); + + it('serializes hooks as JSON', () => { + const hooks = { PreToolUse: [{ matcher: 'Bash' }] }; + const result = serializeSlashCommandMarkdown({ hooks }, 'Prompt'); + expect(result).toContain(`hooks: ${JSON.stringify(hooks)}`); + }); + + it('produces valid frontmatter when no metadata exists', () => { + const result = serializeSlashCommandMarkdown({}, 'Just a prompt'); + expect(result).toBe('---\n\n---\nJust a prompt'); + }); + + it('round-trips through parse', () => { + const serialized = serializeSlashCommandMarkdown({ + description: 'Round trip', + disableModelInvocation: true, + userInvocable: false, + context: 'fork', + agent: 'reviewer', + }, 'Body text'); + + const parsed = parseSlashCommandContent(serialized); + expect(parsed.description).toBe('Round trip'); + expect(parsed.disableModelInvocation).toBe(true); + expect(parsed.userInvocable).toBe(false); + expect(parsed.context).toBe('fork'); + expect(parsed.agent).toBe('reviewer'); + expect(parsed.promptContent).toBe('Body text'); + }); +}); + +describe('validateCommandName', () => { + it('accepts valid lowercase names', () => { + expect(validateCommandName('my-command')).toBeNull(); + expect(validateCommandName('test')).toBeNull(); + expect(validateCommandName('a')).toBeNull(); + expect(validateCommandName('abc123')).toBeNull(); + expect(validateCommandName('my-cmd-2')).toBeNull(); + expect(validateCommandName('a1b2c3')).toBeNull(); + }); + + it('accepts name at exactly 64 characters', () => { + const name = 'a'.repeat(64); + expect(validateCommandName(name)).toBeNull(); + }); + + it('rejects empty name', () => { + expect(validateCommandName('')).not.toBeNull(); + }); + + it('rejects uppercase letters', () => { + expect(validateCommandName('MyCommand')).not.toBeNull(); + expect(validateCommandName('TEST')).not.toBeNull(); + }); + + it('rejects underscores', () => { + expect(validateCommandName('my_command')).not.toBeNull(); + }); + + it('rejects slashes', () => { + expect(validateCommandName('my/command')).not.toBeNull(); + }); + + it('rejects spaces', () => { + expect(validateCommandName('my command')).not.toBeNull(); + }); + + it('rejects colons', () => { + expect(validateCommandName('my:command')).not.toBeNull(); + }); + + it('rejects names exceeding 64 characters', () => { + const name = 'a'.repeat(65); + expect(validateCommandName(name)).not.toBeNull(); + }); + + it('rejects special characters', () => { + expect(validateCommandName('cmd!@#')).not.toBeNull(); + expect(validateCommandName('cmd.test')).not.toBeNull(); + }); + + it.each(['true', 'false', 'null', 'yes', 'no', 'on', 'off'])( + 'rejects YAML reserved word "%s"', + (word) => { + expect(validateCommandName(word)).not.toBeNull(); + } + ); +}); + +describe('extractFirstParagraph', () => { + it('returns the first paragraph from multi-paragraph content', () => { + expect(extractFirstParagraph('First paragraph.\n\nSecond paragraph.')) + .toBe('First paragraph.'); + }); + + it('returns single-line content as-is', () => { + expect(extractFirstParagraph('Only one line')).toBe('Only one line'); + }); + + it('collapses multi-line first paragraph into single line', () => { + expect(extractFirstParagraph('Line one\nline two\n\nSecond paragraph')) + .toBe('Line one line two'); + }); + + it('returns undefined for empty content', () => { + expect(extractFirstParagraph('')).toBeUndefined(); + }); + + it('returns undefined for whitespace-only content', () => { + expect(extractFirstParagraph(' \n \n ')).toBeUndefined(); + }); + + it('skips leading blank lines', () => { + expect(extractFirstParagraph('\n\nActual first paragraph.\n\nSecond.')) + .toBe('Actual first paragraph.'); + }); +}); diff --git a/tests/unit/utils/utils.test.ts b/tests/unit/utils/utils.test.ts new file mode 100644 index 0000000..905d981 --- /dev/null +++ b/tests/unit/utils/utils.test.ts @@ -0,0 +1,809 @@ +import type * as fsType from 'fs'; +import type * as osType from 'os'; +import type * as pathType from 'path'; + +const fs = jest.requireActual('fs'); +const os = jest.requireActual('os'); +const path = jest.requireActual('path'); + +import { findClaudeCLIPath } from '@/providers/claude/cli/findClaudeCLIPath'; +import { getCurrentModelFromEnvironment, getModelsFromEnvironment } from '@/providers/claude/env/claudeModelEnv'; +import { parseEnvironmentVariables } from '@/utils/env'; +import { appendMarkdownSnippet } from '@/utils/markdown'; +import { + expandHomePath, + isPathWithinVault, + normalizePathForFilesystem, + normalizePathForVault, + translateMsysPath, +} from '@/utils/path'; + +describe('utils.ts', () => { + describe('parseEnvironmentVariables', () => { + it('should parse simple KEY=VALUE pairs', () => { + const input = 'API_KEY=abc123\nDEBUG=true'; + const result = parseEnvironmentVariables(input); + + expect(result).toEqual({ + API_KEY: 'abc123', + DEBUG: 'true', + }); + }); + + it('should skip empty lines', () => { + const input = 'KEY1=value1\n\nKEY2=value2\n\n'; + const result = parseEnvironmentVariables(input); + + expect(result).toEqual({ + KEY1: 'value1', + KEY2: 'value2', + }); + }); + + it('should skip comment lines starting with #', () => { + const input = '# This is a comment\nKEY=value\n# Another comment'; + const result = parseEnvironmentVariables(input); + + expect(result).toEqual({ + KEY: 'value', + }); + }); + + it('should handle values with = signs', () => { + const input = 'URL=https://example.com?foo=bar&baz=qux'; + const result = parseEnvironmentVariables(input); + + expect(result).toEqual({ + URL: 'https://example.com?foo=bar&baz=qux', + }); + }); + + it('should trim whitespace from keys and values', () => { + const input = ' KEY = value '; + const result = parseEnvironmentVariables(input); + + expect(result).toEqual({ + KEY: 'value', + }); + }); + + it('should skip lines without = sign', () => { + const input = 'VALID=value\nINVALID_LINE\nANOTHER=test'; + const result = parseEnvironmentVariables(input); + + expect(result).toEqual({ + VALID: 'value', + ANOTHER: 'test', + }); + }); + + it('should skip lines with = at start (no key)', () => { + const input = '=value\nKEY=valid\n =also-no-key'; + const result = parseEnvironmentVariables(input); + + expect(result).toEqual({ + KEY: 'valid', + }); + }); + + it('should return empty object for empty input', () => { + expect(parseEnvironmentVariables('')).toEqual({}); + expect(parseEnvironmentVariables(' ')).toEqual({}); + expect(parseEnvironmentVariables('\n\n')).toEqual({}); + }); + + it('should handle values with spaces', () => { + const input = 'MESSAGE=Hello World'; + const result = parseEnvironmentVariables(input); + + expect(result).toEqual({ + MESSAGE: 'Hello World', + }); + }); + + it('should strip surrounding double quotes from values', () => { + const input = 'URL="https://api.example.com"\nKEY="secret-key"'; + const result = parseEnvironmentVariables(input); + + expect(result).toEqual({ + URL: 'https://api.example.com', + KEY: 'secret-key', + }); + }); + + it('should strip surrounding single quotes from values', () => { + const input = "URL='https://api.example.com'\nKEY='secret-key'"; + const result = parseEnvironmentVariables(input); + + expect(result).toEqual({ + URL: 'https://api.example.com', + KEY: 'secret-key', + }); + }); + + it('should not strip mismatched quotes', () => { + const input = 'VAL1="not-closed\nVAL2=\'also-not-closed\nVAL3="mixed\''; + const result = parseEnvironmentVariables(input); + + expect(result).toEqual({ + VAL1: '"not-closed', + VAL2: "'also-not-closed", + VAL3: '"mixed\'', + }); + }); + + it('should preserve quotes inside values', () => { + const input = 'JSON={"key": "value"}'; + const result = parseEnvironmentVariables(input); + + expect(result).toEqual({ + JSON: '{"key": "value"}', + }); + }); + }); + + describe('expandHomePath', () => { + const envKey = 'CLAUDIAN_TEST_PATH'; + const envValue = path.join(os.tmpdir(), 'claudian-env'); + let originalValue: string | undefined; + + beforeEach(() => { + originalValue = process.env[envKey]; + process.env[envKey] = envValue; + }); + + afterEach(() => { + if (originalValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = originalValue; + } + }); + + it('should expand percent-style environment variables', () => { + expect(expandHomePath(`%${envKey}%`)).toBe(envValue); + }); + + it('should expand dollar-style environment variables', () => { + const braceStyle = '${' + envKey + '}'; + expect(expandHomePath(`$${envKey}`)).toBe(envValue); + expect(expandHomePath(braceStyle)).toBe(envValue); + }); + + it('should handle Windows-specific environment variable formats based on platform', () => { + const powerShellStyle = `$env:${envKey}`; + const cmdStyle = `!${envKey}!`; + + // On Windows: expanded; on Unix: unchanged + const expectedPowerShell = process.platform === 'win32' ? envValue : powerShellStyle; + const expectedCmd = process.platform === 'win32' ? envValue : cmdStyle; + + expect(expandHomePath(powerShellStyle)).toBe(expectedPowerShell); + expect(expandHomePath(cmdStyle)).toBe(expectedCmd); + }); + + it('should leave unknown environment variables untouched', () => { + expect(expandHomePath('%CLAUDIAN_MISSING_VAR%')).toBe('%CLAUDIAN_MISSING_VAR%'); + expect(expandHomePath('$CLAUDIAN_MISSING_VAR')).toBe('$CLAUDIAN_MISSING_VAR'); + }); + }); + + describe('normalizePathForFilesystem', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('expands home paths before filesystem use', () => { + const expected = path.join(os.homedir(), 'notes/file.md'); + expect(normalizePathForFilesystem('~/notes/file.md')).toBe(expected); + }); + + it('expands environment variables before filesystem use', () => { + const envKey = 'CLAUDIAN_FS_TEST_PATH'; + const originalValue = process.env[envKey]; + process.env[envKey] = '/tmp/claudian-test'; + + try { + expect(normalizePathForFilesystem(`$${envKey}/notes/file.md`)).toBe('/tmp/claudian-test/notes/file.md'); + } finally { + if (originalValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = originalValue; + } + } + }); + + it('strips Windows device prefixes when platform is win32', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + expect(normalizePathForFilesystem('\\\\?\\C:\\Users\\test\\file.txt')).toBe('C:\\Users\\test\\file.txt'); + expect(normalizePathForFilesystem('\\\\?\\UNC\\server\\share\\file.txt')).toBe('\\\\server\\share\\file.txt'); + }); + + it('translates MSYS paths when platform is win32', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + expect(normalizePathForFilesystem('/c/Users/test/file.txt')).toBe('C:\\Users\\test\\file.txt'); + }); + + it('handles empty string input', () => { + expect(normalizePathForFilesystem('')).toBe(''); + }); + + it('handles non-existent environment variables', () => { + // Non-existent env vars should be left as-is + expect(normalizePathForFilesystem('$NONEXISTENT/path')).toBe('$NONEXISTENT/path'); + expect(normalizePathForFilesystem('%NONEXISTENT%/path')).toBe('%NONEXISTENT%/path'); + }); + + it('handles mixed path separators', () => { + // Mixed / and \ should be normalized by path operations + const result = normalizePathForFilesystem('C:/Users\\test/path.txt'); + // On Windows: path module normalizes, on Unix: keeps as-is + expect(result).toBeTruthy(); + }); + + it('handles chained home and environment variable expansions', () => { + const envKey = 'CLAUDIAN_TEST_SUBDIR'; + const originalValue = process.env[envKey]; + process.env[envKey] = 'project'; + + try { + const result = normalizePathForFilesystem(`~/$${envKey}/file.md`); + const expected = path.join(os.homedir(), 'project', 'file.md'); + expect(result).toBe(expected); + } finally { + if (originalValue === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = originalValue; + } + } + }); + + it('handles Windows env vars with parentheses like ProgramFiles(x86)', () => { + const originalPlatform = process.platform; + Object.defineProperty(process, 'platform', { value: 'win32' }); + const originalPFx86 = process.env['ProgramFiles(x86)']; + + try { + process.env['ProgramFiles(x86)'] = 'C:\\Program Files (x86)'; + const result = normalizePathForFilesystem('%ProgramFiles(x86)%/app/file.txt'); + expect(result).toBe('C:\\Program Files (x86)\\app\\file.txt'); + } finally { + if (originalPFx86 === undefined) { + delete process.env['ProgramFiles(x86)']; + } else { + process.env['ProgramFiles(x86)'] = originalPFx86; + } + Object.defineProperty(process, 'platform', { value: originalPlatform }); + } + }); + }); + + describe('normalizePathForVault', () => { + it('returns vault-relative path for absolute input inside vault', () => { + expect(normalizePathForVault('/vault/notes/a.md', '/vault')).toBe('notes/a.md'); + }); + + it('returns vault-relative path for relative input inside vault', () => { + expect(normalizePathForVault('notes/a.md', '/vault')).toBe('notes/a.md'); + }); + + it('returns normalized path for external input', () => { + expect(normalizePathForVault('/outside/file.md', '/vault')).toBe('/outside/file.md'); + }); + + it('returns null for empty input', () => { + expect(normalizePathForVault('', '/vault')).toBeNull(); + }); + }); + + describe('appendMarkdownSnippet', () => { + it('should append snippet as-is when existing prompt is empty', () => { + expect(appendMarkdownSnippet('', ' - Test ')).toBe('- Test'); + }); + + it('should append snippet with a blank line separator by default', () => { + const existing = '## Existing\n\n- A'; + const snippet = '## New\n\n- B'; + expect(appendMarkdownSnippet(existing, snippet)).toBe('## Existing\n\n- A\n\n## New\n\n- B'); + }); + + it('should ensure a blank line separation when existing ends with a newline', () => { + const existing = '## Existing\n'; + const snippet = '- B'; + expect(appendMarkdownSnippet(existing, snippet)).toBe('## Existing\n\n- B'); + }); + + it('should not add extra spacing when existing ends with a blank line', () => { + const existing = '## Existing\n\n'; + const snippet = '- B'; + expect(appendMarkdownSnippet(existing, snippet)).toBe('## Existing\n\n- B'); + }); + + it('should return existing prompt unchanged when snippet is empty', () => { + expect(appendMarkdownSnippet('## Existing', ' ')).toBe('## Existing'); + }); + }); + + describe('getModelsFromEnvironment', () => { + it('should extract model from ANTHROPIC_MODEL', () => { + const envVars = { ANTHROPIC_MODEL: 'claude-3-opus' }; + const result = getModelsFromEnvironment(envVars); + + expect(result).toHaveLength(1); + expect(result[0].value).toBe('claude-3-opus'); + expect(result[0].description).toContain('model'); + }); + + it('should extract models from ANTHROPIC_DEFAULT_*_MODEL variables', () => { + const envVars = { + ANTHROPIC_DEFAULT_OPUS_MODEL: 'custom-opus', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'custom-sonnet', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'custom-haiku', + }; + const result = getModelsFromEnvironment(envVars); + + expect(result).toHaveLength(3); + expect(result.map(m => m.value)).toContain('custom-opus'); + expect(result.map(m => m.value)).toContain('custom-sonnet'); + expect(result.map(m => m.value)).toContain('custom-haiku'); + }); + + it('should deduplicate models with same value', () => { + const envVars = { + ANTHROPIC_MODEL: 'same-model', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'same-model', + }; + const result = getModelsFromEnvironment(envVars); + + expect(result).toHaveLength(1); + expect(result[0].value).toBe('same-model'); + expect(result[0].description).toContain('model'); + expect(result[0].description).toContain('opus'); + }); + + it('should return empty array when no model variables are set', () => { + const envVars = { OTHER_VAR: 'value' }; + const result = getModelsFromEnvironment(envVars); + + expect(result).toEqual([]); + }); + + it('should handle model names with slashes (provider/model format)', () => { + const envVars = { ANTHROPIC_MODEL: 'anthropic/claude-3-opus' }; + const result = getModelsFromEnvironment(envVars); + + expect(result).toHaveLength(1); + expect(result[0].value).toBe('anthropic/claude-3-opus'); + expect(result[0].label).toBe('claude-3-opus'); + }); + + it('should fallback to full value when slash-split yields empty', () => { + const envVars = { ANTHROPIC_MODEL: 'trailing-slash/' }; + const result = getModelsFromEnvironment(envVars); + + expect(result).toHaveLength(1); + expect(result[0].label).toBe('trailing-slash/'); + }); + + it('should sort models by priority (model > haiku > sonnet > opus)', () => { + const envVars = { + ANTHROPIC_DEFAULT_OPUS_MODEL: 'opus-model', + ANTHROPIC_MODEL: 'main-model', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet-model', + }; + const result = getModelsFromEnvironment(envVars); + + expect(result[0].value).toBe('main-model'); + expect(result[1].value).toBe('sonnet-model'); + expect(result[2].value).toBe('opus-model'); + }); + }); + + describe('getCurrentModelFromEnvironment', () => { + it('should return ANTHROPIC_MODEL if set', () => { + const envVars = { + ANTHROPIC_MODEL: 'main-model', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'opus-model', + }; + const result = getCurrentModelFromEnvironment(envVars); + + expect(result).toBe('main-model'); + }); + + it('should return ANTHROPIC_DEFAULT_HAIKU_MODEL if ANTHROPIC_MODEL not set', () => { + const envVars = { + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'haiku-model', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet-model', + }; + const result = getCurrentModelFromEnvironment(envVars); + + expect(result).toBe('haiku-model'); + }); + + it('should return ANTHROPIC_DEFAULT_SONNET_MODEL if higher priority not set', () => { + const envVars = { + ANTHROPIC_DEFAULT_SONNET_MODEL: 'sonnet-model', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'opus-model', + }; + const result = getCurrentModelFromEnvironment(envVars); + + expect(result).toBe('sonnet-model'); + }); + + it('should return ANTHROPIC_DEFAULT_HAIKU_MODEL if only that is set', () => { + const envVars = { + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'haiku-model', + }; + const result = getCurrentModelFromEnvironment(envVars); + + expect(result).toBe('haiku-model'); + }); + + it('should return null if no model variables are set', () => { + const envVars = { OTHER_VAR: 'value' }; + const result = getCurrentModelFromEnvironment(envVars); + + expect(result).toBeNull(); + }); + + it('should return null for empty object', () => { + const result = getCurrentModelFromEnvironment({}); + + expect(result).toBeNull(); + }); + }); + + describe('findClaudeCLIPath', () => { + const originalPlatform = process.platform; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + process.env.PATH = ''; + }); + + afterEach(() => { + jest.restoreAllMocks(); + Object.defineProperty(process, 'platform', { value: originalPlatform }); + process.env = originalEnv; + }); + + describe('on Unix/macOS', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + }); + + function mockExistingFile(...paths: string[]) { + const pathSet = new Set(paths); + jest.spyOn(fs, 'existsSync').mockImplementation((p: any) => pathSet.has(p)); + jest.spyOn(fs, 'statSync').mockImplementation((p: any) => ({ + isFile: () => pathSet.has(String(p)), + }) as fsType.Stats); + } + + it('should return first matching Claude CLI path', () => { + jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); + mockExistingFile('/home/test/.local/bin/claude'); + + expect(findClaudeCLIPath()).toBe('/home/test/.local/bin/claude'); + }); + + it('should return null when Claude CLI is not found', () => { + jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); + jest.spyOn(fs, 'existsSync').mockReturnValue(false as any); + + expect(findClaudeCLIPath()).toBeNull(); + }); + + it('should check cli-wrapper.cjs paths as fallback on Unix', () => { + jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); + mockExistingFile('/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli-wrapper.cjs'); + + expect(findClaudeCLIPath()).toBe('/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli-wrapper.cjs'); + }); + + it('should resolve Claude CLI from custom PATH', () => { + mockExistingFile('/custom/bin/claude'); + + const customPath = '/custom/bin:/usr/bin'; + expect(findClaudeCLIPath(customPath)).toBe('/custom/bin/claude'); + }); + + it('should expand home directory in custom PATH', () => { + jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); + mockExistingFile('/home/test/bin/claude'); + + const customPath = '~/bin:/usr/bin'; + expect(findClaudeCLIPath(customPath)).toBe('/home/test/bin/claude'); + }); + + it('should not return a directory path even if it exists', () => { + jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); + const dirPath = path.join('/home/test', '.local', 'bin', 'claude'); + jest.spyOn(fs, 'existsSync').mockImplementation((p: any) => p === dirPath); + jest.spyOn(fs, 'statSync').mockImplementation(() => ({ + isFile: () => false, + }) as fsType.Stats); + + expect(findClaudeCLIPath()).toBeNull(); + }); + }); + + describe('on Windows', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + process.env.ProgramFiles = 'C:\\Program Files'; + process.env['ProgramFiles(x86)'] = 'C:\\Program Files (x86)'; + process.env.APPDATA = 'C:\\Users\\test\\AppData\\Roaming'; + }); + + function mockExistingFile(...paths: string[]) { + const pathSet = new Set(paths); + jest.spyOn(fs, 'existsSync').mockImplementation((p: any) => pathSet.has(p)); + jest.spyOn(fs, 'statSync').mockImplementation((p: any) => ({ + isFile: () => pathSet.has(String(p)), + }) as fsType.Stats); + } + + it('should prefer .exe when both .exe and cli-wrapper.cjs exist', () => { + jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); + const exePath = path.join('C:\\Users\\test', '.claude', 'local', 'claude.exe'); + const cliWrapperPath = path.join('C:\\Users\\test', 'AppData', 'Roaming', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli-wrapper.cjs'); + mockExistingFile(exePath, cliWrapperPath); + + expect(findClaudeCLIPath()).toBe(exePath); + }); + + it('should prioritize cli-wrapper.cjs over .cmd files on Windows', () => { + jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); + // Note: path.join uses actual platform separator, so we match against that + const cliWrapperPath = path.join('C:\\Users\\test', 'AppData', 'Roaming', 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli-wrapper.cjs'); + const cmdPath = path.join('C:\\Users\\test', 'AppData', 'Roaming', 'npm', 'claude.cmd'); + mockExistingFile(cmdPath, cliWrapperPath); + + expect(findClaudeCLIPath()).toBe(cliWrapperPath); + }); + + it('should find cli-wrapper.cjs in custom npm global path via npm_config_prefix', () => { + jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); + process.env.npm_config_prefix = 'D:\\nodejs\\node_global'; + const expectedPath = path.join('D:\\nodejs\\node_global', 'node_modules', '@anthropic-ai', 'claude-code', 'cli-wrapper.cjs'); + mockExistingFile(expectedPath); + + expect(findClaudeCLIPath()).toBe(expectedPath); + }); + + it('should fall back to .exe if package entrypoint is not found', () => { + jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); + const expectedPath = path.join('C:\\Users\\test', '.claude', 'local', 'claude.exe'); + mockExistingFile(expectedPath); + + expect(findClaudeCLIPath()).toBe(expectedPath); + }); + + it('should ignore .cmd fallback on Windows', () => { + jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); + const expectedPath = path.join('C:\\Users\\test', 'AppData', 'Roaming', 'npm', 'claude.cmd'); + mockExistingFile(expectedPath); + + expect(findClaudeCLIPath()).toBeNull(); + }); + + it('should return null when no CLI is found on Windows', () => { + jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); + jest.spyOn(fs, 'existsSync').mockReturnValue(false as any); + + expect(findClaudeCLIPath()).toBeNull(); + }); + + it('should resolve cli-wrapper.cjs from custom PATH npm prefix', () => { + const npmBin = 'C:\\Users\\test\\AppData\\Roaming\\npm'; + const cliWrapperPath = path.join(npmBin, 'node_modules', '@anthropic-ai', 'claude-code', 'cli-wrapper.cjs'); + mockExistingFile(cliWrapperPath); + + const customPath = `${npmBin};C:\\Windows\\System32`; + expect(findClaudeCLIPath(customPath)).toBe(cliWrapperPath); + }); + + it('should not return a directory path even if it exists', () => { + jest.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\test'); + const dirPath = path.join('C:\\Users\\test', '.claude', 'local', 'claude'); + // Simulate a directory named 'claude' (exists but isFile returns false) + jest.spyOn(fs, 'existsSync').mockImplementation((p: any) => p === dirPath); + jest.spyOn(fs, 'statSync').mockImplementation(() => ({ + isFile: () => false, + }) as fsType.Stats); + + expect(findClaudeCLIPath()).toBeNull(); + }); + }); + }); + + describe('isPathWithinVault', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should allow relative paths within vault', () => { + expect(isPathWithinVault('notes/a.md', '/vault')).toBe(true); + }); + + it('should block path traversal escaping vault', () => { + expect(isPathWithinVault('../secrets.txt', '/vault')).toBe(false); + }); + + it('should allow absolute paths inside vault', () => { + expect(isPathWithinVault('/vault/notes/a.md', '/vault')).toBe(true); + }); + + it('should block absolute paths outside vault', () => { + expect(isPathWithinVault('/etc/passwd', '/vault')).toBe(false); + }); + + it('should expand tilde and still enforce vault boundary', () => { + jest.spyOn(os, 'homedir').mockReturnValue('/home/test'); + expect(isPathWithinVault('~/vault/notes/a.md', '/vault')).toBe(false); + }); + + it('should allow exact vault path', () => { + expect(isPathWithinVault('/vault', '/vault')).toBe(true); + expect(isPathWithinVault('.', '/vault')).toBe(true); + }); + + it('should handle non-existent paths via fallback resolution', () => { + // When fs.realpathSync throws (file doesn't exist), path.resolve is used + jest.spyOn(fs, 'realpathSync').mockImplementation(() => { + throw new Error('ENOENT'); + }); + // Even with mock throwing, function should still work via fallback + expect(isPathWithinVault('nonexistent/path.md', '/vault')).toBe(true); + }); + + it('should block symlink escapes for non-existent targets', () => { + jest.spyOn(fs, 'existsSync').mockImplementation((p: any) => { + const s = String(p); + return s === '/' || s === '/vault' || s === '/vault/export'; + }); + + const realpathSpy = jest.spyOn(fs, 'realpathSync').mockImplementation((p: any) => { + const s = String(p); + if (s === '/') return '/'; + if (s === '/vault') return '/vault'; + if (s === '/vault/export') return '/tmp/export'; + throw new Error('ENOENT'); + }); + (fs.realpathSync as any).native = realpathSpy; + + expect(isPathWithinVault('export/newfile.txt', '/vault')).toBe(false); + }); + }); + + describe('Windows separator normalization', () => { + const originalPlatform = process.platform; + const originalSep = path.sep; + const originalIsAbsolute = path.isAbsolute; + + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + // Force Windows-style separator to detect regressions when comparisons rely on path.sep. + Object.defineProperty(path, 'sep', { value: '\\', writable: true }); + jest.spyOn(path, 'isAbsolute').mockImplementation((p: any) => { + const value = String(p); + return /^[A-Za-z]:[\\/]/.test(value) || originalIsAbsolute(value); + }); + + const realpathSpy = jest.spyOn(fs, 'realpathSync').mockImplementation((p: any) => String(p) as any); + (fs.realpathSync as any).native = realpathSpy; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + Object.defineProperty(path, 'sep', { value: originalSep, writable: true }); + jest.restoreAllMocks(); + }); + + it('allows vault paths after slash normalization', () => { + expect(isPathWithinVault('C:\\Users\\test\\vault\\note.md', 'C:\\Users\\test\\vault')).toBe(true); + }); + + }); + + describe('translateMsysPath', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + describe('on Windows', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + }); + + it('should translate MSYS drive paths to Windows paths', () => { + expect(translateMsysPath('/c/Users/test')).toBe('C:\\Users\\test'); + expect(translateMsysPath('/d/Projects/vault')).toBe('D:\\Projects\\vault'); + }); + + it('should handle uppercase drive letters', () => { + expect(translateMsysPath('/C/Users/test')).toBe('C:\\Users\\test'); + }); + + it('should handle root drive paths', () => { + expect(translateMsysPath('/c')).toBe('C:'); + expect(translateMsysPath('/c/')).toBe('C:\\'); + }); + + it('should not translate non-MSYS absolute paths', () => { + expect(translateMsysPath('/home/user')).toBe('/home/user'); + expect(translateMsysPath('/tmp/file.txt')).toBe('/tmp/file.txt'); + }); + + it('should not translate Windows native paths', () => { + expect(translateMsysPath('C:\\Users\\test')).toBe('C:\\Users\\test'); + }); + + it('should not translate relative paths', () => { + expect(translateMsysPath('./file.txt')).toBe('./file.txt'); + expect(translateMsysPath('../parent/file.txt')).toBe('../parent/file.txt'); + }); + }); + + describe('on Unix', () => { + beforeEach(() => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + }); + + it('should not translate any paths', () => { + expect(translateMsysPath('/c/Users/test')).toBe('/c/Users/test'); + expect(translateMsysPath('/home/user')).toBe('/home/user'); + }); + }); + }); + + describe('Windows path handling', () => { + // Note: Full integration tests for Windows path validation require running on Windows + // because Node's `path` module behavior is determined at module load time. + // These tests verify the translateMsysPath function which is platform-mockable. + + describe('translateMsysPath behavior', () => { + const originalPlatform = process.platform; + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + }); + + it('translates MSYS paths to Windows paths when platform is win32', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + + expect(translateMsysPath('/c/Users/test')).toBe('C:\\Users\\test'); + expect(translateMsysPath('/d/Projects/vault')).toBe('D:\\Projects\\vault'); + expect(translateMsysPath('/c')).toBe('C:'); + expect(translateMsysPath('/c/')).toBe('C:\\'); + }); + + it('does not translate non-MSYS paths on Windows', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + + // Multi-letter paths after / are not MSYS drive paths + expect(translateMsysPath('/home/user')).toBe('/home/user'); + expect(translateMsysPath('/tmp/file')).toBe('/tmp/file'); + // Already Windows paths + expect(translateMsysPath('C:\\Users')).toBe('C:\\Users'); + // Relative paths + expect(translateMsysPath('./file')).toBe('./file'); + }); + + it('does not translate any paths on non-Windows', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + + expect(translateMsysPath('/c/Users/test')).toBe('/c/Users/test'); + expect(translateMsysPath('/home/user')).toBe('/home/user'); + }); + }); + }); +}); diff --git a/tsconfig.jest.json b/tsconfig.jest.json new file mode 100644 index 0000000..ddbceff --- /dev/null +++ b/tsconfig.jest.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "ignoreDeprecations": "6.0", + "module": "CommonJS", + "types": ["jest", "node"] + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..783c0d1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./src/*"], + "@test/*": ["./tests/*"], + "@modelcontextprotocol/sdk/*": ["./node_modules/@modelcontextprotocol/sdk/dist/esm/*"] + }, + "skipLibCheck": true, + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "bundler", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "resolveJsonModule": true, + "types": ["node", "jest"], + "lib": ["DOM", "ES2022"], + "outDir": "./dist", + "rootDir": "." + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "tests/**/*.ts"] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..420f024 --- /dev/null +++ b/versions.json @@ -0,0 +1,4 @@ +{ + "1.0.0": "1.0.0", + "0.1.0": "1.0.0" +}

mgj%O0Hf#rta!F-Frh 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