@mseep/claudian 2.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.local.example +2 -0
- package/.github/workflows/ci.yml +59 -0
- package/.github/workflows/claude-code-review.yml +57 -0
- package/.github/workflows/claude.yml +50 -0
- package/.github/workflows/duplicate-issues.yml +22 -0
- package/.github/workflows/release.yml +73 -0
- package/.github/workflows/stale.yml +21 -0
- package/.node-version +1 -0
- package/AGENTS.md +3 -0
- package/CLAUDE.md +80 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/assets/Preview.png +0 -0
- package/assets/sponsors/MOMA.png +0 -0
- package/bun.lock +1618 -0
- package/esbuild.config.mjs +195 -0
- package/eslint.config.mjs +143 -0
- package/jest.config.js +41 -0
- package/main.js +104915 -0
- package/manifest.json +10 -0
- package/package.json +65 -0
- package/scripts/build-css.mjs +119 -0
- package/scripts/build.mjs +19 -0
- package/scripts/postinstall.mjs +25 -0
- package/scripts/rendererSafeUnref.js +205 -0
- package/scripts/run-jest.js +19 -0
- package/scripts/sync-version.js +16 -0
- package/src/app/settings/ClaudianSettingsStorage.ts +435 -0
- package/src/app/settings/defaultSettings.ts +54 -0
- package/src/app/storage/SharedStorageService.ts +106 -0
- package/src/core/CLAUDE.md +84 -0
- package/src/core/auxiliary/AuxQueryRunner.ts +11 -0
- package/src/core/auxiliary/QueryBackedInlineEditService.ts +73 -0
- package/src/core/auxiliary/QueryBackedInstructionRefineService.ts +78 -0
- package/src/core/auxiliary/QueryBackedTitleGenerationService.ts +90 -0
- package/src/core/bootstrap/SessionStorage.ts +179 -0
- package/src/core/bootstrap/StoragePaths.ts +7 -0
- package/src/core/bootstrap/storage.ts +20 -0
- package/src/core/commands/builtInCommands.ts +141 -0
- package/src/core/mcp/McpConfigParser.ts +102 -0
- package/src/core/mcp/McpServerManager.ts +119 -0
- package/src/core/mcp/McpTester.ts +310 -0
- package/src/core/prompt/inlineEdit.ts +252 -0
- package/src/core/prompt/instructionRefine.ts +72 -0
- package/src/core/prompt/mainAgent.ts +213 -0
- package/src/core/prompt/titleGeneration.ts +44 -0
- package/src/core/providers/ProviderRegistry.ts +253 -0
- package/src/core/providers/ProviderSettingsCoordinator.ts +434 -0
- package/src/core/providers/ProviderWorkspaceRegistry.ts +119 -0
- package/src/core/providers/commands/ProviderCommandCatalog.ts +21 -0
- package/src/core/providers/commands/ProviderCommandEntry.ts +33 -0
- package/src/core/providers/commands/hiddenCommands.ts +74 -0
- package/src/core/providers/modelRouting.ts +17 -0
- package/src/core/providers/modelSelection.ts +72 -0
- package/src/core/providers/providerConfig.ts +34 -0
- package/src/core/providers/providerEnvironment.ts +364 -0
- package/src/core/providers/types.ts +544 -0
- package/src/core/runtime/ChatRuntime.ts +66 -0
- package/src/core/runtime/QueuedTurn.ts +97 -0
- package/src/core/runtime/types.ts +118 -0
- package/src/core/security/ApprovalManager.ts +142 -0
- package/src/core/storage/HomeFileAdapter.ts +75 -0
- package/src/core/storage/VaultFileAdapter.ts +132 -0
- package/src/core/tools/todo.ts +65 -0
- package/src/core/tools/toolIcons.ts +80 -0
- package/src/core/tools/toolInput.ts +119 -0
- package/src/core/tools/toolNames.ts +149 -0
- package/src/core/tools/toolResultContent.ts +26 -0
- package/src/core/types/agent.ts +28 -0
- package/src/core/types/chat.ts +177 -0
- package/src/core/types/diff.ts +31 -0
- package/src/core/types/index.ts +78 -0
- package/src/core/types/mcp.ts +97 -0
- package/src/core/types/plugins.ts +9 -0
- package/src/core/types/provider.ts +1 -0
- package/src/core/types/settings.ts +152 -0
- package/src/core/types/tools.ts +80 -0
- package/src/features/chat/CLAUDE.md +136 -0
- package/src/features/chat/ClaudianView.ts +762 -0
- package/src/features/chat/constants.ts +114 -0
- package/src/features/chat/controllers/BrowserSelectionController.ts +295 -0
- package/src/features/chat/controllers/CanvasSelectionController.ts +142 -0
- package/src/features/chat/controllers/ConversationController.ts +1103 -0
- package/src/features/chat/controllers/InputController.ts +1707 -0
- package/src/features/chat/controllers/NavigationController.ts +209 -0
- package/src/features/chat/controllers/SelectionController.ts +430 -0
- package/src/features/chat/controllers/StreamController.ts +1560 -0
- package/src/features/chat/controllers/contextRowVisibility.ts +18 -0
- package/src/features/chat/rendering/DiffRenderer.ts +134 -0
- package/src/features/chat/rendering/InlineAskUserQuestion.ts +702 -0
- package/src/features/chat/rendering/InlineExitPlanMode.ts +263 -0
- package/src/features/chat/rendering/InlinePlanApproval.ts +183 -0
- package/src/features/chat/rendering/MessageRenderer.ts +907 -0
- package/src/features/chat/rendering/SubagentRenderer.ts +678 -0
- package/src/features/chat/rendering/ThinkingBlockRenderer.ts +126 -0
- package/src/features/chat/rendering/TodoListRenderer.ts +5 -0
- package/src/features/chat/rendering/ToolCallRenderer.ts +1161 -0
- package/src/features/chat/rendering/WriteEditRenderer.ts +232 -0
- package/src/features/chat/rendering/collapsible.ts +101 -0
- package/src/features/chat/rendering/subagentLifecycleResolution.ts +25 -0
- package/src/features/chat/rendering/todoUtils.ts +29 -0
- package/src/features/chat/rewind.ts +31 -0
- package/src/features/chat/services/BangBashService.ts +56 -0
- package/src/features/chat/services/SubagentManager.ts +1107 -0
- package/src/features/chat/state/ChatState.ts +436 -0
- package/src/features/chat/state/types.ts +138 -0
- package/src/features/chat/tabs/Tab.ts +1886 -0
- package/src/features/chat/tabs/TabBar.ts +179 -0
- package/src/features/chat/tabs/TabManager.ts +1021 -0
- package/src/features/chat/tabs/providerResolution.ts +34 -0
- package/src/features/chat/tabs/types.ts +287 -0
- package/src/features/chat/ui/BangBashModeManager.ts +121 -0
- package/src/features/chat/ui/FileContext.ts +385 -0
- package/src/features/chat/ui/ImageContext.ts +366 -0
- package/src/features/chat/ui/InputToolbar.ts +1244 -0
- package/src/features/chat/ui/InstructionModeManager.ts +158 -0
- package/src/features/chat/ui/NavigationSidebar.ts +126 -0
- package/src/features/chat/ui/StatusPanel.ts +589 -0
- package/src/features/chat/ui/file-context/state/FileContextState.ts +83 -0
- package/src/features/chat/ui/file-context/view/FileChipsView.ts +70 -0
- package/src/features/chat/ui/textareaResize.ts +47 -0
- package/src/features/chat/utils/usageInfo.ts +26 -0
- package/src/features/inline-edit/ui/InlineEditModal.ts +895 -0
- package/src/features/inline-edit/ui/inlineEditMarkdownPreview.ts +55 -0
- package/src/features/settings/ClaudianSettings.ts +672 -0
- package/src/features/settings/keyboardNavigation.ts +60 -0
- package/src/features/settings/ui/EnvSnippetManager.ts +430 -0
- package/src/features/settings/ui/EnvironmentSettingsSection.ts +85 -0
- package/src/features/settings/ui/McpServerModal.ts +335 -0
- package/src/features/settings/ui/McpSettingsManager.ts +400 -0
- package/src/features/settings/ui/McpTestModal.ts +346 -0
- package/src/i18n/constants.ts +58 -0
- package/src/i18n/i18n.ts +140 -0
- package/src/i18n/locales/de.json +322 -0
- package/src/i18n/locales/en.json +322 -0
- package/src/i18n/locales/es.json +322 -0
- package/src/i18n/locales/fr.json +322 -0
- package/src/i18n/locales/ja.json +322 -0
- package/src/i18n/locales/ko.json +322 -0
- package/src/i18n/locales/pt.json +322 -0
- package/src/i18n/locales/ru.json +322 -0
- package/src/i18n/locales/zh-CN.json +322 -0
- package/src/i18n/locales/zh-TW.json +322 -0
- package/src/i18n/types.ts +248 -0
- package/src/main.ts +772 -0
- package/src/providers/acp/AcpClientConnection.ts +361 -0
- package/src/providers/acp/AcpJsonRpcTransport.ts +427 -0
- package/src/providers/acp/AcpSessionConfig.ts +139 -0
- package/src/providers/acp/AcpSessionUpdateNormalizer.ts +371 -0
- package/src/providers/acp/AcpSubprocess.ts +155 -0
- package/src/providers/acp/AcpToolStreamAdapter.ts +132 -0
- package/src/providers/acp/buildAcpUsageInfo.ts +41 -0
- package/src/providers/acp/index.ts +9 -0
- package/src/providers/acp/methodNames.ts +50 -0
- package/src/providers/acp/types.ts +566 -0
- package/src/providers/claude/CLAUDE.md +78 -0
- package/src/providers/claude/agents/AgentManager.ts +225 -0
- package/src/providers/claude/agents/AgentStorage.ts +101 -0
- package/src/providers/claude/app/ClaudeWorkspaceServices.ts +90 -0
- package/src/providers/claude/auxiliary/ClaudeInlineEditService.ts +122 -0
- package/src/providers/claude/auxiliary/ClaudeInstructionRefineService.ts +90 -0
- package/src/providers/claude/auxiliary/ClaudeTitleGenerationService.ts +129 -0
- package/src/providers/claude/auxiliary/extractAssistantText.ts +28 -0
- package/src/providers/claude/capabilities.ts +17 -0
- package/src/providers/claude/cli/findClaudeCLIPath.ts +261 -0
- package/src/providers/claude/commands/ClaudeCommandCatalog.ts +149 -0
- package/src/providers/claude/commands/probeRuntimeCommands.ts +86 -0
- package/src/providers/claude/env/ClaudeSettingsReconciler.ts +90 -0
- package/src/providers/claude/env/claudeModelEnv.ts +86 -0
- package/src/providers/claude/history/ClaudeConversationHistoryService.ts +446 -0
- package/src/providers/claude/history/ClaudeHistoryStore.ts +170 -0
- package/src/providers/claude/history/sdkAsyncSubagent.ts +92 -0
- package/src/providers/claude/history/sdkBranchFilter.ts +271 -0
- package/src/providers/claude/history/sdkHistoryTypes.ts +61 -0
- package/src/providers/claude/history/sdkMessageParsing.ts +413 -0
- package/src/providers/claude/history/sdkSessionPaths.ts +98 -0
- package/src/providers/claude/history/sdkSubagentSidecar.ts +261 -0
- package/src/providers/claude/hooks/SubagentHooks.ts +31 -0
- package/src/providers/claude/modelLabels.ts +77 -0
- package/src/providers/claude/modelOptions.ts +113 -0
- package/src/providers/claude/modelSelection.ts +17 -0
- package/src/providers/claude/plugins/PluginManager.ts +194 -0
- package/src/providers/claude/prompt/ClaudeTurnEncoder.ts +46 -0
- package/src/providers/claude/registration.ts +39 -0
- package/src/providers/claude/runtime/ClaudeApprovalHandler.ts +153 -0
- package/src/providers/claude/runtime/ClaudeChatRuntime.ts +1812 -0
- package/src/providers/claude/runtime/ClaudeCliResolver.ts +94 -0
- package/src/providers/claude/runtime/ClaudeDynamicUpdates.ts +164 -0
- package/src/providers/claude/runtime/ClaudeMessageChannel.ts +209 -0
- package/src/providers/claude/runtime/ClaudeQueryOptionsBuilder.ts +315 -0
- package/src/providers/claude/runtime/ClaudeRewindService.ts +220 -0
- package/src/providers/claude/runtime/ClaudeSessionManager.ts +92 -0
- package/src/providers/claude/runtime/ClaudeTaskResultInterpreter.ts +172 -0
- package/src/providers/claude/runtime/ClaudeUserMessageFactory.ts +83 -0
- package/src/providers/claude/runtime/claudeColdStartQuery.ts +152 -0
- package/src/providers/claude/runtime/customSpawn.ts +87 -0
- package/src/providers/claude/runtime/types.ts +134 -0
- package/src/providers/claude/sdk/messages.ts +17 -0
- package/src/providers/claude/sdk/toolResultContent.ts +4 -0
- package/src/providers/claude/sdk/typeGuards.ts +14 -0
- package/src/providers/claude/sdk/types.ts +15 -0
- package/src/providers/claude/security/ClaudePermissionUpdates.ts +44 -0
- package/src/providers/claude/settings.ts +138 -0
- package/src/providers/claude/storage/AgentVaultStorage.ts +101 -0
- package/src/providers/claude/storage/CCSettingsStorage.ts +153 -0
- package/src/providers/claude/storage/ClaudianSettingsStorage.ts +6 -0
- package/src/providers/claude/storage/McpStorage.ts +139 -0
- package/src/providers/claude/storage/SessionStorage.ts +5 -0
- package/src/providers/claude/storage/SkillStorage.ts +61 -0
- package/src/providers/claude/storage/SlashCommandStorage.ts +96 -0
- package/src/providers/claude/storage/StorageService.ts +185 -0
- package/src/providers/claude/stream/toolInputStreamState.ts +318 -0
- package/src/providers/claude/stream/transformClaudeMessage.ts +586 -0
- package/src/providers/claude/types/agent.ts +2 -0
- package/src/providers/claude/types/models.ts +168 -0
- package/src/providers/claude/types/plugins.ts +14 -0
- package/src/providers/claude/types/providerState.ts +16 -0
- package/src/providers/claude/types/settings.ts +98 -0
- package/src/providers/claude/ui/AgentSettings.ts +389 -0
- package/src/providers/claude/ui/ClaudeChatUIConfig.ts +113 -0
- package/src/providers/claude/ui/ClaudeSettingsTab.ts +407 -0
- package/src/providers/claude/ui/PluginSettingsManager.ts +149 -0
- package/src/providers/claude/ui/SlashCommandSettings.ts +527 -0
- package/src/providers/codex/CLAUDE.md +64 -0
- package/src/providers/codex/agents/CodexAgentMentionProvider.ts +33 -0
- package/src/providers/codex/app/CodexWorkspaceServices.ts +76 -0
- package/src/providers/codex/auxiliary/CodexInlineEditService.ts +9 -0
- package/src/providers/codex/auxiliary/CodexInstructionRefineService.ts +9 -0
- package/src/providers/codex/auxiliary/CodexTaskResultInterpreter.ts +29 -0
- package/src/providers/codex/auxiliary/CodexTitleGenerationService.ts +22 -0
- package/src/providers/codex/capabilities.ts +16 -0
- package/src/providers/codex/commands/CodexSkillCatalog.ts +180 -0
- package/src/providers/codex/env/CodexSettingsReconciler.ts +68 -0
- package/src/providers/codex/history/CodexConversationHistoryService.ts +212 -0
- package/src/providers/codex/history/CodexHistoryStore.ts +1672 -0
- package/src/providers/codex/modelOptions.ts +99 -0
- package/src/providers/codex/modelSelection.ts +17 -0
- package/src/providers/codex/normalization/codexSubagentNormalization.ts +227 -0
- package/src/providers/codex/normalization/codexToolNormalization.ts +390 -0
- package/src/providers/codex/prompt/encodeCodexTurn.ts +57 -0
- package/src/providers/codex/registration.ts +29 -0
- package/src/providers/codex/runtime/CodexAppServerProcess.ts +105 -0
- package/src/providers/codex/runtime/CodexAuxQueryRunner.ts +180 -0
- package/src/providers/codex/runtime/CodexBinaryLocator.ts +49 -0
- package/src/providers/codex/runtime/CodexChatRuntime.ts +1296 -0
- package/src/providers/codex/runtime/CodexCliResolver.ts +65 -0
- package/src/providers/codex/runtime/CodexExecutionTargetResolver.ts +104 -0
- package/src/providers/codex/runtime/CodexLaunchSpecBuilder.ts +85 -0
- package/src/providers/codex/runtime/CodexNotificationRouter.ts +1033 -0
- package/src/providers/codex/runtime/CodexPathMapper.ts +155 -0
- package/src/providers/codex/runtime/CodexRpcTransport.ts +171 -0
- package/src/providers/codex/runtime/CodexRuntimeContext.ts +109 -0
- package/src/providers/codex/runtime/CodexServerRequestRouter.ts +331 -0
- package/src/providers/codex/runtime/CodexSessionFileTail.ts +792 -0
- package/src/providers/codex/runtime/CodexSessionManager.ts +39 -0
- package/src/providers/codex/runtime/codexAppServerSupport.ts +58 -0
- package/src/providers/codex/runtime/codexAppServerTypes.ts +705 -0
- package/src/providers/codex/runtime/codexLaunchTypes.ts +30 -0
- package/src/providers/codex/settings.ts +236 -0
- package/src/providers/codex/skills/CodexSkillListingService.ts +173 -0
- package/src/providers/codex/storage/CodexSkillStorage.ts +250 -0
- package/src/providers/codex/storage/CodexSubagentStorage.ts +212 -0
- package/src/providers/codex/types/index.ts +16 -0
- package/src/providers/codex/types/models.ts +46 -0
- package/src/providers/codex/types/subagent.ts +23 -0
- package/src/providers/codex/ui/CodexChatUIConfig.ts +128 -0
- package/src/providers/codex/ui/CodexSettingsTab.ts +432 -0
- package/src/providers/codex/ui/CodexSkillSettings.ts +275 -0
- package/src/providers/codex/ui/CodexSubagentSettings.ts +400 -0
- package/src/providers/defaultProviderConfigs.ts +14 -0
- package/src/providers/index.ts +30 -0
- package/src/providers/opencode/agents/OpencodeAgentMentionProvider.ts +42 -0
- package/src/providers/opencode/app/OpencodeRuntimeCommandLoader.ts +67 -0
- package/src/providers/opencode/app/OpencodeWorkspaceServices.ts +55 -0
- package/src/providers/opencode/auxiliary/OpencodeInlineEditService.ts +13 -0
- package/src/providers/opencode/auxiliary/OpencodeInstructionRefineService.ts +12 -0
- package/src/providers/opencode/auxiliary/OpencodeTaskResultInterpreter.ts +29 -0
- package/src/providers/opencode/auxiliary/OpencodeTitleGenerationService.ts +27 -0
- package/src/providers/opencode/capabilities.ts +16 -0
- package/src/providers/opencode/commands/OpencodeCommandCatalog.ts +92 -0
- package/src/providers/opencode/discoveryState.ts +135 -0
- package/src/providers/opencode/env/OpencodeSettingsReconciler.ts +178 -0
- package/src/providers/opencode/history/OpencodeConversationHistoryService.ts +84 -0
- package/src/providers/opencode/history/OpencodeHistoryStore.ts +472 -0
- package/src/providers/opencode/history/OpencodeSqliteReader.ts +284 -0
- package/src/providers/opencode/internal/compareCollections.ts +72 -0
- package/src/providers/opencode/internal/providerProjection.ts +15 -0
- package/src/providers/opencode/models.ts +378 -0
- package/src/providers/opencode/modes.ts +150 -0
- package/src/providers/opencode/normalization/opencodeToolNormalization.ts +406 -0
- package/src/providers/opencode/registration.ts +27 -0
- package/src/providers/opencode/runtime/OpencodeAuxQueryRunner.ts +436 -0
- package/src/providers/opencode/runtime/OpencodeChatRuntime.ts +1603 -0
- package/src/providers/opencode/runtime/OpencodeCliResolver.ts +57 -0
- package/src/providers/opencode/runtime/OpencodeLaunchArtifacts.ts +231 -0
- package/src/providers/opencode/runtime/OpencodePaths.ts +113 -0
- package/src/providers/opencode/runtime/OpencodeRuntimeEnvironment.ts +18 -0
- package/src/providers/opencode/runtime/buildOpencodePrompt.ts +66 -0
- package/src/providers/opencode/settings.ts +427 -0
- package/src/providers/opencode/storage/OpencodeAgentStorage.ts +346 -0
- package/src/providers/opencode/types/agent.ts +37 -0
- package/src/providers/opencode/types/index.ts +9 -0
- package/src/providers/opencode/ui/OpencodeAgentSettings.ts +579 -0
- package/src/providers/opencode/ui/OpencodeChatUIConfig.ts +316 -0
- package/src/providers/opencode/ui/OpencodeSettingsTab.ts +674 -0
- package/src/providers/pi/app/PiRuntimeCommandLoader.ts +57 -0
- package/src/providers/pi/app/PiWorkspaceServices.ts +39 -0
- package/src/providers/pi/auxiliary/PiInlineEditService.ts +9 -0
- package/src/providers/pi/auxiliary/PiInstructionRefineService.ts +9 -0
- package/src/providers/pi/auxiliary/PiTaskResultInterpreter.ts +29 -0
- package/src/providers/pi/auxiliary/PiTitleGenerationService.ts +19 -0
- package/src/providers/pi/capabilities.ts +16 -0
- package/src/providers/pi/commands/PiCommandCatalog.ts +92 -0
- package/src/providers/pi/env/PiSettingsReconciler.ts +180 -0
- package/src/providers/pi/history/PiConversationHistoryService.ts +123 -0
- package/src/providers/pi/history/PiHistoryStore.ts +664 -0
- package/src/providers/pi/internal/compareCollections.ts +4 -0
- package/src/providers/pi/internal/providerProjection.ts +18 -0
- package/src/providers/pi/models.ts +302 -0
- package/src/providers/pi/normalizations/piEventNormalization.ts +211 -0
- package/src/providers/pi/normalizations/piToolNormalization.ts +97 -0
- package/src/providers/pi/registration.ts +30 -0
- package/src/providers/pi/runtime/PiAuxQueryRunner.ts +216 -0
- package/src/providers/pi/runtime/PiChatRuntime.ts +1064 -0
- package/src/providers/pi/runtime/PiCliResolver.ts +53 -0
- package/src/providers/pi/runtime/PiExtensionUiBridge.ts +161 -0
- package/src/providers/pi/runtime/PiJsonl.ts +71 -0
- package/src/providers/pi/runtime/PiLaunchSpec.ts +70 -0
- package/src/providers/pi/runtime/PiModelDiscoveryService.ts +92 -0
- package/src/providers/pi/runtime/PiRpcPayloads.ts +18 -0
- package/src/providers/pi/runtime/PiRpcTransport.ts +243 -0
- package/src/providers/pi/runtime/PiSubprocess.ts +159 -0
- package/src/providers/pi/runtime/buildPiPrompt.ts +62 -0
- package/src/providers/pi/runtime/buildPiUsageInfo.ts +69 -0
- package/src/providers/pi/settings.ts +468 -0
- package/src/providers/pi/types.ts +64 -0
- package/src/providers/pi/ui/ObsidianPiExtensionUiRenderer.ts +251 -0
- package/src/providers/pi/ui/PiChatUIConfig.ts +265 -0
- package/src/providers/pi/ui/PiExtensionUiRenderer.ts +12 -0
- package/src/providers/pi/ui/PiSettingsTab.ts +642 -0
- package/src/shared/components/ResumeSessionDropdown.ts +185 -0
- package/src/shared/components/SelectableDropdown.ts +140 -0
- package/src/shared/components/SelectionHighlight.ts +77 -0
- package/src/shared/components/SlashCommandDropdown.ts +421 -0
- package/src/shared/icons.ts +180 -0
- package/src/shared/mention/MentionDropdownController.ts +627 -0
- package/src/shared/mention/VaultMentionCache.ts +106 -0
- package/src/shared/mention/VaultMentionDataProvider.ts +51 -0
- package/src/shared/mention/types.ts +67 -0
- package/src/shared/modals/ConfirmModal.ts +60 -0
- package/src/shared/modals/ForkTargetModal.ts +47 -0
- package/src/shared/modals/InstructionConfirmModal.ts +281 -0
- package/src/style/CLAUDE.md +49 -0
- package/src/style/accessibility.css +40 -0
- package/src/style/base/animations.css +44 -0
- package/src/style/base/container.css +20 -0
- package/src/style/base/variables.css +46 -0
- package/src/style/base/visibility.css +15 -0
- package/src/style/components/code.css +97 -0
- package/src/style/components/context-footer.css +76 -0
- package/src/style/components/header.css +27 -0
- package/src/style/components/history.css +221 -0
- package/src/style/components/input.css +312 -0
- package/src/style/components/messages.css +262 -0
- package/src/style/components/nav-sidebar.css +58 -0
- package/src/style/components/status-panel.css +202 -0
- package/src/style/components/subagent.css +248 -0
- package/src/style/components/tabs.css +112 -0
- package/src/style/components/thinking.css +88 -0
- package/src/style/components/toolcalls.css +278 -0
- package/src/style/features/ask-user-question.css +315 -0
- package/src/style/features/diff.css +197 -0
- package/src/style/features/file-context.css +188 -0
- package/src/style/features/file-link.css +22 -0
- package/src/style/features/image-context.css +179 -0
- package/src/style/features/image-embed.css +40 -0
- package/src/style/features/image-modal.css +52 -0
- package/src/style/features/inline-edit.css +278 -0
- package/src/style/features/plan-mode.css +103 -0
- package/src/style/features/resume-session.css +119 -0
- package/src/style/features/slash-commands.css +91 -0
- package/src/style/index.css +63 -0
- package/src/style/modals/fork-target.css +21 -0
- package/src/style/modals/instruction.css +161 -0
- package/src/style/modals/mcp-modal.css +241 -0
- package/src/style/settings/agent-settings.css +2 -0
- package/src/style/settings/base.css +300 -0
- package/src/style/settings/env-snippets.css +366 -0
- package/src/style/settings/mcp-settings.css +211 -0
- package/src/style/settings/plugin-settings.css +164 -0
- package/src/style/settings/provider-model-picker.css +367 -0
- package/src/style/settings/slash-settings.css +16 -0
- package/src/style/toolbar/external-context.css +177 -0
- package/src/style/toolbar/mcp-selector.css +176 -0
- package/src/style/toolbar/mode-selector.css +19 -0
- package/src/style/toolbar/model-selector.css +99 -0
- package/src/style/toolbar/permission-toggle.css +56 -0
- package/src/style/toolbar/service-tier-toggle.css +39 -0
- package/src/style/toolbar/thinking-selector.css +83 -0
- package/src/types/smol-toml.d.ts +4 -0
- package/src/utils/agent.ts +50 -0
- package/src/utils/animationFrame.ts +46 -0
- package/src/utils/browser.ts +46 -0
- package/src/utils/canvas.ts +14 -0
- package/src/utils/cliBinaryLocator.ts +97 -0
- package/src/utils/context.ts +117 -0
- package/src/utils/contextMentionResolver.ts +154 -0
- package/src/utils/date.ts +31 -0
- package/src/utils/diff.ts +384 -0
- package/src/utils/editor.ts +104 -0
- package/src/utils/electronCompat.ts +53 -0
- package/src/utils/env.ts +465 -0
- package/src/utils/externalContext.ts +143 -0
- package/src/utils/externalContextScanner.ts +135 -0
- package/src/utils/fileLink.ts +263 -0
- package/src/utils/frontmatter.ts +194 -0
- package/src/utils/imageEmbed.ts +139 -0
- package/src/utils/inlineEdit.ts +22 -0
- package/src/utils/interrupt.ts +23 -0
- package/src/utils/markdown.ts +25 -0
- package/src/utils/markdownMath.ts +130 -0
- package/src/utils/mcp.ts +96 -0
- package/src/utils/obsidianCompat.ts +23 -0
- package/src/utils/path.ts +342 -0
- package/src/utils/session.ts +240 -0
- package/src/utils/slashCommand.ts +152 -0
- package/src/utils/subagentJsonl.ts +52 -0
- package/src/utils/windowsCmdShim.ts +98 -0
- package/tests/__mocks__/claude-agent-sdk.ts +317 -0
- package/tests/__mocks__/codex-sdk.ts +88 -0
- package/tests/__mocks__/obsidian.ts +434 -0
- package/tests/helpers/mockElement.ts +403 -0
- package/tests/helpers/sdkMessages.ts +291 -0
- package/tests/integration/core/agent/ClaudianService.test.ts +1845 -0
- package/tests/integration/core/mcp/mcp.test.ts +905 -0
- package/tests/integration/features/chat/imagePersistence.test.ts +38 -0
- package/tests/integration/main.test.ts +1701 -0
- package/tests/setupWindow.ts +26 -0
- package/tests/tsconfig.json +7 -0
- package/tests/unit/core/commands/builtInCommands.test.ts +239 -0
- package/tests/unit/core/mcp/McpServerManager.test.ts +405 -0
- package/tests/unit/core/mcp/McpTester.test.ts +282 -0
- package/tests/unit/core/mcp/createNodeFetch.test.ts +188 -0
- package/tests/unit/core/providers/ProviderRegistry.test.ts +275 -0
- package/tests/unit/core/providers/ProviderSettingsCoordinator.test.ts +490 -0
- package/tests/unit/core/providers/ProviderWorkspaceRegistry.test.ts +84 -0
- package/tests/unit/core/providers/modelRouting.test.ts +91 -0
- package/tests/unit/core/providers/modelSelection.test.ts +155 -0
- package/tests/unit/core/providers/providerEnvironment.test.ts +162 -0
- package/tests/unit/core/providers/tabLifecycle.test.ts +217 -0
- package/tests/unit/core/security/ApprovalManager.test.ts +152 -0
- package/tests/unit/core/storage/VaultFileAdapter.test.ts +535 -0
- package/tests/unit/core/tools/todo.test.ts +227 -0
- package/tests/unit/core/tools/toolIcons.test.ts +75 -0
- package/tests/unit/core/tools/toolInput.test.ts +350 -0
- package/tests/unit/core/tools/toolNames.test.ts +464 -0
- package/tests/unit/core/types/mcp.test.ts +115 -0
- package/tests/unit/features/chat/ClaudianView.test.ts +404 -0
- package/tests/unit/features/chat/controllers/BrowserSelectionController.test.ts +179 -0
- package/tests/unit/features/chat/controllers/CanvasSelectionController.test.ts +216 -0
- package/tests/unit/features/chat/controllers/ConversationController.test.ts +2764 -0
- package/tests/unit/features/chat/controllers/InputController.test.ts +3188 -0
- package/tests/unit/features/chat/controllers/NavigationController.test.ts +640 -0
- package/tests/unit/features/chat/controllers/SelectionController.test.ts +695 -0
- package/tests/unit/features/chat/controllers/StreamController.test.ts +2534 -0
- package/tests/unit/features/chat/controllers/contextRowVisibility.test.ts +46 -0
- package/tests/unit/features/chat/controllers/index.test.ts +16 -0
- package/tests/unit/features/chat/rendering/DiffRenderer.test.ts +355 -0
- package/tests/unit/features/chat/rendering/InlineAskUserQuestion.test.ts +1035 -0
- package/tests/unit/features/chat/rendering/InlineExitPlanMode.test.ts +191 -0
- package/tests/unit/features/chat/rendering/InlinePlanApproval.test.ts +126 -0
- package/tests/unit/features/chat/rendering/MessageRenderer.test.ts +2004 -0
- package/tests/unit/features/chat/rendering/SubagentRenderer.test.ts +917 -0
- package/tests/unit/features/chat/rendering/ThinkingBlockRenderer.test.ts +124 -0
- package/tests/unit/features/chat/rendering/TodoListRenderer.test.ts +173 -0
- package/tests/unit/features/chat/rendering/ToolCallRenderer.test.ts +909 -0
- package/tests/unit/features/chat/rendering/WriteEditRenderer.test.ts +474 -0
- package/tests/unit/features/chat/rendering/collapsible.test.ts +158 -0
- package/tests/unit/features/chat/rendering/todoUtils.test.ts +105 -0
- package/tests/unit/features/chat/rewind.test.ts +56 -0
- package/tests/unit/features/chat/services/BangBashService.test.ts +142 -0
- package/tests/unit/features/chat/services/InstructionRefineService.test.ts +371 -0
- package/tests/unit/features/chat/services/SubagentManager.test.ts +1759 -0
- package/tests/unit/features/chat/services/TitleGenerationService.test.ts +480 -0
- package/tests/unit/features/chat/state/ChatState.test.ts +581 -0
- package/tests/unit/features/chat/tabs/Tab.test.ts +4287 -0
- package/tests/unit/features/chat/tabs/TabBar.test.ts +357 -0
- package/tests/unit/features/chat/tabs/TabManager.test.ts +2962 -0
- package/tests/unit/features/chat/tabs/index.test.ts +11 -0
- package/tests/unit/features/chat/ui/BangBashModeManager.test.ts +321 -0
- package/tests/unit/features/chat/ui/ExternalContextSelector.test.ts +555 -0
- package/tests/unit/features/chat/ui/FileContextManager.test.ts +876 -0
- package/tests/unit/features/chat/ui/ImageContext.test.ts +777 -0
- package/tests/unit/features/chat/ui/InputToolbar.test.ts +1139 -0
- package/tests/unit/features/chat/ui/InstructionModeManager.test.ts +243 -0
- package/tests/unit/features/chat/ui/NavigationSidebar.test.ts +570 -0
- package/tests/unit/features/chat/ui/StatusPanel.test.ts +953 -0
- package/tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts +155 -0
- package/tests/unit/features/chat/ui/textareaResize.test.ts +102 -0
- package/tests/unit/features/chat/utils/usageInfo.test.ts +56 -0
- package/tests/unit/features/inline-edit/InlineEditService.test.ts +1199 -0
- package/tests/unit/features/inline-edit/ui/InlineEditModal.openAndWait.test.ts +1482 -0
- package/tests/unit/features/inline-edit/ui/InlineEditModal.test.ts +495 -0
- package/tests/unit/features/inline-edit/ui/inlineEditMarkdownPreview.test.ts +92 -0
- package/tests/unit/features/settings/AgentSettings.test.ts +82 -0
- package/tests/unit/features/settings/keyboardNavigation.test.ts +73 -0
- package/tests/unit/features/settings/ui/CodexSkillSettings.test.ts +294 -0
- package/tests/unit/features/settings/ui/CodexSubagentSettings.test.ts +207 -0
- package/tests/unit/i18n/constants.test.ts +43 -0
- package/tests/unit/i18n/i18n.test.ts +244 -0
- package/tests/unit/i18n/locales.test.ts +134 -0
- package/tests/unit/providers/acp/AcpClientConnection.test.ts +248 -0
- package/tests/unit/providers/acp/AcpJsonRpcTransport.test.ts +186 -0
- package/tests/unit/providers/acp/AcpSessionConfig.test.ts +247 -0
- package/tests/unit/providers/acp/AcpSessionUpdateNormalizer.test.ts +145 -0
- package/tests/unit/providers/acp/AcpSubprocess.test.ts +105 -0
- package/tests/unit/providers/acp/buildAcpUsageInfo.test.ts +51 -0
- package/tests/unit/providers/claude/agents/AgentManager.test.ts +590 -0
- package/tests/unit/providers/claude/agents/AgentStorage.test.ts +434 -0
- package/tests/unit/providers/claude/agents/index.test.ts +10 -0
- package/tests/unit/providers/claude/commands/ClaudeCommandCatalog.test.ts +396 -0
- package/tests/unit/providers/claude/commands/probeRuntimeCommands.test.ts +92 -0
- package/tests/unit/providers/claude/env/ClaudeSettingsReconciler.test.ts +57 -0
- package/tests/unit/providers/claude/env/claudeModelEnv.test.ts +228 -0
- package/tests/unit/providers/claude/hooks/SubagentHooks.test.ts +83 -0
- package/tests/unit/providers/claude/plugins/PluginManager.test.ts +832 -0
- package/tests/unit/providers/claude/plugins/index.test.ts +7 -0
- package/tests/unit/providers/claude/prompt/ClaudeTurnEncoder.test.ts +145 -0
- package/tests/unit/providers/claude/prompt/instructionRefine.test.ts +185 -0
- package/tests/unit/providers/claude/prompt/systemPrompt.test.ts +163 -0
- package/tests/unit/providers/claude/prompt/titleGeneration.test.ts +20 -0
- package/tests/unit/providers/claude/runtime/ClaudeTaskResultInterpreter.test.ts +28 -0
- package/tests/unit/providers/claude/runtime/ClaudianService.test.ts +3796 -0
- package/tests/unit/providers/claude/runtime/MessageChannel.test.ts +421 -0
- package/tests/unit/providers/claude/runtime/QueryOptionsBuilder.test.ts +775 -0
- package/tests/unit/providers/claude/runtime/SessionManager.test.ts +182 -0
- package/tests/unit/providers/claude/runtime/claudeColdStartQuery.test.ts +331 -0
- package/tests/unit/providers/claude/runtime/customSpawn.test.ts +374 -0
- package/tests/unit/providers/claude/runtime/index.test.ts +13 -0
- package/tests/unit/providers/claude/runtime/types.test.ts +190 -0
- package/tests/unit/providers/claude/sdk/typeGuards.test.ts +50 -0
- package/tests/unit/providers/claude/security/ClaudePermissionUpdates.test.ts +198 -0
- package/tests/unit/providers/claude/storage/AgentVaultStorage.test.ts +413 -0
- package/tests/unit/providers/claude/storage/CCSettingsStorage.test.ts +408 -0
- package/tests/unit/providers/claude/storage/ClaudianSettingsStorage.test.ts +653 -0
- package/tests/unit/providers/claude/storage/McpStorage.test.ts +619 -0
- package/tests/unit/providers/claude/storage/SessionStorage.test.ts +680 -0
- package/tests/unit/providers/claude/storage/SkillStorage.test.ts +275 -0
- package/tests/unit/providers/claude/storage/SlashCommandStorage.test.ts +612 -0
- package/tests/unit/providers/claude/storage/storage.test.ts +360 -0
- package/tests/unit/providers/claude/storage/storageService.convenience.test.ts +447 -0
- package/tests/unit/providers/claude/stream/transformSDKMessage.test.ts +1729 -0
- package/tests/unit/providers/claude/types/types.test.ts +726 -0
- package/tests/unit/providers/claude/ui/ClaudeChatUIConfig.test.ts +173 -0
- package/tests/unit/providers/claude/ui/ClaudeSettingsTab.test.ts +466 -0
- package/tests/unit/providers/codex/agents/CodexAgentMentionProvider.test.ts +89 -0
- package/tests/unit/providers/codex/auxiliary/CodexInstructionRefineService.test.ts +81 -0
- package/tests/unit/providers/codex/capabilities.test.ts +39 -0
- package/tests/unit/providers/codex/commands/CodexSkillCatalog.test.ts +413 -0
- package/tests/unit/providers/codex/env/CodexSettingsReconciler.test.ts +106 -0
- package/tests/unit/providers/codex/fixtures/codex-session-abort.jsonl +9 -0
- package/tests/unit/providers/codex/fixtures/codex-session-agent-lifecycle.jsonl +12 -0
- package/tests/unit/providers/codex/fixtures/codex-session-persisted-tools.jsonl +15 -0
- package/tests/unit/providers/codex/fixtures/codex-session-simple.jsonl +10 -0
- package/tests/unit/providers/codex/fixtures/codex-session-tools.jsonl +12 -0
- package/tests/unit/providers/codex/fixtures/codex-session-websearch-persisted.jsonl +3 -0
- package/tests/unit/providers/codex/fixtures/codex-session-websearch.jsonl +7 -0
- package/tests/unit/providers/codex/history/CodexConversationHistoryService.test.ts +690 -0
- package/tests/unit/providers/codex/history/CodexHistoryStore.test.ts +2202 -0
- package/tests/unit/providers/codex/normalization/codexSubagentNormalization.test.ts +81 -0
- package/tests/unit/providers/codex/normalization/codexToolNormalization.test.ts +322 -0
- package/tests/unit/providers/codex/prompt/encodeCodexTurn.test.ts +168 -0
- package/tests/unit/providers/codex/runtime/CodexAppServerProcess.test.ts +255 -0
- package/tests/unit/providers/codex/runtime/CodexAuxQueryRunner.test.ts +130 -0
- package/tests/unit/providers/codex/runtime/CodexBinaryLocator.test.ts +90 -0
- package/tests/unit/providers/codex/runtime/CodexChatRuntime.test.ts +2445 -0
- package/tests/unit/providers/codex/runtime/CodexCliResolver.test.ts +103 -0
- package/tests/unit/providers/codex/runtime/CodexExecutionTargetResolver.test.ts +105 -0
- package/tests/unit/providers/codex/runtime/CodexLaunchSpecBuilder.test.ts +150 -0
- package/tests/unit/providers/codex/runtime/CodexNotificationRouter.test.ts +1248 -0
- package/tests/unit/providers/codex/runtime/CodexPathMapper.test.ts +51 -0
- package/tests/unit/providers/codex/runtime/CodexRpcTransport.test.ts +220 -0
- package/tests/unit/providers/codex/runtime/CodexRuntimeContext.test.ts +107 -0
- package/tests/unit/providers/codex/runtime/CodexServerRequestRouter.test.ts +537 -0
- package/tests/unit/providers/codex/runtime/CodexSessionFileTail.test.ts +1305 -0
- package/tests/unit/providers/codex/runtime/CodexSessionManager.test.ts +82 -0
- package/tests/unit/providers/codex/runtime/codexAppServerTypes.test.ts +336 -0
- package/tests/unit/providers/codex/settings.test.ts +189 -0
- package/tests/unit/providers/codex/skills/CodexSkillListingService.test.ts +178 -0
- package/tests/unit/providers/codex/storage/CodexSkillStorage.test.ts +342 -0
- package/tests/unit/providers/codex/storage/CodexSubagentStorage.test.ts +376 -0
- package/tests/unit/providers/codex/ui/CodexChatUIConfig.test.ts +211 -0
- package/tests/unit/providers/codex/ui/CodexSettingsTab.test.ts +578 -0
- package/tests/unit/providers/defaultProviderConfigs.test.ts +18 -0
- package/tests/unit/providers/opencode/OpencodeAuxQueryRunner.test.ts +355 -0
- package/tests/unit/providers/opencode/OpencodeChatRuntime.test.ts +808 -0
- package/tests/unit/providers/opencode/OpencodeCliResolver.test.ts +93 -0
- package/tests/unit/providers/opencode/OpencodeCommandCatalog.test.ts +75 -0
- package/tests/unit/providers/opencode/OpencodeConversationHistoryService.test.ts +103 -0
- package/tests/unit/providers/opencode/OpencodeHistoryStore.test.ts +418 -0
- package/tests/unit/providers/opencode/OpencodeLaunchArtifacts.test.ts +268 -0
- package/tests/unit/providers/opencode/OpencodePaths.test.ts +35 -0
- package/tests/unit/providers/opencode/OpencodeRuntimeCommandLoader.test.ts +129 -0
- package/tests/unit/providers/opencode/OpencodeSettingsReconciler.test.ts +96 -0
- package/tests/unit/providers/opencode/OpencodeSettingsTab.test.ts +649 -0
- package/tests/unit/providers/opencode/OpencodeSqliteReader.test.ts +185 -0
- package/tests/unit/providers/opencode/agents/OpencodeAgentMentionProvider.test.ts +56 -0
- package/tests/unit/providers/opencode/buildOpencodePrompt.test.ts +87 -0
- package/tests/unit/providers/opencode/capabilities.test.ts +39 -0
- package/tests/unit/providers/opencode/models.test.ts +273 -0
- package/tests/unit/providers/opencode/modes.test.ts +151 -0
- package/tests/unit/providers/opencode/opencodeToolNormalization.test.ts +197 -0
- package/tests/unit/providers/opencode/settings.test.ts +469 -0
- package/tests/unit/providers/opencode/storage/OpencodeAgentStorage.test.ts +377 -0
- package/tests/unit/providers/opencode/ui/OpencodeAgentSettings.test.ts +91 -0
- package/tests/unit/providers/pi/PiRuntimeCommandLoader.test.ts +149 -0
- package/tests/unit/providers/pi/capabilities.test.ts +20 -0
- package/tests/unit/providers/pi/commands/PiCommandCatalog.test.ts +69 -0
- package/tests/unit/providers/pi/env/PiSettingsReconciler.test.ts +61 -0
- package/tests/unit/providers/pi/history/PiConversationHistoryService.test.ts +147 -0
- package/tests/unit/providers/pi/history/PiHistoryStore.test.ts +523 -0
- package/tests/unit/providers/pi/models.test.ts +96 -0
- package/tests/unit/providers/pi/registration.test.ts +54 -0
- package/tests/unit/providers/pi/runtime/PiAuxQueryRunner.test.ts +172 -0
- package/tests/unit/providers/pi/runtime/PiChatRuntime.test.ts +830 -0
- package/tests/unit/providers/pi/runtime/PiCliResolver.test.ts +105 -0
- package/tests/unit/providers/pi/runtime/PiEventNormalization.test.ts +147 -0
- package/tests/unit/providers/pi/runtime/PiExtensionUiBridge.test.ts +98 -0
- package/tests/unit/providers/pi/runtime/PiJsonl.test.ts +42 -0
- package/tests/unit/providers/pi/runtime/PiLaunchSpec.test.ts +92 -0
- package/tests/unit/providers/pi/runtime/PiModelDiscoveryService.test.ts +135 -0
- package/tests/unit/providers/pi/runtime/PiRpcPayloads.test.ts +15 -0
- package/tests/unit/providers/pi/runtime/PiRpcTransport.test.ts +116 -0
- package/tests/unit/providers/pi/runtime/PiSubprocess.test.ts +151 -0
- package/tests/unit/providers/pi/runtime/buildPiPrompt.test.ts +84 -0
- package/tests/unit/providers/pi/runtime/buildPiUsageInfo.test.ts +69 -0
- package/tests/unit/providers/pi/settings.test.ts +253 -0
- package/tests/unit/providers/pi/ui/PiChatUIConfig.test.ts +161 -0
- package/tests/unit/providers/pi/ui/PiSettingsTab.test.ts +492 -0
- package/tests/unit/scripts/rendererSafeUnref.test.ts +101 -0
- package/tests/unit/shared/components/ResumeSessionDropdown.test.ts +356 -0
- package/tests/unit/shared/components/SelectableDropdown.test.ts +406 -0
- package/tests/unit/shared/components/SlashCommandDropdown.provider.test.ts +354 -0
- package/tests/unit/shared/components/SlashCommandDropdown.test.ts +508 -0
- package/tests/unit/shared/icons.test.ts +56 -0
- package/tests/unit/shared/index.test.ts +46 -0
- package/tests/unit/shared/mention/MentionDropdownController.test.ts +823 -0
- package/tests/unit/shared/mention/VaultFileCache.test.ts +195 -0
- package/tests/unit/shared/mention/VaultFolderCache.test.ts +143 -0
- package/tests/unit/shared/mention/VaultMentionDataProvider.test.ts +87 -0
- package/tests/unit/shared/modals/ConfirmModal.test.ts +111 -0
- package/tests/unit/shared/modals/ForkTargetModal.test.ts +101 -0
- package/tests/unit/shared/modals/InstructionConfirmModal.test.ts +305 -0
- package/tests/unit/utils/agent.test.ts +395 -0
- package/tests/unit/utils/animationFrame.test.ts +59 -0
- package/tests/unit/utils/browser.test.ts +73 -0
- package/tests/unit/utils/canvas.test.ts +54 -0
- package/tests/unit/utils/claudeCli.test.ts +294 -0
- package/tests/unit/utils/cliBinaryLocator.test.ts +33 -0
- package/tests/unit/utils/context.test.ts +288 -0
- package/tests/unit/utils/contextMentionResolver.test.ts +270 -0
- package/tests/unit/utils/date.test.ts +80 -0
- package/tests/unit/utils/diff.test.ts +291 -0
- package/tests/unit/utils/editor.test.ts +249 -0
- package/tests/unit/utils/electronCompat.test.ts +87 -0
- package/tests/unit/utils/env.test.ts +1240 -0
- package/tests/unit/utils/externalContext.test.ts +336 -0
- package/tests/unit/utils/externalContextScanner.test.ts +186 -0
- package/tests/unit/utils/fileLink.dom.test.ts +273 -0
- package/tests/unit/utils/fileLink.handler.test.ts +64 -0
- package/tests/unit/utils/fileLink.test.ts +233 -0
- package/tests/unit/utils/frontmatter.test.ts +434 -0
- package/tests/unit/utils/imageEmbed.test.ts +407 -0
- package/tests/unit/utils/inlineEdit.test.ts +63 -0
- package/tests/unit/utils/interrupt.test.ts +73 -0
- package/tests/unit/utils/markdown.test.ts +35 -0
- package/tests/unit/utils/markdownMath.test.ts +54 -0
- package/tests/unit/utils/mcp.test.ts +256 -0
- package/tests/unit/utils/obsidianCompat.test.ts +18 -0
- package/tests/unit/utils/path.test.ts +677 -0
- package/tests/unit/utils/sdkSession.test.ts +2359 -0
- package/tests/unit/utils/session.test.ts +971 -0
- package/tests/unit/utils/slashCommand.test.ts +778 -0
- package/tests/unit/utils/utils.test.ts +809 -0
- package/tsconfig.jest.json +8 -0
- package/tsconfig.json +26 -0
- package/versions.json +4 -0
|
@@ -0,0 +1,4287 @@
|
|
|
1
|
+
import '@/providers';
|
|
2
|
+
|
|
3
|
+
import { createMockEl } from '@test/helpers/mockElement';
|
|
4
|
+
import { Notice, Platform } from 'obsidian';
|
|
5
|
+
|
|
6
|
+
import { ProviderRegistry } from '@/core/providers/ProviderRegistry';
|
|
7
|
+
import { ProviderWorkspaceRegistry } from '@/core/providers/ProviderWorkspaceRegistry';
|
|
8
|
+
import { SelectionController } from '@/features/chat/controllers/SelectionController';
|
|
9
|
+
import { ChatState } from '@/features/chat/state/ChatState';
|
|
10
|
+
import {
|
|
11
|
+
activateTab,
|
|
12
|
+
createTab,
|
|
13
|
+
deactivateTab,
|
|
14
|
+
destroyTab,
|
|
15
|
+
getBlankTabModelOptions,
|
|
16
|
+
getTabTitle,
|
|
17
|
+
initializeTabControllers,
|
|
18
|
+
initializeTabService,
|
|
19
|
+
initializeTabUI,
|
|
20
|
+
onProviderAvailabilityChanged,
|
|
21
|
+
setupServiceCallbacks,
|
|
22
|
+
type TabCreateOptions,
|
|
23
|
+
wireTabInputEvents,
|
|
24
|
+
} from '@/features/chat/tabs/Tab';
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_CODEX_PRIMARY_MODEL,
|
|
27
|
+
DEFAULT_CODEX_PRIMARY_MODEL_LABEL,
|
|
28
|
+
} from '@/providers/codex/types/models';
|
|
29
|
+
import * as envUtils from '@/utils/env';
|
|
30
|
+
|
|
31
|
+
// Mock ResizeObserver (not available in jsdom)
|
|
32
|
+
const resizeObserverInstances: MockResizeObserver[] = [];
|
|
33
|
+
class MockResizeObserver {
|
|
34
|
+
callback: ResizeObserverCallback;
|
|
35
|
+
constructor(callback: ResizeObserverCallback) {
|
|
36
|
+
this.callback = callback;
|
|
37
|
+
resizeObserverInstances.push(this);
|
|
38
|
+
}
|
|
39
|
+
observe = jest.fn();
|
|
40
|
+
unobserve = jest.fn();
|
|
41
|
+
disconnect = jest.fn();
|
|
42
|
+
}
|
|
43
|
+
global.ResizeObserver = MockResizeObserver as unknown as typeof ResizeObserver;
|
|
44
|
+
|
|
45
|
+
// Mock provider runtime used by ProviderRegistry
|
|
46
|
+
jest.mock('@/providers/claude/runtime/ClaudeChatRuntime', () => ({
|
|
47
|
+
ClaudianService: jest.fn().mockImplementation(() => ({
|
|
48
|
+
ensureReady: jest.fn().mockResolvedValue(true),
|
|
49
|
+
cleanup: jest.fn(),
|
|
50
|
+
isReady: jest.fn().mockReturnValue(false),
|
|
51
|
+
syncConversationState: jest.fn(),
|
|
52
|
+
onReadyStateChange: jest.fn((listener: (ready: boolean) => void) => {
|
|
53
|
+
listener(false);
|
|
54
|
+
return () => {};
|
|
55
|
+
}),
|
|
56
|
+
})),
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
// Mock factories must be defined before jest.mock calls due to hoisting
|
|
60
|
+
// These will be initialized fresh in beforeEach
|
|
61
|
+
const createMockFileContextManager = () => ({
|
|
62
|
+
setMcpManager: jest.fn(),
|
|
63
|
+
setAgentService: jest.fn(),
|
|
64
|
+
setOnMcpMentionChange: jest.fn(),
|
|
65
|
+
preScanExternalContexts: jest.fn(),
|
|
66
|
+
handleInputChange: jest.fn(),
|
|
67
|
+
handleMentionKeydown: jest.fn().mockReturnValue(false),
|
|
68
|
+
isMentionDropdownVisible: jest.fn().mockReturnValue(false),
|
|
69
|
+
hideMentionDropdown: jest.fn(),
|
|
70
|
+
destroy: jest.fn(),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const createMockImageContextManager = () => ({
|
|
74
|
+
destroy: jest.fn(),
|
|
75
|
+
clearImages: jest.fn(),
|
|
76
|
+
setEnabled: jest.fn(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const createMockSlashCommandDropdown = () => ({
|
|
80
|
+
handleKeydown: jest.fn().mockReturnValue(false),
|
|
81
|
+
isVisible: jest.fn().mockReturnValue(false),
|
|
82
|
+
hide: jest.fn(),
|
|
83
|
+
resetSdkSkillsCache: jest.fn(),
|
|
84
|
+
setHiddenCommands: jest.fn(),
|
|
85
|
+
setEnabled: jest.fn(),
|
|
86
|
+
destroy: jest.fn(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const createMockInstructionModeManager = () => ({
|
|
90
|
+
handleTriggerKey: jest.fn().mockReturnValue(false),
|
|
91
|
+
handleKeydown: jest.fn().mockReturnValue(false),
|
|
92
|
+
handleInputChange: jest.fn(),
|
|
93
|
+
isActive: jest.fn().mockReturnValue(false),
|
|
94
|
+
destroy: jest.fn(),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const createMockBangBashModeManager = () => ({
|
|
98
|
+
handleTriggerKey: jest.fn().mockReturnValue(false),
|
|
99
|
+
handleKeydown: jest.fn().mockReturnValue(false),
|
|
100
|
+
handleInputChange: jest.fn(),
|
|
101
|
+
isActive: jest.fn().mockReturnValue(false),
|
|
102
|
+
destroy: jest.fn(),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const createMockStatusPanel = () => ({
|
|
106
|
+
mount: jest.fn(),
|
|
107
|
+
remount: jest.fn(),
|
|
108
|
+
updateTodos: jest.fn(),
|
|
109
|
+
destroy: jest.fn(),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const createMockModelSelector = () => ({
|
|
113
|
+
updateDisplay: jest.fn(),
|
|
114
|
+
renderOptions: jest.fn(),
|
|
115
|
+
setReady: jest.fn(),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const createMockModeSelector = () => ({
|
|
119
|
+
updateDisplay: jest.fn(),
|
|
120
|
+
renderOptions: jest.fn(),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const createMockClaudianService = (overrides?: {
|
|
124
|
+
ensureReady?: jest.Mock;
|
|
125
|
+
syncConversationState?: jest.Mock;
|
|
126
|
+
onReadyStateChange?: jest.Mock;
|
|
127
|
+
providerId?: 'claude' | 'codex';
|
|
128
|
+
}) => ({
|
|
129
|
+
providerId: overrides?.providerId ?? 'claude',
|
|
130
|
+
ensureReady: overrides?.ensureReady ?? jest.fn().mockResolvedValue(true),
|
|
131
|
+
cleanup: jest.fn(),
|
|
132
|
+
isReady: jest.fn().mockReturnValue(false),
|
|
133
|
+
getCapabilities: jest.fn().mockReturnValue({
|
|
134
|
+
providerId: overrides?.providerId ?? 'claude',
|
|
135
|
+
supportsPersistentRuntime: true,
|
|
136
|
+
supportsNativeHistory: true,
|
|
137
|
+
supportsPlanMode: true,
|
|
138
|
+
supportsRewind: true,
|
|
139
|
+
supportsFork: true,
|
|
140
|
+
supportsProviderCommands: true,
|
|
141
|
+
supportsImageAttachments: true,
|
|
142
|
+
supportsInstructionMode: true,
|
|
143
|
+
supportsMcpTools: true,
|
|
144
|
+
reasoningControl: 'effort',
|
|
145
|
+
}),
|
|
146
|
+
syncConversationState: overrides?.syncConversationState ?? jest.fn(),
|
|
147
|
+
onReadyStateChange: overrides?.onReadyStateChange ?? jest.fn((listener: (ready: boolean) => void) => {
|
|
148
|
+
listener(false);
|
|
149
|
+
return () => {};
|
|
150
|
+
}),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const createMockThinkingBudgetSelector = () => ({
|
|
154
|
+
updateDisplay: jest.fn(),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const createMockContextUsageMeter = () => ({
|
|
158
|
+
update: jest.fn(),
|
|
159
|
+
setVisible: jest.fn(),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const createMockExternalContextSelector = () => ({
|
|
163
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
164
|
+
setOnChange: jest.fn(),
|
|
165
|
+
setPersistentPaths: jest.fn(),
|
|
166
|
+
setOnPersistenceChange: jest.fn(),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const createMockMcpServerSelector = () => ({
|
|
170
|
+
setMcpManager: jest.fn(),
|
|
171
|
+
addMentionedServers: jest.fn(),
|
|
172
|
+
clearEnabled: jest.fn(),
|
|
173
|
+
setVisible: jest.fn(),
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const createMockPermissionToggle = () => ({
|
|
177
|
+
setVisible: jest.fn(),
|
|
178
|
+
updateDisplay: jest.fn(),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const createMockServiceTierToggle = () => ({
|
|
182
|
+
updateDisplay: jest.fn(),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Shared mock instances (reset in beforeEach)
|
|
186
|
+
let mockFileContextManager: ReturnType<typeof createMockFileContextManager>;
|
|
187
|
+
let mockImageContextManager: ReturnType<typeof createMockImageContextManager>;
|
|
188
|
+
let mockSlashCommandDropdown: ReturnType<typeof createMockSlashCommandDropdown>;
|
|
189
|
+
let mockInstructionModeManager: ReturnType<typeof createMockInstructionModeManager>;
|
|
190
|
+
let mockBangBashModeManager: ReturnType<typeof createMockBangBashModeManager>;
|
|
191
|
+
let mockStatusPanel: ReturnType<typeof createMockStatusPanel>;
|
|
192
|
+
let mockModelSelector: ReturnType<typeof createMockModelSelector>;
|
|
193
|
+
let mockModeSelector: ReturnType<typeof createMockModeSelector>;
|
|
194
|
+
let mockThinkingBudgetSelector: ReturnType<typeof createMockThinkingBudgetSelector>;
|
|
195
|
+
let mockContextUsageMeter: ReturnType<typeof createMockContextUsageMeter>;
|
|
196
|
+
let mockExternalContextSelector: ReturnType<typeof createMockExternalContextSelector>;
|
|
197
|
+
let mockMcpServerSelector: ReturnType<typeof createMockMcpServerSelector>;
|
|
198
|
+
let mockPermissionToggle: ReturnType<typeof createMockPermissionToggle>;
|
|
199
|
+
let mockServiceTierToggle: ReturnType<typeof createMockServiceTierToggle>;
|
|
200
|
+
let mockMessageRenderer: { scrollToBottomIfNeeded: jest.Mock; setAsyncSubagentClickCallback: jest.Mock };
|
|
201
|
+
let mockSelectionController: ReturnType<typeof createMockSelectionController>;
|
|
202
|
+
let mockBrowserSelectionController: ReturnType<typeof createMockBrowserSelectionController>;
|
|
203
|
+
let mockCanvasSelectionController: ReturnType<typeof createMockCanvasSelectionController>;
|
|
204
|
+
let mockStreamController: { onAsyncSubagentStateChange: jest.Mock };
|
|
205
|
+
let mockConversationController: { save: jest.Mock; rewind: jest.Mock };
|
|
206
|
+
let mockInputController: ReturnType<typeof createMockInputController>;
|
|
207
|
+
let mockNavigationController: { initialize: jest.Mock; dispose: jest.Mock };
|
|
208
|
+
|
|
209
|
+
const createMockSelectionController = () => ({
|
|
210
|
+
start: jest.fn(),
|
|
211
|
+
stop: jest.fn(),
|
|
212
|
+
clear: jest.fn(),
|
|
213
|
+
showHighlight: jest.fn(),
|
|
214
|
+
updateContextRowVisibility: jest.fn(),
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const createMockBrowserSelectionController = () => ({
|
|
218
|
+
start: jest.fn(),
|
|
219
|
+
stop: jest.fn(),
|
|
220
|
+
clear: jest.fn(),
|
|
221
|
+
updateContextRowVisibility: jest.fn(),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const createMockCanvasSelectionController = () => ({
|
|
225
|
+
start: jest.fn(),
|
|
226
|
+
stop: jest.fn(),
|
|
227
|
+
clear: jest.fn(),
|
|
228
|
+
updateContextRowVisibility: jest.fn(),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const createMockInputController = () => ({
|
|
232
|
+
sendMessage: jest.fn(),
|
|
233
|
+
cancelStreaming: jest.fn(),
|
|
234
|
+
handleInstructionSubmit: jest.fn(),
|
|
235
|
+
updateQueueIndicator: jest.fn(),
|
|
236
|
+
handleResumeKeydown: jest.fn().mockReturnValue(false),
|
|
237
|
+
isResumeDropdownVisible: jest.fn().mockReturnValue(false),
|
|
238
|
+
destroyResumeDropdown: jest.fn(),
|
|
239
|
+
dismissPendingApproval: jest.fn(),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
jest.mock('@/features/chat/ui/FileContext', () => ({
|
|
243
|
+
FileContextManager: jest.fn().mockImplementation(() => {
|
|
244
|
+
mockFileContextManager = createMockFileContextManager();
|
|
245
|
+
return mockFileContextManager;
|
|
246
|
+
}),
|
|
247
|
+
}));
|
|
248
|
+
|
|
249
|
+
jest.mock('@/features/chat/ui/ImageContext', () => ({
|
|
250
|
+
ImageContextManager: jest.fn().mockImplementation(() => {
|
|
251
|
+
mockImageContextManager = createMockImageContextManager();
|
|
252
|
+
return mockImageContextManager;
|
|
253
|
+
}),
|
|
254
|
+
}));
|
|
255
|
+
|
|
256
|
+
jest.mock('@/features/chat/ui/InstructionModeManager', () => ({
|
|
257
|
+
InstructionModeManager: jest.fn().mockImplementation(() => {
|
|
258
|
+
mockInstructionModeManager = createMockInstructionModeManager();
|
|
259
|
+
return mockInstructionModeManager;
|
|
260
|
+
}),
|
|
261
|
+
}));
|
|
262
|
+
|
|
263
|
+
jest.mock('@/features/chat/ui/StatusPanel', () => ({
|
|
264
|
+
StatusPanel: jest.fn().mockImplementation(() => {
|
|
265
|
+
mockStatusPanel = createMockStatusPanel();
|
|
266
|
+
return mockStatusPanel;
|
|
267
|
+
}),
|
|
268
|
+
}));
|
|
269
|
+
|
|
270
|
+
jest.mock('@/features/chat/ui/InputToolbar', () => ({
|
|
271
|
+
createInputToolbar: jest.fn().mockImplementation(() => {
|
|
272
|
+
mockModelSelector = createMockModelSelector();
|
|
273
|
+
mockModeSelector = createMockModeSelector();
|
|
274
|
+
mockThinkingBudgetSelector = createMockThinkingBudgetSelector();
|
|
275
|
+
mockContextUsageMeter = createMockContextUsageMeter();
|
|
276
|
+
mockExternalContextSelector = createMockExternalContextSelector();
|
|
277
|
+
mockMcpServerSelector = createMockMcpServerSelector();
|
|
278
|
+
mockPermissionToggle = createMockPermissionToggle();
|
|
279
|
+
mockServiceTierToggle = createMockServiceTierToggle();
|
|
280
|
+
return {
|
|
281
|
+
modelSelector: mockModelSelector,
|
|
282
|
+
modeSelector: mockModeSelector,
|
|
283
|
+
thinkingBudgetSelector: mockThinkingBudgetSelector,
|
|
284
|
+
contextUsageMeter: mockContextUsageMeter,
|
|
285
|
+
externalContextSelector: mockExternalContextSelector,
|
|
286
|
+
mcpServerSelector: mockMcpServerSelector,
|
|
287
|
+
permissionToggle: mockPermissionToggle,
|
|
288
|
+
serviceTierToggle: mockServiceTierToggle,
|
|
289
|
+
};
|
|
290
|
+
}),
|
|
291
|
+
}));
|
|
292
|
+
|
|
293
|
+
jest.mock('@/shared/components/SlashCommandDropdown', () => ({
|
|
294
|
+
SlashCommandDropdown: jest.fn().mockImplementation(() => {
|
|
295
|
+
mockSlashCommandDropdown = createMockSlashCommandDropdown();
|
|
296
|
+
return mockSlashCommandDropdown;
|
|
297
|
+
}),
|
|
298
|
+
}));
|
|
299
|
+
|
|
300
|
+
// Mock rendering
|
|
301
|
+
jest.mock('@/features/chat/rendering/MessageRenderer', () => ({
|
|
302
|
+
MessageRenderer: jest.fn().mockImplementation(() => {
|
|
303
|
+
mockMessageRenderer = {
|
|
304
|
+
scrollToBottomIfNeeded: jest.fn(),
|
|
305
|
+
setAsyncSubagentClickCallback: jest.fn(),
|
|
306
|
+
};
|
|
307
|
+
return mockMessageRenderer;
|
|
308
|
+
}),
|
|
309
|
+
}));
|
|
310
|
+
|
|
311
|
+
jest.mock('@/features/chat/rendering/ThinkingBlockRenderer', () => ({
|
|
312
|
+
cleanupThinkingBlock: jest.fn(),
|
|
313
|
+
}));
|
|
314
|
+
|
|
315
|
+
// Mock controllers
|
|
316
|
+
jest.mock('@/features/chat/controllers/SelectionController', () => ({
|
|
317
|
+
SelectionController: jest.fn().mockImplementation(() => {
|
|
318
|
+
mockSelectionController = createMockSelectionController();
|
|
319
|
+
return mockSelectionController;
|
|
320
|
+
}),
|
|
321
|
+
}));
|
|
322
|
+
|
|
323
|
+
jest.mock('@/features/chat/controllers/BrowserSelectionController', () => ({
|
|
324
|
+
BrowserSelectionController: jest.fn().mockImplementation(() => {
|
|
325
|
+
mockBrowserSelectionController = createMockBrowserSelectionController();
|
|
326
|
+
return mockBrowserSelectionController;
|
|
327
|
+
}),
|
|
328
|
+
}));
|
|
329
|
+
|
|
330
|
+
jest.mock('@/features/chat/controllers/CanvasSelectionController', () => ({
|
|
331
|
+
CanvasSelectionController: jest.fn().mockImplementation(() => {
|
|
332
|
+
mockCanvasSelectionController = createMockCanvasSelectionController();
|
|
333
|
+
return mockCanvasSelectionController;
|
|
334
|
+
}),
|
|
335
|
+
}));
|
|
336
|
+
|
|
337
|
+
jest.mock('@/features/chat/controllers/StreamController', () => ({
|
|
338
|
+
StreamController: jest.fn().mockImplementation(() => {
|
|
339
|
+
mockStreamController = { onAsyncSubagentStateChange: jest.fn() };
|
|
340
|
+
return mockStreamController;
|
|
341
|
+
}),
|
|
342
|
+
}));
|
|
343
|
+
|
|
344
|
+
jest.mock('@/features/chat/controllers/ConversationController', () => ({
|
|
345
|
+
ConversationController: jest.fn().mockImplementation(() => {
|
|
346
|
+
mockConversationController = {
|
|
347
|
+
save: jest.fn().mockResolvedValue(undefined),
|
|
348
|
+
rewind: jest.fn().mockResolvedValue(undefined),
|
|
349
|
+
};
|
|
350
|
+
return mockConversationController;
|
|
351
|
+
}),
|
|
352
|
+
}));
|
|
353
|
+
|
|
354
|
+
jest.mock('@/features/chat/controllers/InputController', () => ({
|
|
355
|
+
InputController: jest.fn().mockImplementation(() => {
|
|
356
|
+
mockInputController = createMockInputController();
|
|
357
|
+
return mockInputController;
|
|
358
|
+
}),
|
|
359
|
+
}));
|
|
360
|
+
|
|
361
|
+
jest.mock('@/features/chat/controllers/NavigationController', () => ({
|
|
362
|
+
NavigationController: jest.fn().mockImplementation(() => {
|
|
363
|
+
mockNavigationController = { initialize: jest.fn(), dispose: jest.fn() };
|
|
364
|
+
return mockNavigationController;
|
|
365
|
+
}),
|
|
366
|
+
}));
|
|
367
|
+
|
|
368
|
+
// Mock services
|
|
369
|
+
jest.mock('@/features/chat/services/SubagentManager', () => ({
|
|
370
|
+
SubagentManager: jest.fn().mockImplementation(() => ({
|
|
371
|
+
orphanAllActive: jest.fn(),
|
|
372
|
+
setCallback: jest.fn(),
|
|
373
|
+
clear: jest.fn(),
|
|
374
|
+
})),
|
|
375
|
+
}));
|
|
376
|
+
|
|
377
|
+
jest.mock('@/providers/claude/auxiliary/ClaudeInstructionRefineService', () => ({
|
|
378
|
+
InstructionRefineService: jest.fn().mockImplementation(() => ({
|
|
379
|
+
cancel: jest.fn(),
|
|
380
|
+
resetConversation: jest.fn(),
|
|
381
|
+
})),
|
|
382
|
+
}));
|
|
383
|
+
|
|
384
|
+
jest.mock('@/providers/claude/auxiliary/ClaudeTitleGenerationService', () => ({
|
|
385
|
+
TitleGenerationService: jest.fn().mockImplementation(() => ({
|
|
386
|
+
cancel: jest.fn(),
|
|
387
|
+
})),
|
|
388
|
+
}));
|
|
389
|
+
|
|
390
|
+
// Mock path util
|
|
391
|
+
jest.mock('@/utils/path', () => ({
|
|
392
|
+
getVaultPath: jest.fn().mockReturnValue('/test/vault'),
|
|
393
|
+
}));
|
|
394
|
+
|
|
395
|
+
// Helper to create mock plugin
|
|
396
|
+
function createMockPlugin(overrides: Record<string, any> = {}): any {
|
|
397
|
+
const claudeAgentMentionProvider = { searchAgents: jest.fn().mockReturnValue([]) };
|
|
398
|
+
const codexAgentMentionProvider = { searchAgents: jest.fn().mockReturnValue([]) };
|
|
399
|
+
return {
|
|
400
|
+
app: {
|
|
401
|
+
vault: {
|
|
402
|
+
adapter: { basePath: '/test/vault' },
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
settings: {
|
|
406
|
+
excludedTags: [],
|
|
407
|
+
model: 'claude-sonnet-4-5',
|
|
408
|
+
thinkingBudget: 'low',
|
|
409
|
+
effortLevel: 'high',
|
|
410
|
+
serviceTier: 'default',
|
|
411
|
+
permissionMode: 'yolo',
|
|
412
|
+
keyboardNavigation: {
|
|
413
|
+
scrollUpKey: 'k',
|
|
414
|
+
scrollDownKey: 'j',
|
|
415
|
+
focusInputKey: 'i',
|
|
416
|
+
},
|
|
417
|
+
persistentExternalContextPaths: [],
|
|
418
|
+
settingsProvider: 'claude',
|
|
419
|
+
codexEnabled: true,
|
|
420
|
+
savedProviderModel: {
|
|
421
|
+
claude: 'claude-sonnet-4-5',
|
|
422
|
+
},
|
|
423
|
+
savedProviderEffort: {
|
|
424
|
+
claude: 'high',
|
|
425
|
+
},
|
|
426
|
+
savedProviderServiceTier: {
|
|
427
|
+
claude: 'default',
|
|
428
|
+
},
|
|
429
|
+
savedProviderThinkingBudget: {
|
|
430
|
+
claude: 'low',
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
mcpManager: { getMcpServers: jest.fn().mockReturnValue([]) },
|
|
434
|
+
agentManager: claudeAgentMentionProvider,
|
|
435
|
+
codexAgentMentionProvider,
|
|
436
|
+
getConversationById: jest.fn().mockResolvedValue(null),
|
|
437
|
+
getConversationSync: jest.fn().mockReturnValue(null),
|
|
438
|
+
saveSettings: jest.fn().mockResolvedValue(undefined),
|
|
439
|
+
getActiveEnvironmentVariables: jest.fn().mockReturnValue(''),
|
|
440
|
+
...overrides,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Helper to create mock MCP manager
|
|
445
|
+
function createMockMcpManager(): any {
|
|
446
|
+
return {
|
|
447
|
+
getMcpServers: jest.fn().mockReturnValue([]),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
type TestTabCreateOptions = TabCreateOptions & {
|
|
452
|
+
mcpManager: ReturnType<typeof createMockMcpManager>;
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
// Helper to create TabCreateOptions
|
|
456
|
+
function createMockOptions(overrides: Partial<TestTabCreateOptions> = {}): TestTabCreateOptions {
|
|
457
|
+
const options = {
|
|
458
|
+
plugin: createMockPlugin(),
|
|
459
|
+
mcpManager: createMockMcpManager(),
|
|
460
|
+
containerEl: createMockEl(),
|
|
461
|
+
...overrides,
|
|
462
|
+
} as TestTabCreateOptions;
|
|
463
|
+
|
|
464
|
+
const plugin = options.plugin as any;
|
|
465
|
+
ProviderWorkspaceRegistry.setServices('claude', {
|
|
466
|
+
mcpManager: plugin.mcpManager,
|
|
467
|
+
mcpServerManager: plugin.mcpManager,
|
|
468
|
+
agentMentionProvider: plugin.agentManager,
|
|
469
|
+
} as any);
|
|
470
|
+
ProviderWorkspaceRegistry.setServices('codex', {
|
|
471
|
+
agentMentionProvider: plugin.codexAgentMentionProvider,
|
|
472
|
+
} as any);
|
|
473
|
+
|
|
474
|
+
return options;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
describe('Tab - Creation', () => {
|
|
478
|
+
describe('createTab', () => {
|
|
479
|
+
it('should create a new tab with unique ID', () => {
|
|
480
|
+
const options = createMockOptions();
|
|
481
|
+
const tab = createTab(options);
|
|
482
|
+
|
|
483
|
+
expect(tab.id).toBeDefined();
|
|
484
|
+
expect(tab.id).toMatch(/^tab-/);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('should use provided tab ID when specified', () => {
|
|
488
|
+
const options = createMockOptions({ tabId: 'custom-tab-id' });
|
|
489
|
+
const tab = createTab(options);
|
|
490
|
+
|
|
491
|
+
expect(tab.id).toBe('custom-tab-id');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('should initialize with null conversationId when no conversation provided', () => {
|
|
495
|
+
const options = createMockOptions();
|
|
496
|
+
const tab = createTab(options);
|
|
497
|
+
|
|
498
|
+
expect(tab.conversationId).toBeNull();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('should set conversationId when conversation is provided', () => {
|
|
502
|
+
const options = createMockOptions({
|
|
503
|
+
conversation: {
|
|
504
|
+
id: 'conv-123',
|
|
505
|
+
providerId: 'claude',
|
|
506
|
+
title: 'Test Conversation',
|
|
507
|
+
messages: [],
|
|
508
|
+
sessionId: null,
|
|
509
|
+
createdAt: Date.now(),
|
|
510
|
+
updatedAt: Date.now(),
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
const tab = createTab(options);
|
|
514
|
+
|
|
515
|
+
expect(tab.conversationId).toBe('conv-123');
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it('should create tab with lazy-initialized service (null)', () => {
|
|
519
|
+
const options = createMockOptions();
|
|
520
|
+
const tab = createTab(options);
|
|
521
|
+
|
|
522
|
+
expect(tab.service).toBeNull();
|
|
523
|
+
expect(tab.serviceInitialized).toBe(false);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('should create ChatState with callbacks', () => {
|
|
527
|
+
const onStreamingChanged = jest.fn();
|
|
528
|
+
const onAttentionChanged = jest.fn();
|
|
529
|
+
const onConversationIdChanged = jest.fn();
|
|
530
|
+
|
|
531
|
+
const options = createMockOptions({
|
|
532
|
+
onStreamingChanged,
|
|
533
|
+
onAttentionChanged,
|
|
534
|
+
onConversationIdChanged,
|
|
535
|
+
});
|
|
536
|
+
const tab = createTab(options);
|
|
537
|
+
|
|
538
|
+
expect(tab.state).toBeInstanceOf(ChatState);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('should create DOM structure with hidden content', () => {
|
|
542
|
+
const containerEl = createMockEl();
|
|
543
|
+
const options = createMockOptions({ containerEl });
|
|
544
|
+
const tab = createTab(options);
|
|
545
|
+
|
|
546
|
+
expect(tab.dom.contentEl).toBeDefined();
|
|
547
|
+
expect(tab.dom.contentEl.style.display).toBe('none');
|
|
548
|
+
expect(tab.dom.messagesEl).toBeDefined();
|
|
549
|
+
expect(tab.dom.inputEl).toBeDefined();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('should initialize empty eventCleanups array', () => {
|
|
553
|
+
const options = createMockOptions();
|
|
554
|
+
const tab = createTab(options);
|
|
555
|
+
|
|
556
|
+
expect(tab.dom.eventCleanups).toEqual([]);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('should initialize all controllers as null', () => {
|
|
560
|
+
const options = createMockOptions();
|
|
561
|
+
const tab = createTab(options);
|
|
562
|
+
|
|
563
|
+
expect(tab.controllers.selectionController).toBeNull();
|
|
564
|
+
expect(tab.controllers.conversationController).toBeNull();
|
|
565
|
+
expect(tab.controllers.streamController).toBeNull();
|
|
566
|
+
expect(tab.controllers.inputController).toBeNull();
|
|
567
|
+
expect(tab.controllers.navigationController).toBeNull();
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('should derive the blank-tab provider from the default draft model', () => {
|
|
571
|
+
const plugin = createMockPlugin();
|
|
572
|
+
plugin.settings.model = DEFAULT_CODEX_PRIMARY_MODEL;
|
|
573
|
+
|
|
574
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
575
|
+
|
|
576
|
+
expect(tab.lifecycleState).toBe('blank');
|
|
577
|
+
expect(tab.draftModel).toBe(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
578
|
+
expect(tab.providerId).toBe('codex');
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('should resolve draft model from defaultProviderId via projection', () => {
|
|
582
|
+
const plugin = createMockPlugin();
|
|
583
|
+
// Top-level model is Claude, but Codex has its own saved model
|
|
584
|
+
plugin.settings.model = 'claude-sonnet-4-5';
|
|
585
|
+
plugin.settings.settingsProvider = 'claude';
|
|
586
|
+
plugin.settings.savedProviderModel = { claude: 'claude-sonnet-4-5', codex: DEFAULT_CODEX_PRIMARY_MODEL };
|
|
587
|
+
|
|
588
|
+
const tab = createTab(createMockOptions({ plugin, defaultProviderId: 'codex' }));
|
|
589
|
+
|
|
590
|
+
expect(tab.lifecycleState).toBe('blank');
|
|
591
|
+
expect(tab.draftModel).toBe(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
592
|
+
expect(tab.providerId).toBe('codex');
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it('should resolve draft model for Claude when defaultProviderId is claude', () => {
|
|
596
|
+
const plugin = createMockPlugin();
|
|
597
|
+
// Simulate settings where top-level model drifted to a codex value
|
|
598
|
+
plugin.settings.model = 'gpt-5.4-mini';
|
|
599
|
+
plugin.settings.settingsProvider = 'claude';
|
|
600
|
+
plugin.settings.savedProviderModel = { claude: 'opus', codex: 'gpt-5.4-mini' };
|
|
601
|
+
|
|
602
|
+
const tab = createTab(createMockOptions({ plugin, defaultProviderId: 'claude' }));
|
|
603
|
+
|
|
604
|
+
expect(tab.lifecycleState).toBe('blank');
|
|
605
|
+
expect(tab.draftModel).toBe('opus');
|
|
606
|
+
expect(tab.providerId).toBe('claude');
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('should fall back to settings.model when no defaultProviderId is given', () => {
|
|
610
|
+
const plugin = createMockPlugin();
|
|
611
|
+
plugin.settings.model = 'opus';
|
|
612
|
+
|
|
613
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
614
|
+
|
|
615
|
+
expect(tab.lifecycleState).toBe('blank');
|
|
616
|
+
expect(tab.draftModel).toBe('opus');
|
|
617
|
+
expect(tab.providerId).toBe('claude');
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('should keep a Claude custom gpt model on Claude when Codex is disabled', () => {
|
|
621
|
+
const plugin = createMockPlugin();
|
|
622
|
+
plugin.settings.settingsProvider = 'claude';
|
|
623
|
+
plugin.settings.model = DEFAULT_CODEX_PRIMARY_MODEL;
|
|
624
|
+
plugin.settings.providerConfigs = {
|
|
625
|
+
claude: {
|
|
626
|
+
environmentVariables: `ANTHROPIC_MODEL=${DEFAULT_CODEX_PRIMARY_MODEL}`,
|
|
627
|
+
},
|
|
628
|
+
codex: {
|
|
629
|
+
enabled: false,
|
|
630
|
+
},
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
634
|
+
|
|
635
|
+
expect(tab.lifecycleState).toBe('blank');
|
|
636
|
+
expect(tab.draftModel).toBe(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
637
|
+
expect(tab.providerId).toBe('claude');
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('should fall back to an enabled provider when defaultProviderId is disabled', () => {
|
|
641
|
+
const plugin = createMockPlugin();
|
|
642
|
+
plugin.settings.settingsProvider = 'claude';
|
|
643
|
+
plugin.settings.model = 'claude-sonnet-4-5';
|
|
644
|
+
plugin.settings.providerConfigs = {
|
|
645
|
+
claude: {},
|
|
646
|
+
codex: {
|
|
647
|
+
enabled: false,
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
plugin.settings.savedProviderModel = {
|
|
651
|
+
claude: 'opus',
|
|
652
|
+
codex: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
const tab = createTab(createMockOptions({ plugin, defaultProviderId: 'codex' }));
|
|
656
|
+
|
|
657
|
+
expect(tab.lifecycleState).toBe('blank');
|
|
658
|
+
expect(tab.draftModel).toBe('opus');
|
|
659
|
+
expect(tab.providerId).toBe('claude');
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
describe('Tab - Service Initialization', () => {
|
|
665
|
+
afterEach(() => {
|
|
666
|
+
jest.restoreAllMocks();
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
describe('initializeTabService', () => {
|
|
670
|
+
it('should not reinitialize if already initialized', async () => {
|
|
671
|
+
const options = createMockOptions();
|
|
672
|
+
const tab = createTab(options);
|
|
673
|
+
tab.serviceInitialized = true;
|
|
674
|
+
tab.service = createMockClaudianService() as any;
|
|
675
|
+
|
|
676
|
+
await initializeTabService(tab, options.plugin, options.mcpManager);
|
|
677
|
+
|
|
678
|
+
// Service should not be replaced
|
|
679
|
+
expect(tab.service).toEqual(expect.objectContaining({ providerId: 'claude' }));
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
it('should create ClaudianService on first initialization', async () => {
|
|
683
|
+
const options = createMockOptions();
|
|
684
|
+
const tab = createTab(options);
|
|
685
|
+
|
|
686
|
+
await initializeTabService(tab, options.plugin, options.mcpManager);
|
|
687
|
+
|
|
688
|
+
expect(tab.service).toBeDefined();
|
|
689
|
+
expect(tab.serviceInitialized).toBe(true);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('should create the runtime for the conversation provider', async () => {
|
|
693
|
+
const createChatRuntimeSpy = jest.spyOn(ProviderRegistry, 'createChatRuntime');
|
|
694
|
+
const mockRuntime = createMockClaudianService({ providerId: 'codex' });
|
|
695
|
+
createChatRuntimeSpy.mockReturnValue(mockRuntime as any);
|
|
696
|
+
|
|
697
|
+
const conversation = {
|
|
698
|
+
id: 'conv-codex',
|
|
699
|
+
providerId: 'codex' as const,
|
|
700
|
+
title: 'Codex Conversation',
|
|
701
|
+
messages: [],
|
|
702
|
+
sessionId: null,
|
|
703
|
+
createdAt: Date.now(),
|
|
704
|
+
updatedAt: Date.now(),
|
|
705
|
+
};
|
|
706
|
+
|
|
707
|
+
const plugin = createMockPlugin({
|
|
708
|
+
getConversationById: jest.fn().mockResolvedValue(conversation),
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
const tab = createTab(createMockOptions({
|
|
712
|
+
plugin,
|
|
713
|
+
conversation,
|
|
714
|
+
}));
|
|
715
|
+
|
|
716
|
+
await initializeTabService(tab, plugin, createMockMcpManager());
|
|
717
|
+
|
|
718
|
+
expect(createChatRuntimeSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
719
|
+
plugin,
|
|
720
|
+
providerId: 'codex',
|
|
721
|
+
}));
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it('should recreate the runtime when the conversation provider changes', async () => {
|
|
725
|
+
const createChatRuntimeSpy = jest.spyOn(ProviderRegistry, 'createChatRuntime');
|
|
726
|
+
const oldService = createMockClaudianService({ providerId: 'claude' });
|
|
727
|
+
const newService = createMockClaudianService({ providerId: 'codex' });
|
|
728
|
+
createChatRuntimeSpy.mockReturnValue(newService as any);
|
|
729
|
+
|
|
730
|
+
const conversation = {
|
|
731
|
+
id: 'conv-codex',
|
|
732
|
+
providerId: 'codex' as const,
|
|
733
|
+
title: 'Codex Conversation',
|
|
734
|
+
messages: [],
|
|
735
|
+
sessionId: null,
|
|
736
|
+
createdAt: Date.now(),
|
|
737
|
+
updatedAt: Date.now(),
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
const plugin = createMockPlugin({
|
|
741
|
+
getConversationById: jest.fn().mockResolvedValue(conversation),
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
const tab = createTab(createMockOptions({
|
|
745
|
+
plugin,
|
|
746
|
+
conversation,
|
|
747
|
+
}));
|
|
748
|
+
tab.service = oldService as any;
|
|
749
|
+
tab.serviceInitialized = true;
|
|
750
|
+
|
|
751
|
+
await initializeTabService(tab, plugin, createMockMcpManager());
|
|
752
|
+
|
|
753
|
+
expect(oldService.cleanup).toHaveBeenCalled();
|
|
754
|
+
expect(createChatRuntimeSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
755
|
+
plugin,
|
|
756
|
+
providerId: 'codex',
|
|
757
|
+
}));
|
|
758
|
+
expect(tab.service).toBe(newService);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('should NOT call ensureReady for blank tabs (lazy start)', async () => {
|
|
762
|
+
const mockEnsureReady = jest.fn().mockResolvedValue(true);
|
|
763
|
+
const runtimeModule = jest.requireMock('@/providers/claude/runtime/ClaudeChatRuntime') as { ClaudianService: jest.Mock };
|
|
764
|
+
runtimeModule.ClaudianService.mockImplementationOnce(() => createMockClaudianService({ ensureReady: mockEnsureReady }));
|
|
765
|
+
|
|
766
|
+
const options = createMockOptions();
|
|
767
|
+
const tab = createTab(options);
|
|
768
|
+
|
|
769
|
+
await initializeTabService(tab, options.plugin, options.mcpManager);
|
|
770
|
+
|
|
771
|
+
// Runtime starts on demand in query(), not during initialization
|
|
772
|
+
expect(mockEnsureReady).not.toHaveBeenCalled();
|
|
773
|
+
expect(tab.serviceInitialized).toBe(true);
|
|
774
|
+
expect(tab.lifecycleState).toBe('bound_active');
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it('should sync existing conversations with saved external contexts', async () => {
|
|
778
|
+
const mockSyncConversationState = jest.fn();
|
|
779
|
+
const runtimeModule = jest.requireMock('@/providers/claude/runtime/ClaudeChatRuntime') as { ClaudianService: jest.Mock };
|
|
780
|
+
runtimeModule.ClaudianService.mockImplementationOnce(() => createMockClaudianService({
|
|
781
|
+
syncConversationState: mockSyncConversationState,
|
|
782
|
+
}));
|
|
783
|
+
|
|
784
|
+
const conversation = {
|
|
785
|
+
id: 'conv-1',
|
|
786
|
+
providerId: 'claude' as const,
|
|
787
|
+
title: 'Existing Conversation',
|
|
788
|
+
messages: [{ id: 'msg-1', role: 'user' as const, content: 'test', timestamp: Date.now() }],
|
|
789
|
+
sessionId: 'session-123',
|
|
790
|
+
externalContextPaths: ['/saved/path'],
|
|
791
|
+
createdAt: Date.now(),
|
|
792
|
+
updatedAt: Date.now(),
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
const plugin = createMockPlugin();
|
|
796
|
+
plugin.settings.persistentExternalContextPaths = ['/persistent/path'];
|
|
797
|
+
plugin.getConversationById = jest.fn().mockResolvedValue(conversation);
|
|
798
|
+
|
|
799
|
+
const options = createMockOptions({ plugin, conversation });
|
|
800
|
+
const tab = createTab(options);
|
|
801
|
+
|
|
802
|
+
await initializeTabService(tab, options.plugin, options.mcpManager);
|
|
803
|
+
|
|
804
|
+
expect(mockSyncConversationState).toHaveBeenCalledWith(conversation, ['/saved/path']);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it('should initialize toolbar config for the tab provider', () => {
|
|
808
|
+
const getChatUIConfigSpy = jest.spyOn(ProviderRegistry, 'getChatUIConfig');
|
|
809
|
+
const getCapabilitiesSpy = jest.spyOn(ProviderRegistry, 'getCapabilities');
|
|
810
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
811
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
812
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
813
|
+
getChatUIConfigSpy.mockReturnValue({
|
|
814
|
+
getModelOptions: jest.fn().mockReturnValue([]),
|
|
815
|
+
ownsModel: jest.fn().mockReturnValue(false),
|
|
816
|
+
isAdaptiveReasoningModel: jest.fn().mockReturnValue(false),
|
|
817
|
+
getReasoningOptions: jest.fn().mockReturnValue([]),
|
|
818
|
+
getDefaultReasoningValue: jest.fn().mockReturnValue('off'),
|
|
819
|
+
getContextWindowSize: jest.fn().mockReturnValue(200000),
|
|
820
|
+
isDefaultModel: jest.fn().mockReturnValue(true),
|
|
821
|
+
applyModelDefaults: jest.fn(),
|
|
822
|
+
normalizeModelVariant: jest.fn((model: string) => model),
|
|
823
|
+
getCustomModelIds: jest.fn().mockReturnValue(new Set()),
|
|
824
|
+
});
|
|
825
|
+
getCapabilitiesSpy.mockReturnValue({
|
|
826
|
+
providerId: 'codex',
|
|
827
|
+
supportsPersistentRuntime: true,
|
|
828
|
+
supportsNativeHistory: true,
|
|
829
|
+
supportsPlanMode: false,
|
|
830
|
+
supportsRewind: false,
|
|
831
|
+
supportsFork: false,
|
|
832
|
+
supportsProviderCommands: false,
|
|
833
|
+
supportsImageAttachments: true,
|
|
834
|
+
supportsInstructionMode: false,
|
|
835
|
+
supportsMcpTools: false,
|
|
836
|
+
reasoningControl: 'none',
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
const options = createMockOptions({
|
|
840
|
+
conversation: {
|
|
841
|
+
id: 'conv-codex',
|
|
842
|
+
providerId: 'codex',
|
|
843
|
+
title: 'Codex Conversation',
|
|
844
|
+
messages: [],
|
|
845
|
+
sessionId: null,
|
|
846
|
+
createdAt: Date.now(),
|
|
847
|
+
updatedAt: Date.now(),
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
const tab = createTab(options);
|
|
851
|
+
|
|
852
|
+
initializeTabUI(tab, options.plugin);
|
|
853
|
+
|
|
854
|
+
const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as {
|
|
855
|
+
createInputToolbar: jest.Mock;
|
|
856
|
+
};
|
|
857
|
+
const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1];
|
|
858
|
+
expect(toolbarCallbacks).toBeDefined();
|
|
859
|
+
|
|
860
|
+
toolbarCallbacks.getUIConfig();
|
|
861
|
+
toolbarCallbacks.getCapabilities();
|
|
862
|
+
|
|
863
|
+
expect(getChatUIConfigSpy).toHaveBeenCalledWith('codex');
|
|
864
|
+
expect(getCapabilitiesSpy).toHaveBeenCalledWith('codex');
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
it('resolves the agent mention service through the provider-specific lookup', () => {
|
|
868
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
869
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
870
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
871
|
+
|
|
872
|
+
const codexAgentMentionProvider = { searchAgents: jest.fn().mockReturnValue([]) };
|
|
873
|
+
const getAgentMentionProviderSpy = jest.spyOn(ProviderWorkspaceRegistry, 'getAgentMentionProvider')
|
|
874
|
+
.mockReturnValue(codexAgentMentionProvider as any);
|
|
875
|
+
const plugin = createMockPlugin({
|
|
876
|
+
codexAgentMentionProvider,
|
|
877
|
+
});
|
|
878
|
+
const tab = createTab(createMockOptions({
|
|
879
|
+
plugin,
|
|
880
|
+
conversation: {
|
|
881
|
+
id: 'conv-codex-agent-split',
|
|
882
|
+
providerId: 'codex',
|
|
883
|
+
title: 'Codex agent split',
|
|
884
|
+
messages: [],
|
|
885
|
+
sessionId: null,
|
|
886
|
+
createdAt: Date.now(),
|
|
887
|
+
updatedAt: Date.now(),
|
|
888
|
+
},
|
|
889
|
+
}));
|
|
890
|
+
|
|
891
|
+
initializeTabUI(tab, plugin);
|
|
892
|
+
|
|
893
|
+
expect(getAgentMentionProviderSpy).toHaveBeenCalledWith('codex');
|
|
894
|
+
expect(mockFileContextManager.setAgentService).toHaveBeenCalledWith(codexAgentMentionProvider);
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
it('falls back blank Codex draft to Claude when Codex is disabled', () => {
|
|
898
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
899
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
900
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
901
|
+
|
|
902
|
+
const plugin = createMockPlugin();
|
|
903
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
904
|
+
initializeTabUI(tab, plugin);
|
|
905
|
+
|
|
906
|
+
// Simulate blank tab with Codex draft model
|
|
907
|
+
tab.draftModel = DEFAULT_CODEX_PRIMARY_MODEL;
|
|
908
|
+
tab.providerId = 'codex';
|
|
909
|
+
tab.lifecycleState = 'blank';
|
|
910
|
+
|
|
911
|
+
const staleService = createMockClaudianService({ providerId: 'codex' });
|
|
912
|
+
tab.service = staleService as any;
|
|
913
|
+
tab.serviceInitialized = true;
|
|
914
|
+
|
|
915
|
+
// Disable Codex
|
|
916
|
+
plugin.settings.codexEnabled = false;
|
|
917
|
+
|
|
918
|
+
onProviderAvailabilityChanged(tab, plugin);
|
|
919
|
+
|
|
920
|
+
expect(staleService.cleanup).toHaveBeenCalled();
|
|
921
|
+
expect(tab.providerId).toBe('claude');
|
|
922
|
+
expect(tab.service).toBeNull();
|
|
923
|
+
expect(tab.serviceInitialized).toBe(false);
|
|
924
|
+
expect(mockSlashCommandDropdown.resetSdkSkillsCache).toHaveBeenCalled();
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it('rebinds provider-scoped helper services when a newly enabled provider takes over the draft model', () => {
|
|
928
|
+
const createInstructionRefineServiceSpy = jest.spyOn(ProviderRegistry, 'createInstructionRefineService')
|
|
929
|
+
.mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
930
|
+
const createTitleGenerationServiceSpy = jest.spyOn(ProviderRegistry, 'createTitleGenerationService')
|
|
931
|
+
.mockReturnValue({ cancel: jest.fn() } as any);
|
|
932
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
933
|
+
|
|
934
|
+
const plugin = createMockPlugin();
|
|
935
|
+
plugin.settings.settingsProvider = 'claude';
|
|
936
|
+
plugin.settings.model = DEFAULT_CODEX_PRIMARY_MODEL;
|
|
937
|
+
plugin.settings.providerConfigs = {
|
|
938
|
+
claude: {
|
|
939
|
+
environmentVariables: `ANTHROPIC_MODEL=${DEFAULT_CODEX_PRIMARY_MODEL}`,
|
|
940
|
+
},
|
|
941
|
+
codex: {
|
|
942
|
+
enabled: false,
|
|
943
|
+
},
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
947
|
+
initializeTabUI(tab, plugin);
|
|
948
|
+
|
|
949
|
+
expect(tab.draftModel).toBe(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
950
|
+
expect(tab.providerId).toBe('claude');
|
|
951
|
+
|
|
952
|
+
plugin.settings.providerConfigs = {
|
|
953
|
+
...plugin.settings.providerConfigs,
|
|
954
|
+
codex: {
|
|
955
|
+
enabled: true,
|
|
956
|
+
},
|
|
957
|
+
};
|
|
958
|
+
|
|
959
|
+
onProviderAvailabilityChanged(tab, plugin);
|
|
960
|
+
|
|
961
|
+
expect(tab.providerId).toBe('codex');
|
|
962
|
+
expect(createInstructionRefineServiceSpy).toHaveBeenLastCalledWith(plugin, 'codex');
|
|
963
|
+
expect(createTitleGenerationServiceSpy).not.toHaveBeenCalledWith(plugin, 'codex');
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
it('surfaces provider-scoped model settings for inactive-provider tabs and saves back to that provider snapshot', async () => {
|
|
967
|
+
const plugin = createMockPlugin({
|
|
968
|
+
settings: {
|
|
969
|
+
excludedTags: [],
|
|
970
|
+
model: 'claude-sonnet-4-5',
|
|
971
|
+
thinkingBudget: 'low',
|
|
972
|
+
effortLevel: 'high',
|
|
973
|
+
permissionMode: 'yolo',
|
|
974
|
+
keyboardNavigation: {
|
|
975
|
+
scrollUpKey: 'k',
|
|
976
|
+
scrollDownKey: 'j',
|
|
977
|
+
focusInputKey: 'i',
|
|
978
|
+
},
|
|
979
|
+
persistentExternalContextPaths: [],
|
|
980
|
+
settingsProvider: 'claude',
|
|
981
|
+
codexEnabled: true,
|
|
982
|
+
savedProviderModel: {
|
|
983
|
+
claude: 'claude-sonnet-4-5',
|
|
984
|
+
codex: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
985
|
+
},
|
|
986
|
+
savedProviderEffort: {
|
|
987
|
+
claude: 'high',
|
|
988
|
+
codex: 'medium',
|
|
989
|
+
},
|
|
990
|
+
savedProviderThinkingBudget: {
|
|
991
|
+
claude: 'low',
|
|
992
|
+
codex: 'off',
|
|
993
|
+
},
|
|
994
|
+
},
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
const tab = createTab(createMockOptions({
|
|
998
|
+
plugin,
|
|
999
|
+
conversation: {
|
|
1000
|
+
id: 'conv-codex-settings',
|
|
1001
|
+
providerId: 'codex',
|
|
1002
|
+
title: 'Codex conversation',
|
|
1003
|
+
messages: [],
|
|
1004
|
+
sessionId: null,
|
|
1005
|
+
createdAt: Date.now(),
|
|
1006
|
+
updatedAt: Date.now(),
|
|
1007
|
+
},
|
|
1008
|
+
}));
|
|
1009
|
+
|
|
1010
|
+
initializeTabUI(tab, plugin);
|
|
1011
|
+
|
|
1012
|
+
const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as {
|
|
1013
|
+
createInputToolbar: jest.Mock;
|
|
1014
|
+
};
|
|
1015
|
+
const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1];
|
|
1016
|
+
|
|
1017
|
+
expect(toolbarCallbacks.getSettings()).toEqual(expect.objectContaining({
|
|
1018
|
+
model: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
1019
|
+
effortLevel: 'medium',
|
|
1020
|
+
}));
|
|
1021
|
+
|
|
1022
|
+
await toolbarCallbacks.onModelChange(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
1023
|
+
|
|
1024
|
+
expect(plugin.settings.model).toBe('claude-sonnet-4-5');
|
|
1025
|
+
expect(plugin.settings.savedProviderModel).toEqual(expect.objectContaining({
|
|
1026
|
+
claude: 'claude-sonnet-4-5',
|
|
1027
|
+
codex: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
1028
|
+
}));
|
|
1029
|
+
expect(plugin.saveSettings).toHaveBeenCalled();
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it('maps shared permission mode selections onto managed OpenCode modes', async () => {
|
|
1033
|
+
const plugin = createMockPlugin({
|
|
1034
|
+
settings: {
|
|
1035
|
+
excludedTags: [],
|
|
1036
|
+
model: 'claude-sonnet-4-5',
|
|
1037
|
+
thinkingBudget: 'low',
|
|
1038
|
+
effortLevel: 'high',
|
|
1039
|
+
permissionMode: 'yolo',
|
|
1040
|
+
keyboardNavigation: {
|
|
1041
|
+
scrollUpKey: 'k',
|
|
1042
|
+
scrollDownKey: 'j',
|
|
1043
|
+
focusInputKey: 'i',
|
|
1044
|
+
},
|
|
1045
|
+
persistentExternalContextPaths: [],
|
|
1046
|
+
settingsProvider: 'claude',
|
|
1047
|
+
providerConfigs: {
|
|
1048
|
+
opencode: {
|
|
1049
|
+
availableModes: [
|
|
1050
|
+
{ id: 'claudian-yolo', name: 'YOLO' },
|
|
1051
|
+
{ id: 'claudian-safe', name: 'Safe' },
|
|
1052
|
+
{ id: 'plan', name: 'Plan' },
|
|
1053
|
+
],
|
|
1054
|
+
enabled: true,
|
|
1055
|
+
selectedMode: 'claudian-yolo',
|
|
1056
|
+
},
|
|
1057
|
+
},
|
|
1058
|
+
savedProviderEffort: {
|
|
1059
|
+
claude: 'high',
|
|
1060
|
+
opencode: 'default',
|
|
1061
|
+
},
|
|
1062
|
+
savedProviderModel: {
|
|
1063
|
+
claude: 'claude-sonnet-4-5',
|
|
1064
|
+
opencode: 'opencode:openai/gpt-5',
|
|
1065
|
+
},
|
|
1066
|
+
savedProviderPermissionMode: {
|
|
1067
|
+
claude: 'yolo',
|
|
1068
|
+
},
|
|
1069
|
+
},
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
const tab = createTab(createMockOptions({
|
|
1073
|
+
plugin,
|
|
1074
|
+
conversation: {
|
|
1075
|
+
id: 'conv-opencode-settings',
|
|
1076
|
+
providerId: 'opencode',
|
|
1077
|
+
title: 'OpenCode conversation',
|
|
1078
|
+
messages: [],
|
|
1079
|
+
sessionId: null,
|
|
1080
|
+
createdAt: Date.now(),
|
|
1081
|
+
updatedAt: Date.now(),
|
|
1082
|
+
},
|
|
1083
|
+
}));
|
|
1084
|
+
|
|
1085
|
+
initializeTabUI(tab, plugin);
|
|
1086
|
+
expect(mockPermissionToggle.setVisible).toHaveBeenLastCalledWith(true);
|
|
1087
|
+
|
|
1088
|
+
const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as {
|
|
1089
|
+
createInputToolbar: jest.Mock;
|
|
1090
|
+
};
|
|
1091
|
+
const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1];
|
|
1092
|
+
|
|
1093
|
+
await toolbarCallbacks.onPermissionModeChange('normal');
|
|
1094
|
+
|
|
1095
|
+
expect(plugin.settings.providerConfigs.opencode.selectedMode).toBe('claudian-safe');
|
|
1096
|
+
expect(plugin.settings.savedProviderPermissionMode).toEqual(expect.objectContaining({
|
|
1097
|
+
claude: 'yolo',
|
|
1098
|
+
opencode: 'normal',
|
|
1099
|
+
}));
|
|
1100
|
+
expect(plugin.settings.permissionMode).toBe('yolo');
|
|
1101
|
+
expect(plugin.saveSettings).toHaveBeenCalled();
|
|
1102
|
+
expect(mockPermissionToggle.updateDisplay).toHaveBeenCalled();
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it('resets to blank state when the new-conversation callback fires', () => {
|
|
1106
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
1107
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
1108
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
1109
|
+
|
|
1110
|
+
const plugin = createMockPlugin();
|
|
1111
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
1112
|
+
initializeTabUI(tab, plugin);
|
|
1113
|
+
initializeTabControllers(tab, plugin, {} as any, createMockMcpManager());
|
|
1114
|
+
|
|
1115
|
+
// Simulate a bound tab
|
|
1116
|
+
tab.lifecycleState = 'bound_cold';
|
|
1117
|
+
tab.conversationId = 'conv-1';
|
|
1118
|
+
|
|
1119
|
+
const convCtrlModule = jest.requireMock('@/features/chat/controllers/ConversationController') as {
|
|
1120
|
+
ConversationController: jest.Mock;
|
|
1121
|
+
};
|
|
1122
|
+
const callback = convCtrlModule.ConversationController.mock.calls.at(-1)?.[1]?.onNewConversation;
|
|
1123
|
+
|
|
1124
|
+
expect(callback).toBeDefined();
|
|
1125
|
+
|
|
1126
|
+
callback();
|
|
1127
|
+
|
|
1128
|
+
expect(tab.lifecycleState).toBe('blank');
|
|
1129
|
+
expect(tab.conversationId).toBeNull();
|
|
1130
|
+
// Draft model is resolved via provider projection, not raw settings.model
|
|
1131
|
+
expect(tab.draftModel).toBe(plugin.settings.savedProviderModel.claude);
|
|
1132
|
+
expect(tab.serviceInitialized).toBe(false);
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
it('preserves codex provider on new session when tab was codex', () => {
|
|
1136
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
1137
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
1138
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
1139
|
+
|
|
1140
|
+
const plugin = createMockPlugin();
|
|
1141
|
+
plugin.settings.savedProviderModel = { claude: 'claude-sonnet-4-5', codex: DEFAULT_CODEX_PRIMARY_MODEL };
|
|
1142
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
1143
|
+
initializeTabUI(tab, plugin);
|
|
1144
|
+
initializeTabControllers(tab, plugin, {} as any, createMockMcpManager());
|
|
1145
|
+
|
|
1146
|
+
// Simulate a bound Codex tab
|
|
1147
|
+
tab.lifecycleState = 'bound_cold';
|
|
1148
|
+
tab.conversationId = 'conv-1';
|
|
1149
|
+
tab.providerId = 'codex';
|
|
1150
|
+
|
|
1151
|
+
const convCtrlModule = jest.requireMock('@/features/chat/controllers/ConversationController') as {
|
|
1152
|
+
ConversationController: jest.Mock;
|
|
1153
|
+
};
|
|
1154
|
+
const callback = convCtrlModule.ConversationController.mock.calls.at(-1)?.[1]?.onNewConversation;
|
|
1155
|
+
|
|
1156
|
+
callback();
|
|
1157
|
+
|
|
1158
|
+
expect(tab.lifecycleState).toBe('blank');
|
|
1159
|
+
expect(tab.draftModel).toBe(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
1160
|
+
expect(tab.providerId).toBe('codex');
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
it('cleans up the active runtime when resetting to a new blank session', () => {
|
|
1164
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
1165
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
1166
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
1167
|
+
|
|
1168
|
+
const plugin = createMockPlugin();
|
|
1169
|
+
plugin.settings.savedProviderModel = { claude: 'claude-sonnet-4-5', codex: DEFAULT_CODEX_PRIMARY_MODEL };
|
|
1170
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
1171
|
+
initializeTabUI(tab, plugin);
|
|
1172
|
+
initializeTabControllers(tab, plugin, {} as any, createMockMcpManager());
|
|
1173
|
+
|
|
1174
|
+
const staleService = createMockClaudianService({ providerId: 'codex' });
|
|
1175
|
+
tab.lifecycleState = 'bound_active';
|
|
1176
|
+
tab.conversationId = 'conv-1';
|
|
1177
|
+
tab.providerId = 'codex';
|
|
1178
|
+
tab.service = staleService as any;
|
|
1179
|
+
tab.serviceInitialized = true;
|
|
1180
|
+
|
|
1181
|
+
const convCtrlModule = jest.requireMock('@/features/chat/controllers/ConversationController') as {
|
|
1182
|
+
ConversationController: jest.Mock;
|
|
1183
|
+
};
|
|
1184
|
+
const callback = convCtrlModule.ConversationController.mock.calls.at(-1)?.[1]?.onNewConversation;
|
|
1185
|
+
|
|
1186
|
+
callback();
|
|
1187
|
+
|
|
1188
|
+
expect(staleService.cleanup).toHaveBeenCalledTimes(1);
|
|
1189
|
+
expect(tab.service).toBeNull();
|
|
1190
|
+
expect(tab.serviceInitialized).toBe(false);
|
|
1191
|
+
expect(tab.lifecycleState).toBe('blank');
|
|
1192
|
+
expect(tab.providerId).toBe('codex');
|
|
1193
|
+
expect(tab.draftModel).toBe(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
1194
|
+
});
|
|
1195
|
+
});
|
|
1196
|
+
});
|
|
1197
|
+
|
|
1198
|
+
describe('Tab - Activation/Deactivation', () => {
|
|
1199
|
+
describe('activateTab', () => {
|
|
1200
|
+
it('should show tab content', () => {
|
|
1201
|
+
const options = createMockOptions();
|
|
1202
|
+
const tab = createTab(options);
|
|
1203
|
+
|
|
1204
|
+
activateTab(tab);
|
|
1205
|
+
|
|
1206
|
+
expect(tab.dom.contentEl.style.display).toBe('flex');
|
|
1207
|
+
});
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
describe('deactivateTab', () => {
|
|
1211
|
+
it('should hide tab content', () => {
|
|
1212
|
+
const options = createMockOptions();
|
|
1213
|
+
const tab = createTab(options);
|
|
1214
|
+
|
|
1215
|
+
// First activate, then deactivate
|
|
1216
|
+
activateTab(tab);
|
|
1217
|
+
deactivateTab(tab);
|
|
1218
|
+
|
|
1219
|
+
expect(tab.dom.contentEl.style.display).toBe('none');
|
|
1220
|
+
});
|
|
1221
|
+
});
|
|
1222
|
+
});
|
|
1223
|
+
|
|
1224
|
+
describe('Tab - Event Wiring', () => {
|
|
1225
|
+
describe('wireTabInputEvents', () => {
|
|
1226
|
+
it('should register event listeners on input element', () => {
|
|
1227
|
+
const options = createMockOptions();
|
|
1228
|
+
const tab = createTab(options);
|
|
1229
|
+
|
|
1230
|
+
// Initialize minimal controllers needed
|
|
1231
|
+
tab.controllers.inputController = {
|
|
1232
|
+
sendMessage: jest.fn(),
|
|
1233
|
+
cancelStreaming: jest.fn(),
|
|
1234
|
+
} as any;
|
|
1235
|
+
tab.controllers.selectionController = {
|
|
1236
|
+
showHighlight: jest.fn(),
|
|
1237
|
+
} as any;
|
|
1238
|
+
|
|
1239
|
+
wireTabInputEvents(tab, options.plugin);
|
|
1240
|
+
|
|
1241
|
+
// Check that event listeners were added (cast to any to access mock method)
|
|
1242
|
+
const inputListeners = (tab.dom.inputEl as any).getEventListeners();
|
|
1243
|
+
expect(inputListeners.get('keydown')).toBeDefined();
|
|
1244
|
+
expect(inputListeners.get('input')).toBeDefined();
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
it('should store cleanup functions for memory management', () => {
|
|
1248
|
+
const options = createMockOptions();
|
|
1249
|
+
const tab = createTab(options);
|
|
1250
|
+
|
|
1251
|
+
// Initialize minimal controllers
|
|
1252
|
+
tab.controllers.inputController = { sendMessage: jest.fn() } as any;
|
|
1253
|
+
tab.controllers.selectionController = { showHighlight: jest.fn() } as any;
|
|
1254
|
+
|
|
1255
|
+
wireTabInputEvents(tab, options.plugin);
|
|
1256
|
+
|
|
1257
|
+
expect(tab.dom.eventCleanups.length).toBe(3); // keydown, input, scroll
|
|
1258
|
+
});
|
|
1259
|
+
});
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
describe('Tab - Destruction', () => {
|
|
1263
|
+
describe('destroyTab', () => {
|
|
1264
|
+
it('should be an async function', async () => {
|
|
1265
|
+
const options = createMockOptions();
|
|
1266
|
+
const tab = createTab(options);
|
|
1267
|
+
|
|
1268
|
+
const result = destroyTab(tab);
|
|
1269
|
+
|
|
1270
|
+
expect(result).toBeInstanceOf(Promise);
|
|
1271
|
+
await result; // Should resolve without error
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
it('should call cleanup functions for event listeners', async () => {
|
|
1275
|
+
const options = createMockOptions();
|
|
1276
|
+
const tab = createTab(options);
|
|
1277
|
+
|
|
1278
|
+
const cleanup1 = jest.fn();
|
|
1279
|
+
const cleanup2 = jest.fn();
|
|
1280
|
+
tab.dom.eventCleanups = [cleanup1, cleanup2];
|
|
1281
|
+
|
|
1282
|
+
await destroyTab(tab);
|
|
1283
|
+
|
|
1284
|
+
expect(cleanup1).toHaveBeenCalled();
|
|
1285
|
+
expect(cleanup2).toHaveBeenCalled();
|
|
1286
|
+
});
|
|
1287
|
+
|
|
1288
|
+
it('should clear eventCleanups array after cleanup', async () => {
|
|
1289
|
+
const options = createMockOptions();
|
|
1290
|
+
const tab = createTab(options);
|
|
1291
|
+
|
|
1292
|
+
tab.dom.eventCleanups = [jest.fn(), jest.fn()];
|
|
1293
|
+
|
|
1294
|
+
await destroyTab(tab);
|
|
1295
|
+
|
|
1296
|
+
expect(tab.dom.eventCleanups.length).toBe(0);
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
it('should unsubscribe from ready state changes when tab is destroyed', async () => {
|
|
1300
|
+
const unsubscribeFn = jest.fn();
|
|
1301
|
+
const mockOnReadyStateChange = jest.fn(() => unsubscribeFn);
|
|
1302
|
+
|
|
1303
|
+
const runtimeModule = jest.requireMock('@/providers/claude/runtime/ClaudeChatRuntime') as { ClaudianService: jest.Mock };
|
|
1304
|
+
runtimeModule.ClaudianService.mockImplementationOnce(() => createMockClaudianService({ onReadyStateChange: mockOnReadyStateChange }));
|
|
1305
|
+
|
|
1306
|
+
const options = createMockOptions();
|
|
1307
|
+
const tab = createTab(options);
|
|
1308
|
+
initializeTabUI(tab, options.plugin);
|
|
1309
|
+
|
|
1310
|
+
await initializeTabService(tab, options.plugin, options.mcpManager);
|
|
1311
|
+
|
|
1312
|
+
expect(mockOnReadyStateChange).toHaveBeenCalled();
|
|
1313
|
+
|
|
1314
|
+
await destroyTab(tab);
|
|
1315
|
+
|
|
1316
|
+
expect(unsubscribeFn).toHaveBeenCalled();
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
it('should cleanup the runtime service', async () => {
|
|
1320
|
+
const mockCleanup = jest.fn();
|
|
1321
|
+
const options = createMockOptions();
|
|
1322
|
+
const tab = createTab(options);
|
|
1323
|
+
|
|
1324
|
+
tab.service = {
|
|
1325
|
+
cleanup: mockCleanup,
|
|
1326
|
+
} as any;
|
|
1327
|
+
|
|
1328
|
+
await destroyTab(tab);
|
|
1329
|
+
|
|
1330
|
+
expect(mockCleanup).toHaveBeenCalled();
|
|
1331
|
+
expect(tab.service).toBeNull();
|
|
1332
|
+
});
|
|
1333
|
+
|
|
1334
|
+
it('should remove DOM element', async () => {
|
|
1335
|
+
const options = createMockOptions();
|
|
1336
|
+
const tab = createTab(options);
|
|
1337
|
+
const removeSpy = jest.spyOn(tab.dom.contentEl, 'remove');
|
|
1338
|
+
|
|
1339
|
+
await destroyTab(tab);
|
|
1340
|
+
|
|
1341
|
+
expect(removeSpy).toHaveBeenCalled();
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
it('should cleanup subagents', async () => {
|
|
1345
|
+
const options = createMockOptions();
|
|
1346
|
+
const tab = createTab(options);
|
|
1347
|
+
|
|
1348
|
+
const orphanAllActive = jest.fn();
|
|
1349
|
+
const clear = jest.fn();
|
|
1350
|
+
tab.services.subagentManager = { orphanAllActive, clear } as any;
|
|
1351
|
+
|
|
1352
|
+
await destroyTab(tab);
|
|
1353
|
+
|
|
1354
|
+
expect(orphanAllActive).toHaveBeenCalled();
|
|
1355
|
+
expect(clear).toHaveBeenCalled();
|
|
1356
|
+
});
|
|
1357
|
+
|
|
1358
|
+
it('should cleanup UI components', async () => {
|
|
1359
|
+
const options = createMockOptions();
|
|
1360
|
+
const tab = createTab(options);
|
|
1361
|
+
|
|
1362
|
+
const destroyFileContext = jest.fn();
|
|
1363
|
+
const destroySlashDropdown = jest.fn();
|
|
1364
|
+
const destroyInstructionMode = jest.fn();
|
|
1365
|
+
const cancelInstructionRefine = jest.fn();
|
|
1366
|
+
const cancelTitleGeneration = jest.fn();
|
|
1367
|
+
const destroyTodoPanel = jest.fn();
|
|
1368
|
+
const destroyResumeDropdown = jest.fn();
|
|
1369
|
+
|
|
1370
|
+
tab.controllers.inputController = { destroyResumeDropdown, dismissPendingApproval: jest.fn() } as any;
|
|
1371
|
+
tab.ui.fileContextManager = { destroy: destroyFileContext } as any;
|
|
1372
|
+
tab.ui.slashCommandDropdown = { destroy: destroySlashDropdown } as any;
|
|
1373
|
+
tab.ui.instructionModeManager = { destroy: destroyInstructionMode } as any;
|
|
1374
|
+
tab.services.instructionRefineService = { cancel: cancelInstructionRefine, resetConversation: jest.fn() } as any;
|
|
1375
|
+
tab.services.titleGenerationService = { cancel: cancelTitleGeneration } as any;
|
|
1376
|
+
tab.ui.statusPanel = { destroy: destroyTodoPanel } as any;
|
|
1377
|
+
|
|
1378
|
+
await destroyTab(tab);
|
|
1379
|
+
|
|
1380
|
+
expect(destroyResumeDropdown).toHaveBeenCalled();
|
|
1381
|
+
expect(destroyFileContext).toHaveBeenCalled();
|
|
1382
|
+
expect(destroySlashDropdown).toHaveBeenCalled();
|
|
1383
|
+
expect(destroyInstructionMode).toHaveBeenCalled();
|
|
1384
|
+
expect(cancelInstructionRefine).toHaveBeenCalled();
|
|
1385
|
+
expect(cancelTitleGeneration).toHaveBeenCalled();
|
|
1386
|
+
expect(destroyTodoPanel).toHaveBeenCalled();
|
|
1387
|
+
});
|
|
1388
|
+
});
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
describe('Tab - Service Callbacks', () => {
|
|
1392
|
+
describe('setupServiceCallbacks', () => {
|
|
1393
|
+
function setupAutoTurnTest() {
|
|
1394
|
+
const plugin = createMockPlugin();
|
|
1395
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
1396
|
+
const addMessageSpy = jest.spyOn(tab.state, 'addMessage');
|
|
1397
|
+
const addMessage = jest.fn(() => {
|
|
1398
|
+
const msgEl = createMockEl();
|
|
1399
|
+
msgEl.createDiv({ cls: 'claudian-message-content' });
|
|
1400
|
+
return msgEl;
|
|
1401
|
+
});
|
|
1402
|
+
const scrollToBottom = jest.fn();
|
|
1403
|
+
const handleStreamChunk = jest.fn().mockResolvedValue(undefined);
|
|
1404
|
+
|
|
1405
|
+
Object.defineProperty(tab.dom.contentEl, 'isConnected', {
|
|
1406
|
+
value: true,
|
|
1407
|
+
writable: true,
|
|
1408
|
+
configurable: true,
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
tab.renderer = {
|
|
1412
|
+
addMessage,
|
|
1413
|
+
renderContent: jest.fn(),
|
|
1414
|
+
addTextCopyButton: jest.fn(),
|
|
1415
|
+
scrollToBottom,
|
|
1416
|
+
} as any;
|
|
1417
|
+
tab.controllers.streamController = {
|
|
1418
|
+
handleStreamChunk,
|
|
1419
|
+
appendText: jest.fn().mockResolvedValue(undefined),
|
|
1420
|
+
finalizeCurrentThinkingBlock: jest.fn().mockResolvedValue(undefined),
|
|
1421
|
+
finalizeCurrentTextBlock: jest.fn().mockResolvedValue(undefined),
|
|
1422
|
+
hideThinkingIndicator: jest.fn(),
|
|
1423
|
+
} as any;
|
|
1424
|
+
tab.controllers.inputController = {
|
|
1425
|
+
handleApprovalRequest: jest.fn(),
|
|
1426
|
+
dismissPendingApproval: jest.fn(),
|
|
1427
|
+
handleAskUserQuestion: jest.fn(),
|
|
1428
|
+
handleExitPlanMode: jest.fn(),
|
|
1429
|
+
} as any;
|
|
1430
|
+
tab.services.subagentManager = {
|
|
1431
|
+
hasRunningSubagents: jest.fn().mockReturnValue(false),
|
|
1432
|
+
resetStreamingState: jest.fn(),
|
|
1433
|
+
} as any;
|
|
1434
|
+
|
|
1435
|
+
const service = {
|
|
1436
|
+
setApprovalCallback: jest.fn(),
|
|
1437
|
+
setApprovalDismisser: jest.fn(),
|
|
1438
|
+
setAskUserQuestionCallback: jest.fn(),
|
|
1439
|
+
setExitPlanModeCallback: jest.fn(),
|
|
1440
|
+
setSubagentHookProvider: jest.fn(),
|
|
1441
|
+
setAutoTurnCallback: jest.fn(),
|
|
1442
|
+
setPermissionModeSyncCallback: jest.fn(),
|
|
1443
|
+
};
|
|
1444
|
+
tab.service = service as any;
|
|
1445
|
+
|
|
1446
|
+
setupServiceCallbacks(tab, plugin);
|
|
1447
|
+
|
|
1448
|
+
const autoTurnCallback = service.setAutoTurnCallback.mock.calls[0][0];
|
|
1449
|
+
return { tab, addMessageSpy, addMessage, handleStreamChunk, scrollToBottom, autoTurnCallback };
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
it('renders tool-only auto-triggered turns with a placeholder assistant message', async () => {
|
|
1453
|
+
const { addMessageSpy, addMessage, handleStreamChunk, scrollToBottom, autoTurnCallback } = setupAutoTurnTest();
|
|
1454
|
+
|
|
1455
|
+
await autoTurnCallback({
|
|
1456
|
+
chunks: [
|
|
1457
|
+
{ type: 'tool_result', id: 'task-1', content: 'done' },
|
|
1458
|
+
],
|
|
1459
|
+
metadata: {},
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
expect(addMessageSpy).toHaveBeenCalledWith(
|
|
1463
|
+
expect.objectContaining({
|
|
1464
|
+
role: 'assistant',
|
|
1465
|
+
content: '(background task completed)',
|
|
1466
|
+
})
|
|
1467
|
+
);
|
|
1468
|
+
expect(addMessage).toHaveBeenCalled();
|
|
1469
|
+
expect(handleStreamChunk).toHaveBeenCalledWith(
|
|
1470
|
+
{ type: 'tool_result', id: 'task-1', content: 'done' },
|
|
1471
|
+
expect.objectContaining({ role: 'assistant' })
|
|
1472
|
+
);
|
|
1473
|
+
expect(scrollToBottom).toHaveBeenCalled();
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
it('routes hidden async subagent auto-turn chunks without adding a placeholder message', async () => {
|
|
1477
|
+
const { addMessageSpy, addMessage, handleStreamChunk, scrollToBottom, autoTurnCallback } = setupAutoTurnTest();
|
|
1478
|
+
|
|
1479
|
+
await autoTurnCallback({
|
|
1480
|
+
chunks: [
|
|
1481
|
+
{
|
|
1482
|
+
type: 'async_subagent_result',
|
|
1483
|
+
agentId: 'agent-1',
|
|
1484
|
+
status: 'completed',
|
|
1485
|
+
result: 'Done',
|
|
1486
|
+
},
|
|
1487
|
+
],
|
|
1488
|
+
metadata: {},
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
expect(handleStreamChunk).toHaveBeenCalledWith(
|
|
1492
|
+
{
|
|
1493
|
+
type: 'async_subagent_result',
|
|
1494
|
+
agentId: 'agent-1',
|
|
1495
|
+
status: 'completed',
|
|
1496
|
+
result: 'Done',
|
|
1497
|
+
},
|
|
1498
|
+
expect.objectContaining({ role: 'assistant' })
|
|
1499
|
+
);
|
|
1500
|
+
expect(addMessageSpy).not.toHaveBeenCalled();
|
|
1501
|
+
expect(addMessage).not.toHaveBeenCalled();
|
|
1502
|
+
expect(scrollToBottom).not.toHaveBeenCalled();
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
it('skips auto-triggered rendering after the tab DOM is detached', async () => {
|
|
1506
|
+
const { tab, addMessageSpy, addMessage, handleStreamChunk, scrollToBottom, autoTurnCallback } = setupAutoTurnTest();
|
|
1507
|
+
|
|
1508
|
+
(tab.dom.contentEl as any).isConnected = false;
|
|
1509
|
+
await autoTurnCallback({
|
|
1510
|
+
chunks: [
|
|
1511
|
+
{ type: 'text', content: 'Background result' },
|
|
1512
|
+
],
|
|
1513
|
+
metadata: {},
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
expect(addMessageSpy).not.toHaveBeenCalled();
|
|
1517
|
+
expect(addMessage).not.toHaveBeenCalled();
|
|
1518
|
+
expect(handleStreamChunk).not.toHaveBeenCalled();
|
|
1519
|
+
expect(scrollToBottom).not.toHaveBeenCalled();
|
|
1520
|
+
});
|
|
1521
|
+
});
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
describe('Tab - Title', () => {
|
|
1525
|
+
describe('getTabTitle', () => {
|
|
1526
|
+
it('should return "New Chat" for tab without conversation', () => {
|
|
1527
|
+
const options = createMockOptions();
|
|
1528
|
+
const tab = createTab(options);
|
|
1529
|
+
|
|
1530
|
+
const title = getTabTitle(tab, options.plugin);
|
|
1531
|
+
|
|
1532
|
+
expect(title).toBe('New Chat');
|
|
1533
|
+
});
|
|
1534
|
+
|
|
1535
|
+
it('should return conversation title when available', () => {
|
|
1536
|
+
const plugin = createMockPlugin({
|
|
1537
|
+
getConversationSync: jest.fn().mockReturnValue({
|
|
1538
|
+
id: 'conv-123',
|
|
1539
|
+
title: 'My Conversation',
|
|
1540
|
+
}),
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
const options = createMockOptions({ plugin });
|
|
1544
|
+
const tab = createTab(options);
|
|
1545
|
+
tab.conversationId = 'conv-123';
|
|
1546
|
+
|
|
1547
|
+
const title = getTabTitle(tab, plugin);
|
|
1548
|
+
|
|
1549
|
+
expect(title).toBe('My Conversation');
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
it('should return "New Chat" when conversation has no title', () => {
|
|
1553
|
+
const plugin = createMockPlugin({
|
|
1554
|
+
getConversationSync: jest.fn().mockReturnValue({
|
|
1555
|
+
id: 'conv-123',
|
|
1556
|
+
title: null,
|
|
1557
|
+
}),
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
const options = createMockOptions({ plugin });
|
|
1561
|
+
const tab = createTab(options);
|
|
1562
|
+
tab.conversationId = 'conv-123';
|
|
1563
|
+
|
|
1564
|
+
const title = getTabTitle(tab, plugin);
|
|
1565
|
+
|
|
1566
|
+
expect(title).toBe('New Chat');
|
|
1567
|
+
});
|
|
1568
|
+
});
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
describe('Tab - UI Initialization', () => {
|
|
1572
|
+
beforeEach(() => {
|
|
1573
|
+
jest.clearAllMocks();
|
|
1574
|
+
});
|
|
1575
|
+
|
|
1576
|
+
describe('initializeTabUI', () => {
|
|
1577
|
+
it('should create FileContextManager', () => {
|
|
1578
|
+
const options = createMockOptions();
|
|
1579
|
+
const tab = createTab(options);
|
|
1580
|
+
|
|
1581
|
+
initializeTabUI(tab, options.plugin);
|
|
1582
|
+
|
|
1583
|
+
expect(tab.ui.fileContextManager).toBeDefined();
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
it('should wire FileContextManager to MCP service', () => {
|
|
1587
|
+
const options = createMockOptions();
|
|
1588
|
+
const tab = createTab(options);
|
|
1589
|
+
|
|
1590
|
+
initializeTabUI(tab, options.plugin);
|
|
1591
|
+
|
|
1592
|
+
expect(mockFileContextManager.setMcpManager).toHaveBeenCalledWith((options.plugin as any).mcpManager);
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
it('should create ImageContextManager', () => {
|
|
1596
|
+
const options = createMockOptions();
|
|
1597
|
+
const tab = createTab(options);
|
|
1598
|
+
|
|
1599
|
+
initializeTabUI(tab, options.plugin);
|
|
1600
|
+
|
|
1601
|
+
expect(tab.ui.imageContextManager).toBeDefined();
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
it('should create selection indicator element', () => {
|
|
1605
|
+
const options = createMockOptions();
|
|
1606
|
+
const tab = createTab(options);
|
|
1607
|
+
|
|
1608
|
+
initializeTabUI(tab, options.plugin);
|
|
1609
|
+
|
|
1610
|
+
expect(tab.dom.selectionIndicatorEl).toBeDefined();
|
|
1611
|
+
expect(tab.dom.selectionIndicatorEl!.style.display).toBe('none');
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
it('should create SlashCommandDropdown', () => {
|
|
1615
|
+
const options = createMockOptions();
|
|
1616
|
+
const tab = createTab(options);
|
|
1617
|
+
|
|
1618
|
+
initializeTabUI(tab, options.plugin);
|
|
1619
|
+
|
|
1620
|
+
expect(tab.ui.slashCommandDropdown).toBeDefined();
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
it('should create InstructionRefineService', () => {
|
|
1624
|
+
const options = createMockOptions();
|
|
1625
|
+
const tab = createTab(options);
|
|
1626
|
+
|
|
1627
|
+
initializeTabUI(tab, options.plugin);
|
|
1628
|
+
|
|
1629
|
+
expect(tab.services.instructionRefineService).toBeDefined();
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
it('should create TitleGenerationService', () => {
|
|
1633
|
+
const options = createMockOptions();
|
|
1634
|
+
const tab = createTab(options);
|
|
1635
|
+
|
|
1636
|
+
initializeTabUI(tab, options.plugin);
|
|
1637
|
+
|
|
1638
|
+
expect(tab.services.titleGenerationService).toBeDefined();
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
it('should create InstructionModeManager', () => {
|
|
1642
|
+
const options = createMockOptions();
|
|
1643
|
+
const tab = createTab(options);
|
|
1644
|
+
|
|
1645
|
+
initializeTabUI(tab, options.plugin);
|
|
1646
|
+
|
|
1647
|
+
expect(tab.ui.instructionModeManager).toBeDefined();
|
|
1648
|
+
});
|
|
1649
|
+
|
|
1650
|
+
it('should create and mount StatusPanel', () => {
|
|
1651
|
+
const options = createMockOptions();
|
|
1652
|
+
const tab = createTab(options);
|
|
1653
|
+
|
|
1654
|
+
initializeTabUI(tab, options.plugin);
|
|
1655
|
+
|
|
1656
|
+
expect(tab.ui.statusPanel).toBeDefined();
|
|
1657
|
+
expect(mockStatusPanel.mount).toHaveBeenCalledWith(tab.dom.statusPanelContainerEl);
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
it('should create input toolbar components', () => {
|
|
1661
|
+
const options = createMockOptions();
|
|
1662
|
+
const tab = createTab(options);
|
|
1663
|
+
|
|
1664
|
+
initializeTabUI(tab, options.plugin);
|
|
1665
|
+
|
|
1666
|
+
expect(tab.ui.modelSelector).toBeDefined();
|
|
1667
|
+
expect(tab.ui.thinkingBudgetSelector).toBeDefined();
|
|
1668
|
+
expect(tab.ui.contextUsageMeter).toBeDefined();
|
|
1669
|
+
expect(tab.ui.externalContextSelector).toBeDefined();
|
|
1670
|
+
expect(tab.ui.mcpServerSelector).toBeDefined();
|
|
1671
|
+
expect(tab.ui.permissionToggle).toBeDefined();
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
it('should create bang-bash mode from provider UI config', () => {
|
|
1675
|
+
const getEnhancedPathSpy = jest
|
|
1676
|
+
.spyOn(envUtils, 'getEnhancedPath')
|
|
1677
|
+
.mockReturnValue('/usr/bin');
|
|
1678
|
+
const plugin = createMockPlugin({
|
|
1679
|
+
settings: {
|
|
1680
|
+
...createMockPlugin().settings,
|
|
1681
|
+
providerConfigs: {
|
|
1682
|
+
claude: { enableBangBash: true },
|
|
1683
|
+
codex: { enabled: true },
|
|
1684
|
+
},
|
|
1685
|
+
},
|
|
1686
|
+
});
|
|
1687
|
+
const options = createMockOptions({ plugin });
|
|
1688
|
+
const tab = createTab(options);
|
|
1689
|
+
|
|
1690
|
+
initializeTabUI(tab, plugin);
|
|
1691
|
+
|
|
1692
|
+
expect(tab.ui.bangBashModeManager).toBeDefined();
|
|
1693
|
+
|
|
1694
|
+
getEnhancedPathSpy.mockRestore();
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
it('should wire MCP server selector to MCP service', () => {
|
|
1698
|
+
const options = createMockOptions();
|
|
1699
|
+
const tab = createTab(options);
|
|
1700
|
+
|
|
1701
|
+
initializeTabUI(tab, options.plugin);
|
|
1702
|
+
|
|
1703
|
+
expect(mockMcpServerSelector.setMcpManager).toHaveBeenCalledWith((options.plugin as any).mcpManager);
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
it('should wire external context selector onChange', () => {
|
|
1707
|
+
const options = createMockOptions();
|
|
1708
|
+
const tab = createTab(options);
|
|
1709
|
+
|
|
1710
|
+
initializeTabUI(tab, options.plugin);
|
|
1711
|
+
|
|
1712
|
+
expect(mockExternalContextSelector.setOnChange).toHaveBeenCalled();
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
it('should initialize persistent paths from settings', () => {
|
|
1716
|
+
const plugin = createMockPlugin({
|
|
1717
|
+
settings: {
|
|
1718
|
+
...createMockPlugin().settings,
|
|
1719
|
+
persistentExternalContextPaths: ['/path/1', '/path/2'],
|
|
1720
|
+
},
|
|
1721
|
+
});
|
|
1722
|
+
const options = createMockOptions({ plugin });
|
|
1723
|
+
const tab = createTab(options);
|
|
1724
|
+
|
|
1725
|
+
initializeTabUI(tab, plugin);
|
|
1726
|
+
|
|
1727
|
+
expect(mockExternalContextSelector.setPersistentPaths).toHaveBeenCalledWith(['/path/1', '/path/2']);
|
|
1728
|
+
});
|
|
1729
|
+
|
|
1730
|
+
it('should update ChatState callbacks for UI updates', () => {
|
|
1731
|
+
const options = createMockOptions();
|
|
1732
|
+
const tab = createTab(options);
|
|
1733
|
+
|
|
1734
|
+
initializeTabUI(tab, options.plugin);
|
|
1735
|
+
|
|
1736
|
+
// Verify callbacks are set by checking the state
|
|
1737
|
+
expect(tab.state.callbacks.onUsageChanged).toBeDefined();
|
|
1738
|
+
expect(tab.state.callbacks.onTodosChanged).toBeDefined();
|
|
1739
|
+
});
|
|
1740
|
+
});
|
|
1741
|
+
});
|
|
1742
|
+
|
|
1743
|
+
describe('Tab - Controller Initialization', () => {
|
|
1744
|
+
beforeEach(() => {
|
|
1745
|
+
jest.clearAllMocks();
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
describe('initializeTabControllers', () => {
|
|
1749
|
+
it('should create MessageRenderer', () => {
|
|
1750
|
+
const options = createMockOptions();
|
|
1751
|
+
const tab = createTab(options);
|
|
1752
|
+
const mockComponent = {} as any;
|
|
1753
|
+
|
|
1754
|
+
initializeTabUI(tab, options.plugin);
|
|
1755
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
1756
|
+
|
|
1757
|
+
expect(tab.renderer).toBeDefined();
|
|
1758
|
+
});
|
|
1759
|
+
|
|
1760
|
+
it('should create SelectionController', () => {
|
|
1761
|
+
const options = createMockOptions();
|
|
1762
|
+
const tab = createTab(options);
|
|
1763
|
+
const mockComponent = {} as any;
|
|
1764
|
+
|
|
1765
|
+
initializeTabUI(tab, options.plugin);
|
|
1766
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
1767
|
+
|
|
1768
|
+
expect(tab.controllers.selectionController).toBeDefined();
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
it('should include shared view controls in the selection focus scope', () => {
|
|
1772
|
+
const options = createMockOptions();
|
|
1773
|
+
const tab = createTab(options);
|
|
1774
|
+
const sharedFocusScopeEl = createMockEl();
|
|
1775
|
+
const mockComponent = {
|
|
1776
|
+
getSharedSelectionFocusScopeEls: jest.fn(() => [sharedFocusScopeEl]),
|
|
1777
|
+
} as any;
|
|
1778
|
+
|
|
1779
|
+
initializeTabUI(tab, options.plugin);
|
|
1780
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
1781
|
+
|
|
1782
|
+
expect(SelectionController).toHaveBeenCalledWith(
|
|
1783
|
+
options.plugin.app,
|
|
1784
|
+
tab.dom.selectionIndicatorEl,
|
|
1785
|
+
tab.dom.inputEl,
|
|
1786
|
+
tab.dom.contextRowEl,
|
|
1787
|
+
expect.any(Function),
|
|
1788
|
+
[tab.dom.contentEl, tab.dom.inputComposerEl, sharedFocusScopeEl],
|
|
1789
|
+
);
|
|
1790
|
+
});
|
|
1791
|
+
|
|
1792
|
+
it('should create StreamController', () => {
|
|
1793
|
+
const options = createMockOptions();
|
|
1794
|
+
const tab = createTab(options);
|
|
1795
|
+
const mockComponent = {} as any;
|
|
1796
|
+
|
|
1797
|
+
initializeTabUI(tab, options.plugin);
|
|
1798
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
1799
|
+
|
|
1800
|
+
expect(tab.controllers.streamController).toBeDefined();
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
it('should create ConversationController', () => {
|
|
1804
|
+
const options = createMockOptions();
|
|
1805
|
+
const tab = createTab(options);
|
|
1806
|
+
const mockComponent = {} as any;
|
|
1807
|
+
|
|
1808
|
+
initializeTabUI(tab, options.plugin);
|
|
1809
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
1810
|
+
|
|
1811
|
+
expect(tab.controllers.conversationController).toBeDefined();
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
it('should forward rewind mode from renderer to ConversationController', async () => {
|
|
1815
|
+
const options = createMockOptions();
|
|
1816
|
+
const tab = createTab(options);
|
|
1817
|
+
const mockComponent = {} as any;
|
|
1818
|
+
|
|
1819
|
+
initializeTabUI(tab, options.plugin);
|
|
1820
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
1821
|
+
|
|
1822
|
+
const { MessageRenderer } = jest.requireMock('@/features/chat/rendering/MessageRenderer') as { MessageRenderer: jest.Mock };
|
|
1823
|
+
const lastCall = MessageRenderer.mock.calls[MessageRenderer.mock.calls.length - 1];
|
|
1824
|
+
const rewindCallback = lastCall[3];
|
|
1825
|
+
|
|
1826
|
+
await rewindCallback('message-1', 'conversation');
|
|
1827
|
+
|
|
1828
|
+
expect(mockConversationController.rewind).toHaveBeenCalledWith('message-1', 'conversation');
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
it('should create InputController', () => {
|
|
1832
|
+
const options = createMockOptions();
|
|
1833
|
+
const tab = createTab(options);
|
|
1834
|
+
const mockComponent = {} as any;
|
|
1835
|
+
|
|
1836
|
+
initializeTabUI(tab, options.plugin);
|
|
1837
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
1838
|
+
|
|
1839
|
+
expect(tab.controllers.inputController).toBeDefined();
|
|
1840
|
+
});
|
|
1841
|
+
|
|
1842
|
+
it('should create and initialize NavigationController', () => {
|
|
1843
|
+
const options = createMockOptions();
|
|
1844
|
+
const tab = createTab(options);
|
|
1845
|
+
const mockComponent = {} as any;
|
|
1846
|
+
|
|
1847
|
+
initializeTabUI(tab, options.plugin);
|
|
1848
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
1849
|
+
|
|
1850
|
+
expect(tab.controllers.navigationController).toBeDefined();
|
|
1851
|
+
expect(mockNavigationController.initialize).toHaveBeenCalled();
|
|
1852
|
+
});
|
|
1853
|
+
|
|
1854
|
+
it('should update SubagentManager with StreamController callback', () => {
|
|
1855
|
+
const options = createMockOptions();
|
|
1856
|
+
const tab = createTab(options);
|
|
1857
|
+
const mockComponent = {} as any;
|
|
1858
|
+
|
|
1859
|
+
initializeTabUI(tab, options.plugin);
|
|
1860
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
1861
|
+
|
|
1862
|
+
// The subagent manager should have its callback set
|
|
1863
|
+
expect(tab.services.subagentManager).toBeDefined();
|
|
1864
|
+
});
|
|
1865
|
+
|
|
1866
|
+
it('persists async subagent state changes when not streaming', async () => {
|
|
1867
|
+
const options = createMockOptions();
|
|
1868
|
+
const tab = createTab(options);
|
|
1869
|
+
const mockComponent = {} as any;
|
|
1870
|
+
|
|
1871
|
+
initializeTabUI(tab, options.plugin);
|
|
1872
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
1873
|
+
|
|
1874
|
+
tab.state.currentConversationId = 'conv-1';
|
|
1875
|
+
tab.state.isStreaming = false;
|
|
1876
|
+
|
|
1877
|
+
const setCallback = tab.services.subagentManager.setCallback as jest.Mock;
|
|
1878
|
+
const callback = setCallback.mock.calls[0][0] as (subagent: any) => void;
|
|
1879
|
+
|
|
1880
|
+
callback({
|
|
1881
|
+
id: 'task-1',
|
|
1882
|
+
description: 'Background task',
|
|
1883
|
+
mode: 'async',
|
|
1884
|
+
asyncStatus: 'completed',
|
|
1885
|
+
status: 'completed',
|
|
1886
|
+
prompt: 'do work',
|
|
1887
|
+
result: 'done',
|
|
1888
|
+
toolCalls: [],
|
|
1889
|
+
isExpanded: false,
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
// Wait one microtask so Promise chain from save(false) can run.
|
|
1893
|
+
await Promise.resolve();
|
|
1894
|
+
|
|
1895
|
+
expect(mockStreamController.onAsyncSubagentStateChange).toHaveBeenCalled();
|
|
1896
|
+
expect(mockConversationController.save).toHaveBeenCalledWith(false);
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
it('does not persist async subagent state while main stream is active', async () => {
|
|
1900
|
+
const options = createMockOptions();
|
|
1901
|
+
const tab = createTab(options);
|
|
1902
|
+
const mockComponent = {} as any;
|
|
1903
|
+
|
|
1904
|
+
initializeTabUI(tab, options.plugin);
|
|
1905
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
1906
|
+
|
|
1907
|
+
tab.state.currentConversationId = 'conv-1';
|
|
1908
|
+
tab.state.isStreaming = true;
|
|
1909
|
+
|
|
1910
|
+
const setCallback = tab.services.subagentManager.setCallback as jest.Mock;
|
|
1911
|
+
const callback = setCallback.mock.calls[0][0] as (subagent: any) => void;
|
|
1912
|
+
|
|
1913
|
+
callback({
|
|
1914
|
+
id: 'task-1',
|
|
1915
|
+
description: 'Background task',
|
|
1916
|
+
mode: 'async',
|
|
1917
|
+
asyncStatus: 'running',
|
|
1918
|
+
status: 'running',
|
|
1919
|
+
toolCalls: [],
|
|
1920
|
+
isExpanded: false,
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
await Promise.resolve();
|
|
1924
|
+
|
|
1925
|
+
expect(mockConversationController.save).not.toHaveBeenCalled();
|
|
1926
|
+
});
|
|
1927
|
+
});
|
|
1928
|
+
});
|
|
1929
|
+
|
|
1930
|
+
describe('Tab - Event Handler Behavior', () => {
|
|
1931
|
+
beforeEach(() => {
|
|
1932
|
+
jest.clearAllMocks();
|
|
1933
|
+
Platform.isMacOS = true;
|
|
1934
|
+
mockFileContextManager = createMockFileContextManager();
|
|
1935
|
+
mockSlashCommandDropdown = createMockSlashCommandDropdown();
|
|
1936
|
+
mockInstructionModeManager = createMockInstructionModeManager();
|
|
1937
|
+
mockBangBashModeManager = createMockBangBashModeManager();
|
|
1938
|
+
mockInputController = createMockInputController();
|
|
1939
|
+
mockSelectionController = createMockSelectionController();
|
|
1940
|
+
});
|
|
1941
|
+
|
|
1942
|
+
// Wire up a tab with all UI managers and controllers needed for keydown tests,
|
|
1943
|
+
// then return the tab + a helper to fire keydown events.
|
|
1944
|
+
function setupKeydownTab(overrides?: {
|
|
1945
|
+
bangBashManager?: typeof mockBangBashModeManager;
|
|
1946
|
+
}) {
|
|
1947
|
+
const options = createMockOptions();
|
|
1948
|
+
const tab = createTab(options);
|
|
1949
|
+
|
|
1950
|
+
tab.ui.bangBashModeManager = (overrides?.bangBashManager ?? mockBangBashModeManager) as any;
|
|
1951
|
+
tab.ui.instructionModeManager = mockInstructionModeManager as any;
|
|
1952
|
+
tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any;
|
|
1953
|
+
tab.ui.fileContextManager = mockFileContextManager as any;
|
|
1954
|
+
tab.controllers.inputController = mockInputController as any;
|
|
1955
|
+
tab.controllers.selectionController = mockSelectionController as any;
|
|
1956
|
+
|
|
1957
|
+
wireTabInputEvents(tab, options.plugin);
|
|
1958
|
+
|
|
1959
|
+
const listeners = (tab.dom.inputEl as any).getEventListeners();
|
|
1960
|
+
const fireKeydown = (event: Record<string, any>) => listeners.get('keydown')[0](event);
|
|
1961
|
+
|
|
1962
|
+
return { tab, options, listeners, fireKeydown };
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
describe('wireTabInputEvents - keydown handlers', () => {
|
|
1966
|
+
it('should not pass keydown events to other handlers when bang-bash mode is active', () => {
|
|
1967
|
+
const options = createMockOptions();
|
|
1968
|
+
const tab = createTab(options);
|
|
1969
|
+
|
|
1970
|
+
tab.ui.bangBashModeManager = mockBangBashModeManager as any;
|
|
1971
|
+
tab.ui.instructionModeManager = mockInstructionModeManager as any;
|
|
1972
|
+
tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any;
|
|
1973
|
+
tab.ui.fileContextManager = mockFileContextManager as any;
|
|
1974
|
+
tab.controllers.inputController = mockInputController as any;
|
|
1975
|
+
tab.controllers.selectionController = mockSelectionController as any;
|
|
1976
|
+
|
|
1977
|
+
mockBangBashModeManager.isActive.mockReturnValue(true);
|
|
1978
|
+
|
|
1979
|
+
wireTabInputEvents(tab, options.plugin);
|
|
1980
|
+
|
|
1981
|
+
const listeners = (tab.dom.inputEl as any).getEventListeners();
|
|
1982
|
+
const keydownHandler = listeners.get('keydown')[0];
|
|
1983
|
+
const event = { key: '#', preventDefault: jest.fn() };
|
|
1984
|
+
keydownHandler(event);
|
|
1985
|
+
|
|
1986
|
+
expect(mockBangBashModeManager.handleKeydown).toHaveBeenCalled();
|
|
1987
|
+
expect(mockInstructionModeManager.handleTriggerKey).not.toHaveBeenCalled();
|
|
1988
|
+
expect(mockSlashCommandDropdown.handleKeydown).not.toHaveBeenCalled();
|
|
1989
|
+
expect(mockFileContextManager.handleMentionKeydown).not.toHaveBeenCalled();
|
|
1990
|
+
});
|
|
1991
|
+
|
|
1992
|
+
it('should suppress slash dropdown and mention handling on bang-bash enter/exit', () => {
|
|
1993
|
+
const options = createMockOptions();
|
|
1994
|
+
const tab = createTab(options);
|
|
1995
|
+
|
|
1996
|
+
let active = false;
|
|
1997
|
+
tab.ui.bangBashModeManager = {
|
|
1998
|
+
isActive: jest.fn(() => active),
|
|
1999
|
+
handleTriggerKey: jest.fn((e: any) => {
|
|
2000
|
+
active = true;
|
|
2001
|
+
e.preventDefault();
|
|
2002
|
+
return true;
|
|
2003
|
+
}),
|
|
2004
|
+
handleKeydown: jest.fn((e: any) => {
|
|
2005
|
+
if (!active) return false;
|
|
2006
|
+
if (e.key === 'Escape') {
|
|
2007
|
+
active = false;
|
|
2008
|
+
e.preventDefault();
|
|
2009
|
+
return true;
|
|
2010
|
+
}
|
|
2011
|
+
return false;
|
|
2012
|
+
}),
|
|
2013
|
+
handleInputChange: jest.fn(),
|
|
2014
|
+
destroy: jest.fn(),
|
|
2015
|
+
} as any;
|
|
2016
|
+
|
|
2017
|
+
tab.ui.instructionModeManager = mockInstructionModeManager as any;
|
|
2018
|
+
tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any;
|
|
2019
|
+
tab.ui.fileContextManager = mockFileContextManager as any;
|
|
2020
|
+
tab.controllers.inputController = mockInputController as any;
|
|
2021
|
+
tab.controllers.selectionController = mockSelectionController as any;
|
|
2022
|
+
|
|
2023
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2024
|
+
mockInstructionModeManager.handleKeydown.mockReturnValue(false);
|
|
2025
|
+
|
|
2026
|
+
wireTabInputEvents(tab, options.plugin);
|
|
2027
|
+
|
|
2028
|
+
const listeners = (tab.dom.inputEl as any).getEventListeners();
|
|
2029
|
+
const keydownHandler = listeners.get('keydown')[0];
|
|
2030
|
+
|
|
2031
|
+
keydownHandler({ key: '!', preventDefault: jest.fn() });
|
|
2032
|
+
expect(mockSlashCommandDropdown.setEnabled).toHaveBeenCalledWith(false);
|
|
2033
|
+
expect(mockFileContextManager.hideMentionDropdown).toHaveBeenCalled();
|
|
2034
|
+
|
|
2035
|
+
keydownHandler({ key: 'Escape', preventDefault: jest.fn() });
|
|
2036
|
+
expect(mockSlashCommandDropdown.setEnabled).toHaveBeenCalledWith(true);
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
it('should handle instruction mode trigger key', () => {
|
|
2040
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValueOnce(true);
|
|
2041
|
+
const { fireKeydown } = setupKeydownTab();
|
|
2042
|
+
|
|
2043
|
+
fireKeydown({ key: '#', preventDefault: jest.fn() });
|
|
2044
|
+
|
|
2045
|
+
expect(mockInstructionModeManager.handleTriggerKey).toHaveBeenCalled();
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
it('should handle instruction mode keydown', () => {
|
|
2049
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2050
|
+
mockInstructionModeManager.handleKeydown.mockReturnValueOnce(true);
|
|
2051
|
+
const { fireKeydown } = setupKeydownTab();
|
|
2052
|
+
|
|
2053
|
+
fireKeydown({ key: 'Tab', preventDefault: jest.fn() });
|
|
2054
|
+
|
|
2055
|
+
expect(mockInstructionModeManager.handleKeydown).toHaveBeenCalled();
|
|
2056
|
+
});
|
|
2057
|
+
|
|
2058
|
+
it('should handle slash command dropdown keydown', () => {
|
|
2059
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2060
|
+
mockInstructionModeManager.handleKeydown.mockReturnValue(false);
|
|
2061
|
+
mockSlashCommandDropdown.handleKeydown.mockReturnValueOnce(true);
|
|
2062
|
+
const { fireKeydown } = setupKeydownTab();
|
|
2063
|
+
|
|
2064
|
+
fireKeydown({ key: 'ArrowDown', preventDefault: jest.fn() });
|
|
2065
|
+
|
|
2066
|
+
expect(mockSlashCommandDropdown.handleKeydown).toHaveBeenCalled();
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
it('should let explicit Command+Enter send before slash dropdown handles Enter', () => {
|
|
2070
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2071
|
+
mockInstructionModeManager.handleKeydown.mockReturnValue(false);
|
|
2072
|
+
mockSlashCommandDropdown.handleKeydown.mockReturnValue(true);
|
|
2073
|
+
mockFileContextManager.handleMentionKeydown.mockReturnValue(false);
|
|
2074
|
+
const { fireKeydown } = setupKeydownTab();
|
|
2075
|
+
Platform.isMacOS = true;
|
|
2076
|
+
|
|
2077
|
+
const event = {
|
|
2078
|
+
key: 'Enter',
|
|
2079
|
+
shiftKey: false,
|
|
2080
|
+
ctrlKey: false,
|
|
2081
|
+
metaKey: true,
|
|
2082
|
+
altKey: false,
|
|
2083
|
+
isComposing: false,
|
|
2084
|
+
preventDefault: jest.fn(),
|
|
2085
|
+
};
|
|
2086
|
+
fireKeydown(event);
|
|
2087
|
+
|
|
2088
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
2089
|
+
expect(mockInputController.sendMessage).toHaveBeenCalled();
|
|
2090
|
+
expect(mockSlashCommandDropdown.handleKeydown).not.toHaveBeenCalled();
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
it('should keep plain Enter routed to visible slash dropdown before sending', () => {
|
|
2094
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2095
|
+
mockInstructionModeManager.handleKeydown.mockReturnValue(false);
|
|
2096
|
+
mockSlashCommandDropdown.handleKeydown.mockReturnValue(true);
|
|
2097
|
+
mockFileContextManager.handleMentionKeydown.mockReturnValue(false);
|
|
2098
|
+
const { fireKeydown } = setupKeydownTab();
|
|
2099
|
+
|
|
2100
|
+
const event = {
|
|
2101
|
+
key: 'Enter',
|
|
2102
|
+
shiftKey: false,
|
|
2103
|
+
ctrlKey: false,
|
|
2104
|
+
metaKey: false,
|
|
2105
|
+
altKey: false,
|
|
2106
|
+
isComposing: false,
|
|
2107
|
+
preventDefault: jest.fn(),
|
|
2108
|
+
};
|
|
2109
|
+
fireKeydown(event);
|
|
2110
|
+
|
|
2111
|
+
expect(mockSlashCommandDropdown.handleKeydown).toHaveBeenCalled();
|
|
2112
|
+
expect(mockInputController.sendMessage).not.toHaveBeenCalled();
|
|
2113
|
+
});
|
|
2114
|
+
|
|
2115
|
+
it('should handle resume dropdown keydown', () => {
|
|
2116
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2117
|
+
mockInstructionModeManager.handleKeydown.mockReturnValue(false);
|
|
2118
|
+
mockInputController.handleResumeKeydown.mockReturnValueOnce(true);
|
|
2119
|
+
const { fireKeydown } = setupKeydownTab();
|
|
2120
|
+
|
|
2121
|
+
fireKeydown({ key: 'ArrowDown', preventDefault: jest.fn() });
|
|
2122
|
+
|
|
2123
|
+
expect(mockInputController.handleResumeKeydown).toHaveBeenCalled();
|
|
2124
|
+
expect(mockSlashCommandDropdown.handleKeydown).not.toHaveBeenCalled();
|
|
2125
|
+
expect(mockFileContextManager.handleMentionKeydown).not.toHaveBeenCalled();
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2128
|
+
it('should handle file context mention keydown', () => {
|
|
2129
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2130
|
+
mockInstructionModeManager.handleKeydown.mockReturnValue(false);
|
|
2131
|
+
mockSlashCommandDropdown.handleKeydown.mockReturnValue(false);
|
|
2132
|
+
mockFileContextManager.handleMentionKeydown.mockReturnValueOnce(true);
|
|
2133
|
+
const { fireKeydown } = setupKeydownTab();
|
|
2134
|
+
|
|
2135
|
+
fireKeydown({ key: 'ArrowUp', preventDefault: jest.fn() });
|
|
2136
|
+
|
|
2137
|
+
expect(mockFileContextManager.handleMentionKeydown).toHaveBeenCalled();
|
|
2138
|
+
});
|
|
2139
|
+
|
|
2140
|
+
it('should cancel streaming on Escape when streaming', () => {
|
|
2141
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2142
|
+
mockInstructionModeManager.handleKeydown.mockReturnValue(false);
|
|
2143
|
+
mockSlashCommandDropdown.handleKeydown.mockReturnValue(false);
|
|
2144
|
+
mockFileContextManager.handleMentionKeydown.mockReturnValue(false);
|
|
2145
|
+
const { tab, fireKeydown } = setupKeydownTab();
|
|
2146
|
+
tab.state.isStreaming = true;
|
|
2147
|
+
|
|
2148
|
+
const event = { key: 'Escape', isComposing: false, preventDefault: jest.fn() };
|
|
2149
|
+
fireKeydown(event);
|
|
2150
|
+
|
|
2151
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
2152
|
+
expect(mockInputController.cancelStreaming).toHaveBeenCalled();
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
it('should not cancel streaming on Escape when isComposing (IME)', () => {
|
|
2156
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2157
|
+
mockInstructionModeManager.handleKeydown.mockReturnValue(false);
|
|
2158
|
+
mockSlashCommandDropdown.handleKeydown.mockReturnValue(false);
|
|
2159
|
+
mockFileContextManager.handleMentionKeydown.mockReturnValue(false);
|
|
2160
|
+
const { tab, fireKeydown } = setupKeydownTab();
|
|
2161
|
+
tab.state.isStreaming = true;
|
|
2162
|
+
|
|
2163
|
+
const event = { key: 'Escape', isComposing: true, preventDefault: jest.fn() };
|
|
2164
|
+
fireKeydown(event);
|
|
2165
|
+
|
|
2166
|
+
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
2167
|
+
expect(mockInputController.cancelStreaming).not.toHaveBeenCalled();
|
|
2168
|
+
});
|
|
2169
|
+
|
|
2170
|
+
it('should send message on Enter (without Shift)', () => {
|
|
2171
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2172
|
+
mockInstructionModeManager.handleKeydown.mockReturnValue(false);
|
|
2173
|
+
mockSlashCommandDropdown.handleKeydown.mockReturnValue(false);
|
|
2174
|
+
mockFileContextManager.handleMentionKeydown.mockReturnValue(false);
|
|
2175
|
+
const { fireKeydown } = setupKeydownTab();
|
|
2176
|
+
|
|
2177
|
+
const event = { key: 'Enter', shiftKey: false, isComposing: false, preventDefault: jest.fn() };
|
|
2178
|
+
fireKeydown(event);
|
|
2179
|
+
|
|
2180
|
+
expect(event.preventDefault).toHaveBeenCalled();
|
|
2181
|
+
expect(mockInputController.sendMessage).toHaveBeenCalled();
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
it('should not send message on Shift+Enter (newline)', () => {
|
|
2185
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2186
|
+
mockInstructionModeManager.handleKeydown.mockReturnValue(false);
|
|
2187
|
+
mockSlashCommandDropdown.handleKeydown.mockReturnValue(false);
|
|
2188
|
+
mockFileContextManager.handleMentionKeydown.mockReturnValue(false);
|
|
2189
|
+
const { fireKeydown } = setupKeydownTab();
|
|
2190
|
+
|
|
2191
|
+
const event = { key: 'Enter', shiftKey: true, isComposing: false, preventDefault: jest.fn() };
|
|
2192
|
+
fireKeydown(event);
|
|
2193
|
+
|
|
2194
|
+
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
2195
|
+
expect(mockInputController.sendMessage).not.toHaveBeenCalled();
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
it('should require Command+Enter on macOS when the send shortcut setting is enabled', () => {
|
|
2199
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2200
|
+
mockInstructionModeManager.handleKeydown.mockReturnValue(false);
|
|
2201
|
+
mockSlashCommandDropdown.handleKeydown.mockReturnValue(false);
|
|
2202
|
+
mockFileContextManager.handleMentionKeydown.mockReturnValue(false);
|
|
2203
|
+
const { options, fireKeydown } = setupKeydownTab();
|
|
2204
|
+
Platform.isMacOS = true;
|
|
2205
|
+
options.plugin.settings.requireCommandOrControlEnterToSend = true;
|
|
2206
|
+
|
|
2207
|
+
const enterEvent = { key: 'Enter', shiftKey: false, ctrlKey: false, metaKey: false, isComposing: false, preventDefault: jest.fn() };
|
|
2208
|
+
fireKeydown(enterEvent);
|
|
2209
|
+
|
|
2210
|
+
expect(enterEvent.preventDefault).not.toHaveBeenCalled();
|
|
2211
|
+
expect(mockInputController.sendMessage).not.toHaveBeenCalled();
|
|
2212
|
+
|
|
2213
|
+
const controlEnterEvent = { key: 'Enter', shiftKey: false, ctrlKey: true, metaKey: false, isComposing: false, preventDefault: jest.fn() };
|
|
2214
|
+
fireKeydown(controlEnterEvent);
|
|
2215
|
+
|
|
2216
|
+
expect(controlEnterEvent.preventDefault).not.toHaveBeenCalled();
|
|
2217
|
+
expect(mockInputController.sendMessage).not.toHaveBeenCalled();
|
|
2218
|
+
|
|
2219
|
+
const commandEnterEvent = { key: 'Enter', shiftKey: false, ctrlKey: false, metaKey: true, isComposing: false, preventDefault: jest.fn() };
|
|
2220
|
+
fireKeydown(commandEnterEvent);
|
|
2221
|
+
|
|
2222
|
+
expect(commandEnterEvent.preventDefault).toHaveBeenCalled();
|
|
2223
|
+
expect(mockInputController.sendMessage).toHaveBeenCalled();
|
|
2224
|
+
});
|
|
2225
|
+
|
|
2226
|
+
it('should require Ctrl+Enter off macOS when the send shortcut setting is enabled', () => {
|
|
2227
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2228
|
+
mockInstructionModeManager.handleKeydown.mockReturnValue(false);
|
|
2229
|
+
mockSlashCommandDropdown.handleKeydown.mockReturnValue(false);
|
|
2230
|
+
mockFileContextManager.handleMentionKeydown.mockReturnValue(false);
|
|
2231
|
+
const { options, fireKeydown } = setupKeydownTab();
|
|
2232
|
+
Platform.isMacOS = false;
|
|
2233
|
+
options.plugin.settings.requireCommandOrControlEnterToSend = true;
|
|
2234
|
+
|
|
2235
|
+
const commandEnterEvent = { key: 'Enter', shiftKey: false, ctrlKey: false, metaKey: true, isComposing: false, preventDefault: jest.fn() };
|
|
2236
|
+
fireKeydown(commandEnterEvent);
|
|
2237
|
+
|
|
2238
|
+
expect(commandEnterEvent.preventDefault).not.toHaveBeenCalled();
|
|
2239
|
+
expect(mockInputController.sendMessage).not.toHaveBeenCalled();
|
|
2240
|
+
|
|
2241
|
+
const controlEnterEvent = { key: 'Enter', shiftKey: false, ctrlKey: true, metaKey: false, isComposing: false, preventDefault: jest.fn() };
|
|
2242
|
+
fireKeydown(controlEnterEvent);
|
|
2243
|
+
|
|
2244
|
+
expect(controlEnterEvent.preventDefault).toHaveBeenCalled();
|
|
2245
|
+
expect(mockInputController.sendMessage).toHaveBeenCalled();
|
|
2246
|
+
});
|
|
2247
|
+
|
|
2248
|
+
it('should not send message on Enter when isComposing (IME)', () => {
|
|
2249
|
+
mockInstructionModeManager.handleTriggerKey.mockReturnValue(false);
|
|
2250
|
+
mockInstructionModeManager.handleKeydown.mockReturnValue(false);
|
|
2251
|
+
mockSlashCommandDropdown.handleKeydown.mockReturnValue(false);
|
|
2252
|
+
mockFileContextManager.handleMentionKeydown.mockReturnValue(false);
|
|
2253
|
+
const { fireKeydown } = setupKeydownTab();
|
|
2254
|
+
|
|
2255
|
+
const event = { key: 'Enter', shiftKey: false, isComposing: true, preventDefault: jest.fn() };
|
|
2256
|
+
fireKeydown(event);
|
|
2257
|
+
|
|
2258
|
+
expect(event.preventDefault).not.toHaveBeenCalled();
|
|
2259
|
+
expect(mockInputController.sendMessage).not.toHaveBeenCalled();
|
|
2260
|
+
});
|
|
2261
|
+
});
|
|
2262
|
+
|
|
2263
|
+
describe('wireTabInputEvents - input handler', () => {
|
|
2264
|
+
it('should trigger file context input change', () => {
|
|
2265
|
+
const options = createMockOptions();
|
|
2266
|
+
const tab = createTab(options);
|
|
2267
|
+
|
|
2268
|
+
tab.ui.fileContextManager = mockFileContextManager as any;
|
|
2269
|
+
tab.ui.instructionModeManager = mockInstructionModeManager as any;
|
|
2270
|
+
tab.controllers.inputController = mockInputController as any;
|
|
2271
|
+
tab.controllers.selectionController = mockSelectionController as any;
|
|
2272
|
+
|
|
2273
|
+
wireTabInputEvents(tab, options.plugin);
|
|
2274
|
+
|
|
2275
|
+
const listeners = (tab.dom.inputEl as any).getEventListeners();
|
|
2276
|
+
const inputHandler = listeners.get('input')[0];
|
|
2277
|
+
inputHandler();
|
|
2278
|
+
|
|
2279
|
+
expect(mockFileContextManager.handleInputChange).toHaveBeenCalled();
|
|
2280
|
+
expect(mockInstructionModeManager.handleInputChange).toHaveBeenCalled();
|
|
2281
|
+
});
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
describe('wireTabInputEvents - input handlers', () => {
|
|
2285
|
+
it('should not call FileContextManager.handleInputChange when bang-bash mode is active', () => {
|
|
2286
|
+
const options = createMockOptions();
|
|
2287
|
+
const tab = createTab(options);
|
|
2288
|
+
|
|
2289
|
+
tab.ui.bangBashModeManager = mockBangBashModeManager as any;
|
|
2290
|
+
tab.ui.instructionModeManager = mockInstructionModeManager as any;
|
|
2291
|
+
tab.ui.slashCommandDropdown = mockSlashCommandDropdown as any;
|
|
2292
|
+
tab.ui.fileContextManager = mockFileContextManager as any;
|
|
2293
|
+
|
|
2294
|
+
mockBangBashModeManager.isActive.mockReturnValue(true);
|
|
2295
|
+
|
|
2296
|
+
wireTabInputEvents(tab, options.plugin);
|
|
2297
|
+
|
|
2298
|
+
const listeners = (tab.dom.inputEl as any).getEventListeners();
|
|
2299
|
+
const inputHandler = listeners.get('input')[0];
|
|
2300
|
+
inputHandler();
|
|
2301
|
+
|
|
2302
|
+
expect(mockFileContextManager.handleInputChange).not.toHaveBeenCalled();
|
|
2303
|
+
expect(mockBangBashModeManager.handleInputChange).toHaveBeenCalled();
|
|
2304
|
+
});
|
|
2305
|
+
});
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
describe('Tab - ChatState Callback Integration', () => {
|
|
2309
|
+
beforeEach(() => {
|
|
2310
|
+
jest.clearAllMocks();
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
it('should invoke onStreamingChanged callback when streaming state changes', () => {
|
|
2314
|
+
const onStreamingChanged = jest.fn();
|
|
2315
|
+
const options = createMockOptions({ onStreamingChanged });
|
|
2316
|
+
const tab = createTab(options);
|
|
2317
|
+
|
|
2318
|
+
// Trigger the callback through ChatState
|
|
2319
|
+
tab.state.callbacks.onStreamingStateChanged?.(true);
|
|
2320
|
+
|
|
2321
|
+
expect(onStreamingChanged).toHaveBeenCalledWith(true);
|
|
2322
|
+
});
|
|
2323
|
+
|
|
2324
|
+
it('should invoke onAttentionChanged callback when attention state changes', () => {
|
|
2325
|
+
const onAttentionChanged = jest.fn();
|
|
2326
|
+
const options = createMockOptions({ onAttentionChanged });
|
|
2327
|
+
const tab = createTab(options);
|
|
2328
|
+
|
|
2329
|
+
// Trigger the callback through ChatState
|
|
2330
|
+
tab.state.callbacks.onAttentionChanged?.(true);
|
|
2331
|
+
|
|
2332
|
+
expect(onAttentionChanged).toHaveBeenCalledWith(true);
|
|
2333
|
+
});
|
|
2334
|
+
|
|
2335
|
+
it('should invoke onConversationIdChanged callback when conversation changes', () => {
|
|
2336
|
+
const onConversationIdChanged = jest.fn();
|
|
2337
|
+
const options = createMockOptions({ onConversationIdChanged });
|
|
2338
|
+
const tab = createTab(options);
|
|
2339
|
+
|
|
2340
|
+
// Trigger the callback through ChatState
|
|
2341
|
+
tab.state.callbacks.onConversationChanged?.('new-conv-id');
|
|
2342
|
+
|
|
2343
|
+
expect(onConversationIdChanged).toHaveBeenCalledWith('new-conv-id');
|
|
2344
|
+
});
|
|
2345
|
+
});
|
|
2346
|
+
|
|
2347
|
+
describe('Tab - UI Callback Wiring', () => {
|
|
2348
|
+
beforeEach(() => {
|
|
2349
|
+
jest.clearAllMocks();
|
|
2350
|
+
});
|
|
2351
|
+
|
|
2352
|
+
describe('initializeTabUI callbacks', () => {
|
|
2353
|
+
it('should wire onChipsChanged to scroll to bottom', () => {
|
|
2354
|
+
const options = createMockOptions();
|
|
2355
|
+
const tab = createTab(options);
|
|
2356
|
+
|
|
2357
|
+
// Initialize UI to wire callbacks
|
|
2358
|
+
initializeTabUI(tab, options.plugin);
|
|
2359
|
+
|
|
2360
|
+
// Set up renderer
|
|
2361
|
+
tab.renderer = mockMessageRenderer as any;
|
|
2362
|
+
|
|
2363
|
+
// Get the FileContextManager constructor call arguments
|
|
2364
|
+
const { FileContextManager } = jest.requireMock('@/features/chat/ui/FileContext');
|
|
2365
|
+
const constructorCall = FileContextManager.mock.calls[0];
|
|
2366
|
+
const callbacks = constructorCall[3]; // 4th argument is callbacks
|
|
2367
|
+
|
|
2368
|
+
// Trigger onChipsChanged callback
|
|
2369
|
+
callbacks.onChipsChanged();
|
|
2370
|
+
|
|
2371
|
+
expect(mockMessageRenderer.scrollToBottomIfNeeded).toHaveBeenCalled();
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
it('should wire onImagesChanged to scroll to bottom', () => {
|
|
2375
|
+
const options = createMockOptions();
|
|
2376
|
+
const tab = createTab(options);
|
|
2377
|
+
|
|
2378
|
+
initializeTabUI(tab, options.plugin);
|
|
2379
|
+
|
|
2380
|
+
tab.renderer = mockMessageRenderer as any;
|
|
2381
|
+
|
|
2382
|
+
// Get the ImageContextManager constructor call
|
|
2383
|
+
const { ImageContextManager } = jest.requireMock('@/features/chat/ui/ImageContext');
|
|
2384
|
+
const constructorCall = ImageContextManager.mock.calls[0];
|
|
2385
|
+
const callbacks = constructorCall[2]; // 3rd argument is callbacks (app parameter was removed)
|
|
2386
|
+
|
|
2387
|
+
callbacks.onImagesChanged();
|
|
2388
|
+
|
|
2389
|
+
expect(mockMessageRenderer.scrollToBottomIfNeeded).toHaveBeenCalled();
|
|
2390
|
+
});
|
|
2391
|
+
|
|
2392
|
+
it('should wire getExcludedTags to return plugin settings', () => {
|
|
2393
|
+
const plugin = createMockPlugin({
|
|
2394
|
+
settings: {
|
|
2395
|
+
...createMockPlugin().settings,
|
|
2396
|
+
excludedTags: ['tag1', 'tag2'],
|
|
2397
|
+
},
|
|
2398
|
+
});
|
|
2399
|
+
const options = createMockOptions({ plugin });
|
|
2400
|
+
const tab = createTab(options);
|
|
2401
|
+
|
|
2402
|
+
initializeTabUI(tab, plugin);
|
|
2403
|
+
|
|
2404
|
+
const { FileContextManager } = jest.requireMock('@/features/chat/ui/FileContext');
|
|
2405
|
+
const constructorCall = FileContextManager.mock.calls[0];
|
|
2406
|
+
const callbacks = constructorCall[3];
|
|
2407
|
+
|
|
2408
|
+
const excludedTags = callbacks.getExcludedTags();
|
|
2409
|
+
|
|
2410
|
+
expect(excludedTags).toEqual(['tag1', 'tag2']);
|
|
2411
|
+
});
|
|
2412
|
+
|
|
2413
|
+
it('should wire getExternalContexts to return external context selector contexts', () => {
|
|
2414
|
+
const options = createMockOptions();
|
|
2415
|
+
const tab = createTab(options);
|
|
2416
|
+
|
|
2417
|
+
initializeTabUI(tab, options.plugin);
|
|
2418
|
+
|
|
2419
|
+
// Mock external context selector return value
|
|
2420
|
+
mockExternalContextSelector.getExternalContexts.mockReturnValue(['/path/1', '/path/2']);
|
|
2421
|
+
|
|
2422
|
+
const { FileContextManager } = jest.requireMock('@/features/chat/ui/FileContext');
|
|
2423
|
+
const constructorCall = FileContextManager.mock.calls[0];
|
|
2424
|
+
const callbacks = constructorCall[3];
|
|
2425
|
+
|
|
2426
|
+
const contexts = callbacks.getExternalContexts();
|
|
2427
|
+
|
|
2428
|
+
expect(contexts).toEqual(['/path/1', '/path/2']);
|
|
2429
|
+
});
|
|
2430
|
+
|
|
2431
|
+
it('should wire MCP mention change to add servers to selector', () => {
|
|
2432
|
+
const options = createMockOptions();
|
|
2433
|
+
const tab = createTab(options);
|
|
2434
|
+
|
|
2435
|
+
initializeTabUI(tab, options.plugin);
|
|
2436
|
+
|
|
2437
|
+
// Get the setOnMcpMentionChange callback
|
|
2438
|
+
const onMcpMentionChange = mockFileContextManager.setOnMcpMentionChange.mock.calls[0][0];
|
|
2439
|
+
|
|
2440
|
+
// Trigger with server list
|
|
2441
|
+
onMcpMentionChange(['server1', 'server2']);
|
|
2442
|
+
|
|
2443
|
+
expect(mockMcpServerSelector.addMentionedServers).toHaveBeenCalledWith(['server1', 'server2']);
|
|
2444
|
+
});
|
|
2445
|
+
|
|
2446
|
+
it('should wire external context onChange to pre-scan contexts', () => {
|
|
2447
|
+
const options = createMockOptions();
|
|
2448
|
+
const tab = createTab(options);
|
|
2449
|
+
|
|
2450
|
+
initializeTabUI(tab, options.plugin);
|
|
2451
|
+
|
|
2452
|
+
// Get the setOnChange callback
|
|
2453
|
+
const onChange = mockExternalContextSelector.setOnChange.mock.calls[0][0];
|
|
2454
|
+
|
|
2455
|
+
// Trigger onChange
|
|
2456
|
+
onChange();
|
|
2457
|
+
|
|
2458
|
+
expect(mockFileContextManager.preScanExternalContexts).toHaveBeenCalled();
|
|
2459
|
+
});
|
|
2460
|
+
|
|
2461
|
+
it('should wire persistence change to save settings', async () => {
|
|
2462
|
+
const saveSettings = jest.fn().mockResolvedValue(undefined);
|
|
2463
|
+
const plugin = createMockPlugin({ saveSettings });
|
|
2464
|
+
const options = createMockOptions({ plugin });
|
|
2465
|
+
const tab = createTab(options);
|
|
2466
|
+
|
|
2467
|
+
initializeTabUI(tab, plugin);
|
|
2468
|
+
|
|
2469
|
+
// Get the setOnPersistenceChange callback
|
|
2470
|
+
const onPersistenceChange = mockExternalContextSelector.setOnPersistenceChange.mock.calls[0][0];
|
|
2471
|
+
|
|
2472
|
+
// Trigger with new paths
|
|
2473
|
+
await onPersistenceChange(['/new/path1', '/new/path2']);
|
|
2474
|
+
|
|
2475
|
+
expect(plugin.settings.persistentExternalContextPaths).toEqual(['/new/path1', '/new/path2']);
|
|
2476
|
+
expect(saveSettings).toHaveBeenCalled();
|
|
2477
|
+
});
|
|
2478
|
+
|
|
2479
|
+
it('should wire onUsageChanged callback to update context meter', () => {
|
|
2480
|
+
const options = createMockOptions();
|
|
2481
|
+
const tab = createTab(options);
|
|
2482
|
+
|
|
2483
|
+
initializeTabUI(tab, options.plugin);
|
|
2484
|
+
|
|
2485
|
+
// Verify callback is wired
|
|
2486
|
+
const usage = { inputTokens: 1000, outputTokens: 500 };
|
|
2487
|
+
tab.state.callbacks.onUsageChanged?.(usage as any);
|
|
2488
|
+
|
|
2489
|
+
expect(mockContextUsageMeter.update).toHaveBeenCalledWith(usage);
|
|
2490
|
+
});
|
|
2491
|
+
|
|
2492
|
+
it('should update context meter for Codex tabs on usage change', () => {
|
|
2493
|
+
const getCapabilitiesSpy = jest.spyOn(ProviderRegistry, 'getCapabilities');
|
|
2494
|
+
getCapabilitiesSpy.mockReturnValue({
|
|
2495
|
+
providerId: 'codex',
|
|
2496
|
+
supportsPersistentRuntime: true,
|
|
2497
|
+
supportsNativeHistory: true,
|
|
2498
|
+
supportsPlanMode: false,
|
|
2499
|
+
supportsRewind: false,
|
|
2500
|
+
supportsFork: false,
|
|
2501
|
+
supportsProviderCommands: false,
|
|
2502
|
+
supportsImageAttachments: true,
|
|
2503
|
+
supportsInstructionMode: false,
|
|
2504
|
+
supportsMcpTools: false,
|
|
2505
|
+
reasoningControl: 'none',
|
|
2506
|
+
});
|
|
2507
|
+
|
|
2508
|
+
const options = createMockOptions({
|
|
2509
|
+
conversation: {
|
|
2510
|
+
id: 'conv-codex',
|
|
2511
|
+
providerId: 'codex',
|
|
2512
|
+
title: 'Codex Conversation',
|
|
2513
|
+
messages: [],
|
|
2514
|
+
sessionId: null,
|
|
2515
|
+
createdAt: Date.now(),
|
|
2516
|
+
updatedAt: Date.now(),
|
|
2517
|
+
},
|
|
2518
|
+
});
|
|
2519
|
+
const tab = createTab(options);
|
|
2520
|
+
initializeTabUI(tab, options.plugin);
|
|
2521
|
+
|
|
2522
|
+
mockContextUsageMeter.update.mockClear();
|
|
2523
|
+
|
|
2524
|
+
const usage = {
|
|
2525
|
+
inputTokens: 5000,
|
|
2526
|
+
cacheCreationInputTokens: 0,
|
|
2527
|
+
cacheReadInputTokens: 1000,
|
|
2528
|
+
contextWindow: 200000,
|
|
2529
|
+
contextTokens: 6000,
|
|
2530
|
+
percentage: 3,
|
|
2531
|
+
};
|
|
2532
|
+
tab.state.callbacks.onUsageChanged?.(usage as any);
|
|
2533
|
+
|
|
2534
|
+
expect(mockContextUsageMeter.update).toHaveBeenCalledWith(usage);
|
|
2535
|
+
|
|
2536
|
+
getCapabilitiesSpy.mockRestore();
|
|
2537
|
+
});
|
|
2538
|
+
|
|
2539
|
+
it('should wire onTodosChanged callback to update todo panel', () => {
|
|
2540
|
+
const options = createMockOptions();
|
|
2541
|
+
const tab = createTab(options);
|
|
2542
|
+
|
|
2543
|
+
initializeTabUI(tab, options.plugin);
|
|
2544
|
+
|
|
2545
|
+
// Verify callback is wired
|
|
2546
|
+
const todos = [{ id: '1', content: 'Test todo', status: 'pending' }];
|
|
2547
|
+
tab.state.callbacks.onTodosChanged?.(todos as any);
|
|
2548
|
+
|
|
2549
|
+
expect(mockStatusPanel.updateTodos).toHaveBeenCalledWith(todos);
|
|
2550
|
+
});
|
|
2551
|
+
|
|
2552
|
+
it('should wire instruction mode onSubmit to input controller', async () => {
|
|
2553
|
+
const options = createMockOptions();
|
|
2554
|
+
const tab = createTab(options);
|
|
2555
|
+
const mockComponent = {} as any;
|
|
2556
|
+
|
|
2557
|
+
initializeTabUI(tab, options.plugin);
|
|
2558
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
2559
|
+
|
|
2560
|
+
// Get the InstructionModeManager constructor arguments
|
|
2561
|
+
const { InstructionModeManager } = jest.requireMock('@/features/chat/ui/InstructionModeManager');
|
|
2562
|
+
const constructorCall = InstructionModeManager.mock.calls[0];
|
|
2563
|
+
const callbacks = constructorCall[1]; // 2nd argument is callbacks
|
|
2564
|
+
|
|
2565
|
+
// Trigger onSubmit
|
|
2566
|
+
await callbacks.onSubmit('refined instruction');
|
|
2567
|
+
|
|
2568
|
+
expect(mockInputController.handleInstructionSubmit).toHaveBeenCalledWith('refined instruction');
|
|
2569
|
+
});
|
|
2570
|
+
|
|
2571
|
+
it('should wire getInputWrapper to return input wrapper element', () => {
|
|
2572
|
+
const options = createMockOptions();
|
|
2573
|
+
const tab = createTab(options);
|
|
2574
|
+
|
|
2575
|
+
initializeTabUI(tab, options.plugin);
|
|
2576
|
+
|
|
2577
|
+
const { InstructionModeManager } = jest.requireMock('@/features/chat/ui/InstructionModeManager');
|
|
2578
|
+
const constructorCall = InstructionModeManager.mock.calls[0];
|
|
2579
|
+
const callbacks = constructorCall[1];
|
|
2580
|
+
|
|
2581
|
+
const wrapper = callbacks.getInputWrapper();
|
|
2582
|
+
|
|
2583
|
+
expect(wrapper).toBe(tab.dom.inputWrapper);
|
|
2584
|
+
});
|
|
2585
|
+
|
|
2586
|
+
it('should wire provider catalog config when provided in options', async () => {
|
|
2587
|
+
const mockEntries = [{
|
|
2588
|
+
id: 'cmd-review',
|
|
2589
|
+
providerId: 'claude' as const,
|
|
2590
|
+
kind: 'command' as const,
|
|
2591
|
+
name: 'review',
|
|
2592
|
+
description: 'Review code',
|
|
2593
|
+
content: '',
|
|
2594
|
+
scope: 'vault' as const,
|
|
2595
|
+
source: 'user' as const,
|
|
2596
|
+
isEditable: true,
|
|
2597
|
+
isDeletable: true,
|
|
2598
|
+
displayPrefix: '/',
|
|
2599
|
+
insertPrefix: '/',
|
|
2600
|
+
}];
|
|
2601
|
+
const mockConfig = { providerId: 'claude' as const, triggerChars: ['/'], builtInPrefix: '/', skillPrefix: '/', commandPrefix: '/' };
|
|
2602
|
+
const plugin = createMockPlugin();
|
|
2603
|
+
const options = createMockOptions({ plugin });
|
|
2604
|
+
const tab = createTab(options);
|
|
2605
|
+
|
|
2606
|
+
initializeTabUI(tab, plugin, {
|
|
2607
|
+
getProviderCatalogConfig: () => ({
|
|
2608
|
+
config: mockConfig,
|
|
2609
|
+
getEntries: jest.fn().mockResolvedValue(mockEntries),
|
|
2610
|
+
}),
|
|
2611
|
+
});
|
|
2612
|
+
|
|
2613
|
+
const { SlashCommandDropdown } = jest.requireMock('@/shared/components/SlashCommandDropdown');
|
|
2614
|
+
const constructorCall = SlashCommandDropdown.mock.calls[0];
|
|
2615
|
+
const opts = constructorCall[3]; // 4th argument is options
|
|
2616
|
+
|
|
2617
|
+
expect(opts.providerConfig).toEqual(mockConfig);
|
|
2618
|
+
expect(typeof opts.getProviderEntries).toBe('function');
|
|
2619
|
+
});
|
|
2620
|
+
|
|
2621
|
+
it('should wire provider-scoped hidden commands into the slash dropdown', () => {
|
|
2622
|
+
const plugin = createMockPlugin({
|
|
2623
|
+
settings: {
|
|
2624
|
+
excludedTags: [],
|
|
2625
|
+
model: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
2626
|
+
thinkingBudget: 'low',
|
|
2627
|
+
effortLevel: 'high',
|
|
2628
|
+
permissionMode: 'yolo',
|
|
2629
|
+
keyboardNavigation: {
|
|
2630
|
+
scrollUpKey: 'k',
|
|
2631
|
+
scrollDownKey: 'j',
|
|
2632
|
+
focusInputKey: 'i',
|
|
2633
|
+
},
|
|
2634
|
+
persistentExternalContextPaths: [],
|
|
2635
|
+
settingsProvider: 'claude',
|
|
2636
|
+
codexEnabled: true,
|
|
2637
|
+
savedProviderModel: {
|
|
2638
|
+
claude: 'claude-sonnet-4-5',
|
|
2639
|
+
codex: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
2640
|
+
},
|
|
2641
|
+
savedProviderEffort: {
|
|
2642
|
+
claude: 'high',
|
|
2643
|
+
codex: 'medium',
|
|
2644
|
+
},
|
|
2645
|
+
savedProviderThinkingBudget: {
|
|
2646
|
+
claude: 'low',
|
|
2647
|
+
codex: 'off',
|
|
2648
|
+
},
|
|
2649
|
+
hiddenProviderCommands: {
|
|
2650
|
+
claude: ['commit'],
|
|
2651
|
+
codex: ['analyze'],
|
|
2652
|
+
},
|
|
2653
|
+
},
|
|
2654
|
+
});
|
|
2655
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
2656
|
+
|
|
2657
|
+
initializeTabUI(tab, plugin);
|
|
2658
|
+
|
|
2659
|
+
const { SlashCommandDropdown } = jest.requireMock('@/shared/components/SlashCommandDropdown');
|
|
2660
|
+
const constructorCall = SlashCommandDropdown.mock.calls[0];
|
|
2661
|
+
const opts = constructorCall[3];
|
|
2662
|
+
|
|
2663
|
+
expect(Array.from(opts.hiddenCommands)).toEqual(['analyze']);
|
|
2664
|
+
});
|
|
2665
|
+
});
|
|
2666
|
+
});
|
|
2667
|
+
|
|
2668
|
+
describe('Tab - Service Initialization Error Handling', () => {
|
|
2669
|
+
beforeEach(() => {
|
|
2670
|
+
jest.clearAllMocks();
|
|
2671
|
+
});
|
|
2672
|
+
|
|
2673
|
+
it('should skip re-initialization if already initialized', async () => {
|
|
2674
|
+
const options = createMockOptions();
|
|
2675
|
+
const tab = createTab(options);
|
|
2676
|
+
|
|
2677
|
+
// Mark as already initialized
|
|
2678
|
+
tab.serviceInitialized = true;
|
|
2679
|
+
const originalService = createMockClaudianService() as any;
|
|
2680
|
+
tab.service = originalService;
|
|
2681
|
+
|
|
2682
|
+
await initializeTabService(tab, options.plugin, options.mcpManager);
|
|
2683
|
+
|
|
2684
|
+
// Should not change existing service
|
|
2685
|
+
expect(tab.service).toBe(originalService);
|
|
2686
|
+
expect(tab.serviceInitialized).toBe(true);
|
|
2687
|
+
});
|
|
2688
|
+
|
|
2689
|
+
it('should set serviceInitialized to true after successful initialization', async () => {
|
|
2690
|
+
const options = createMockOptions();
|
|
2691
|
+
const tab = createTab(options);
|
|
2692
|
+
|
|
2693
|
+
expect(tab.serviceInitialized).toBe(false);
|
|
2694
|
+
expect(tab.service).toBeNull();
|
|
2695
|
+
|
|
2696
|
+
await initializeTabService(tab, options.plugin, options.mcpManager);
|
|
2697
|
+
|
|
2698
|
+
expect(tab.serviceInitialized).toBe(true);
|
|
2699
|
+
expect(tab.service).not.toBeNull();
|
|
2700
|
+
});
|
|
2701
|
+
|
|
2702
|
+
});
|
|
2703
|
+
|
|
2704
|
+
describe('Tab - Controller Configuration', () => {
|
|
2705
|
+
beforeEach(() => {
|
|
2706
|
+
jest.clearAllMocks();
|
|
2707
|
+
});
|
|
2708
|
+
|
|
2709
|
+
describe('InputController configuration', () => {
|
|
2710
|
+
it('should wire ensureServiceInitialized to return true when already initialized and bound_active', async () => {
|
|
2711
|
+
const { InputController } = jest.requireMock('@/features/chat/controllers/InputController');
|
|
2712
|
+
const options = createMockOptions();
|
|
2713
|
+
const tab = createTab(options);
|
|
2714
|
+
const mockComponent = {} as any;
|
|
2715
|
+
|
|
2716
|
+
initializeTabUI(tab, options.plugin);
|
|
2717
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
2718
|
+
|
|
2719
|
+
// Get InputController constructor config
|
|
2720
|
+
const constructorCall = InputController.mock.calls[0];
|
|
2721
|
+
const config = constructorCall[0];
|
|
2722
|
+
|
|
2723
|
+
// Test ensureServiceInitialized when already initialized and bound_active
|
|
2724
|
+
tab.serviceInitialized = true;
|
|
2725
|
+
tab.lifecycleState = 'bound_active';
|
|
2726
|
+
const result = await config.ensureServiceInitialized();
|
|
2727
|
+
expect(result).toBe(true);
|
|
2728
|
+
});
|
|
2729
|
+
|
|
2730
|
+
it('should wire getAgentService to return tab service', () => {
|
|
2731
|
+
const { InputController } = jest.requireMock('@/features/chat/controllers/InputController');
|
|
2732
|
+
const options = createMockOptions();
|
|
2733
|
+
const tab = createTab(options);
|
|
2734
|
+
const mockComponent = {} as any;
|
|
2735
|
+
|
|
2736
|
+
initializeTabUI(tab, options.plugin);
|
|
2737
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
2738
|
+
|
|
2739
|
+
const constructorCall = InputController.mock.calls[0];
|
|
2740
|
+
const config = constructorCall[0];
|
|
2741
|
+
|
|
2742
|
+
// Verify getAgentService returns tab's service
|
|
2743
|
+
tab.service = { id: 'test-service' } as any;
|
|
2744
|
+
expect(config.getAgentService()).toBe(tab.service);
|
|
2745
|
+
});
|
|
2746
|
+
|
|
2747
|
+
it('should wire getters to return tab UI components', () => {
|
|
2748
|
+
const { InputController } = jest.requireMock('@/features/chat/controllers/InputController');
|
|
2749
|
+
const options = createMockOptions();
|
|
2750
|
+
const tab = createTab(options);
|
|
2751
|
+
const mockComponent = {} as any;
|
|
2752
|
+
|
|
2753
|
+
initializeTabUI(tab, options.plugin);
|
|
2754
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
2755
|
+
|
|
2756
|
+
const constructorCall = InputController.mock.calls[0];
|
|
2757
|
+
const config = constructorCall[0];
|
|
2758
|
+
|
|
2759
|
+
// Test getters return correct UI components
|
|
2760
|
+
expect(config.getInputEl()).toBe(tab.dom.inputEl);
|
|
2761
|
+
expect(config.getMessagesEl()).toBe(tab.dom.messagesEl);
|
|
2762
|
+
expect(config.getFileContextManager()).toBe(tab.ui.fileContextManager);
|
|
2763
|
+
expect(config.getImageContextManager()).toBe(tab.ui.imageContextManager);
|
|
2764
|
+
expect(config.getMcpServerSelector()).toBe(tab.ui.mcpServerSelector);
|
|
2765
|
+
expect(config.getExternalContextSelector()).toBe(tab.ui.externalContextSelector);
|
|
2766
|
+
expect(config.getInstructionModeManager()).toBe(tab.ui.instructionModeManager);
|
|
2767
|
+
expect(config.getInstructionRefineService()).toBe(tab.services.instructionRefineService);
|
|
2768
|
+
expect(config.getTitleGenerationService()).toBe(tab.services.titleGenerationService);
|
|
2769
|
+
});
|
|
2770
|
+
|
|
2771
|
+
});
|
|
2772
|
+
|
|
2773
|
+
describe('StreamController configuration', () => {
|
|
2774
|
+
it('should wire updateQueueIndicator to input controller', () => {
|
|
2775
|
+
const { StreamController } = jest.requireMock('@/features/chat/controllers/StreamController');
|
|
2776
|
+
const options = createMockOptions();
|
|
2777
|
+
const tab = createTab(options);
|
|
2778
|
+
const mockComponent = {} as any;
|
|
2779
|
+
|
|
2780
|
+
initializeTabUI(tab, options.plugin);
|
|
2781
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
2782
|
+
|
|
2783
|
+
const constructorCall = StreamController.mock.calls[0];
|
|
2784
|
+
const config = constructorCall[0];
|
|
2785
|
+
|
|
2786
|
+
config.updateQueueIndicator();
|
|
2787
|
+
|
|
2788
|
+
expect(mockInputController.updateQueueIndicator).toHaveBeenCalled();
|
|
2789
|
+
});
|
|
2790
|
+
|
|
2791
|
+
it('should wire getAgentService to return tab service', () => {
|
|
2792
|
+
const { StreamController } = jest.requireMock('@/features/chat/controllers/StreamController');
|
|
2793
|
+
const options = createMockOptions();
|
|
2794
|
+
const tab = createTab(options);
|
|
2795
|
+
const mockComponent = {} as any;
|
|
2796
|
+
|
|
2797
|
+
initializeTabUI(tab, options.plugin);
|
|
2798
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
2799
|
+
|
|
2800
|
+
tab.service = { id: 'test-service' } as any;
|
|
2801
|
+
|
|
2802
|
+
const constructorCall = StreamController.mock.calls[0];
|
|
2803
|
+
const config = constructorCall[0];
|
|
2804
|
+
|
|
2805
|
+
expect(config.getAgentService()).toBe(tab.service);
|
|
2806
|
+
});
|
|
2807
|
+
|
|
2808
|
+
it('should wire getMessagesEl to return tab messages element', () => {
|
|
2809
|
+
const { StreamController } = jest.requireMock('@/features/chat/controllers/StreamController');
|
|
2810
|
+
const options = createMockOptions();
|
|
2811
|
+
const tab = createTab(options);
|
|
2812
|
+
const mockComponent = {} as any;
|
|
2813
|
+
|
|
2814
|
+
initializeTabUI(tab, options.plugin);
|
|
2815
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
2816
|
+
|
|
2817
|
+
const constructorCall = StreamController.mock.calls[0];
|
|
2818
|
+
const config = constructorCall[0];
|
|
2819
|
+
|
|
2820
|
+
expect(config.getMessagesEl()).toBe(tab.dom.messagesEl);
|
|
2821
|
+
});
|
|
2822
|
+
});
|
|
2823
|
+
|
|
2824
|
+
describe('NavigationController configuration', () => {
|
|
2825
|
+
it('should wire shouldSkipEscapeHandling to check UI state', () => {
|
|
2826
|
+
const { NavigationController } = jest.requireMock('@/features/chat/controllers/NavigationController');
|
|
2827
|
+
const options = createMockOptions();
|
|
2828
|
+
const tab = createTab(options);
|
|
2829
|
+
const mockComponent = {} as any;
|
|
2830
|
+
|
|
2831
|
+
initializeTabUI(tab, options.plugin);
|
|
2832
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
2833
|
+
|
|
2834
|
+
const constructorCall = NavigationController.mock.calls[0];
|
|
2835
|
+
const config = constructorCall[0];
|
|
2836
|
+
|
|
2837
|
+
// Test when instruction mode is active
|
|
2838
|
+
mockInstructionModeManager.isActive.mockReturnValue(true);
|
|
2839
|
+
expect(config.shouldSkipEscapeHandling()).toBe(true);
|
|
2840
|
+
|
|
2841
|
+
// Test when slash command dropdown is visible
|
|
2842
|
+
mockInstructionModeManager.isActive.mockReturnValue(false);
|
|
2843
|
+
mockSlashCommandDropdown.isVisible.mockReturnValue(true);
|
|
2844
|
+
expect(config.shouldSkipEscapeHandling()).toBe(true);
|
|
2845
|
+
|
|
2846
|
+
// Test when mention dropdown is visible
|
|
2847
|
+
mockSlashCommandDropdown.isVisible.mockReturnValue(false);
|
|
2848
|
+
mockFileContextManager.isMentionDropdownVisible.mockReturnValue(true);
|
|
2849
|
+
expect(config.shouldSkipEscapeHandling()).toBe(true);
|
|
2850
|
+
|
|
2851
|
+
// Test when resume dropdown is visible
|
|
2852
|
+
mockFileContextManager.isMentionDropdownVisible.mockReturnValue(false);
|
|
2853
|
+
mockInputController.isResumeDropdownVisible.mockReturnValue(true);
|
|
2854
|
+
expect(config.shouldSkipEscapeHandling()).toBe(true);
|
|
2855
|
+
|
|
2856
|
+
// Test when nothing active
|
|
2857
|
+
mockInputController.isResumeDropdownVisible.mockReturnValue(false);
|
|
2858
|
+
expect(config.shouldSkipEscapeHandling()).toBe(false);
|
|
2859
|
+
});
|
|
2860
|
+
|
|
2861
|
+
it('should wire isStreaming to return tab state', () => {
|
|
2862
|
+
const { NavigationController } = jest.requireMock('@/features/chat/controllers/NavigationController');
|
|
2863
|
+
const options = createMockOptions();
|
|
2864
|
+
const tab = createTab(options);
|
|
2865
|
+
const mockComponent = {} as any;
|
|
2866
|
+
|
|
2867
|
+
initializeTabUI(tab, options.plugin);
|
|
2868
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
2869
|
+
|
|
2870
|
+
const constructorCall = NavigationController.mock.calls[0];
|
|
2871
|
+
const config = constructorCall[0];
|
|
2872
|
+
|
|
2873
|
+
tab.state.isStreaming = true;
|
|
2874
|
+
expect(config.isStreaming()).toBe(true);
|
|
2875
|
+
|
|
2876
|
+
tab.state.isStreaming = false;
|
|
2877
|
+
expect(config.isStreaming()).toBe(false);
|
|
2878
|
+
});
|
|
2879
|
+
|
|
2880
|
+
it('should wire getSettings to return keyboard navigation settings', () => {
|
|
2881
|
+
const keyboardNavigation = {
|
|
2882
|
+
scrollUpKey: 'k',
|
|
2883
|
+
scrollDownKey: 'j',
|
|
2884
|
+
focusInputKey: 'i',
|
|
2885
|
+
};
|
|
2886
|
+
const plugin = createMockPlugin({
|
|
2887
|
+
settings: {
|
|
2888
|
+
...createMockPlugin().settings,
|
|
2889
|
+
keyboardNavigation,
|
|
2890
|
+
},
|
|
2891
|
+
});
|
|
2892
|
+
const { NavigationController } = jest.requireMock('@/features/chat/controllers/NavigationController');
|
|
2893
|
+
const options = createMockOptions({ plugin });
|
|
2894
|
+
const tab = createTab(options);
|
|
2895
|
+
const mockComponent = {} as any;
|
|
2896
|
+
|
|
2897
|
+
initializeTabUI(tab, plugin);
|
|
2898
|
+
initializeTabControllers(tab, plugin, mockComponent, options.mcpManager);
|
|
2899
|
+
|
|
2900
|
+
const constructorCall = NavigationController.mock.calls[0];
|
|
2901
|
+
const config = constructorCall[0];
|
|
2902
|
+
|
|
2903
|
+
expect(config.getSettings()).toEqual(keyboardNavigation);
|
|
2904
|
+
});
|
|
2905
|
+
});
|
|
2906
|
+
|
|
2907
|
+
describe('ConversationController configuration', () => {
|
|
2908
|
+
it('should wire getHistoryDropdown to return null (tab has no dropdown)', () => {
|
|
2909
|
+
const { ConversationController } = jest.requireMock('@/features/chat/controllers/ConversationController');
|
|
2910
|
+
const options = createMockOptions();
|
|
2911
|
+
const tab = createTab(options);
|
|
2912
|
+
const mockComponent = {} as any;
|
|
2913
|
+
|
|
2914
|
+
initializeTabUI(tab, options.plugin);
|
|
2915
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
2916
|
+
|
|
2917
|
+
const constructorCall = ConversationController.mock.calls[0];
|
|
2918
|
+
const config = constructorCall[0];
|
|
2919
|
+
|
|
2920
|
+
expect(config.getHistoryDropdown()).toBeNull();
|
|
2921
|
+
});
|
|
2922
|
+
|
|
2923
|
+
it('should wire welcome element getters and setters', () => {
|
|
2924
|
+
const { ConversationController } = jest.requireMock('@/features/chat/controllers/ConversationController');
|
|
2925
|
+
const options = createMockOptions();
|
|
2926
|
+
const tab = createTab(options);
|
|
2927
|
+
const mockComponent = {} as any;
|
|
2928
|
+
|
|
2929
|
+
initializeTabUI(tab, options.plugin);
|
|
2930
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
2931
|
+
|
|
2932
|
+
const constructorCall = ConversationController.mock.calls[0];
|
|
2933
|
+
const config = constructorCall[0];
|
|
2934
|
+
|
|
2935
|
+
// Test getter - use mock element
|
|
2936
|
+
const mockWelcome = { id: 'welcome-el' } as any;
|
|
2937
|
+
tab.dom.welcomeEl = mockWelcome;
|
|
2938
|
+
expect(config.getWelcomeEl()).toBe(mockWelcome);
|
|
2939
|
+
|
|
2940
|
+
// Test setter
|
|
2941
|
+
const newWelcomeEl = { id: 'new-welcome-el' } as any;
|
|
2942
|
+
config.setWelcomeEl(newWelcomeEl);
|
|
2943
|
+
expect(tab.dom.welcomeEl).toBe(newWelcomeEl);
|
|
2944
|
+
});
|
|
2945
|
+
|
|
2946
|
+
it('should reset slash-command cache across conversation lifecycle events', () => {
|
|
2947
|
+
const { ConversationController } = jest.requireMock('@/features/chat/controllers/ConversationController');
|
|
2948
|
+
const options = createMockOptions();
|
|
2949
|
+
const tab = createTab(options);
|
|
2950
|
+
const mockComponent = {} as any;
|
|
2951
|
+
|
|
2952
|
+
initializeTabUI(tab, options.plugin);
|
|
2953
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
2954
|
+
|
|
2955
|
+
const constructorCall = ConversationController.mock.calls[0];
|
|
2956
|
+
const callbacks = constructorCall[1];
|
|
2957
|
+
|
|
2958
|
+
callbacks.onNewConversation();
|
|
2959
|
+
callbacks.onConversationLoaded();
|
|
2960
|
+
callbacks.onConversationSwitched();
|
|
2961
|
+
|
|
2962
|
+
expect(mockSlashCommandDropdown.resetSdkSkillsCache).toHaveBeenCalledTimes(3);
|
|
2963
|
+
});
|
|
2964
|
+
});
|
|
2965
|
+
});
|
|
2966
|
+
|
|
2967
|
+
const mockNotice = Notice as jest.Mock;
|
|
2968
|
+
|
|
2969
|
+
describe('Tab - handleForkRequest', () => {
|
|
2970
|
+
|
|
2971
|
+
function setupForkTest(overrides: Record<string, any> = {}) {
|
|
2972
|
+
const options = createMockOptions(overrides);
|
|
2973
|
+
const tab = createTab(options);
|
|
2974
|
+
const mockComponent = {} as any;
|
|
2975
|
+
const forkRequestCallback = jest.fn().mockResolvedValue(undefined);
|
|
2976
|
+
|
|
2977
|
+
initializeTabUI(tab, options.plugin);
|
|
2978
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager, forkRequestCallback);
|
|
2979
|
+
|
|
2980
|
+
// Extract the fork callback from the MessageRenderer constructor
|
|
2981
|
+
const { MessageRenderer } = jest.requireMock('@/features/chat/rendering/MessageRenderer') as { MessageRenderer: jest.Mock };
|
|
2982
|
+
const lastCall = MessageRenderer.mock.calls[MessageRenderer.mock.calls.length - 1];
|
|
2983
|
+
const forkCallback = lastCall[4]; // 5th argument is forkCallback
|
|
2984
|
+
|
|
2985
|
+
return { tab, forkCallback, forkRequestCallback, plugin: options.plugin };
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
beforeEach(() => {
|
|
2989
|
+
mockNotice.mockClear();
|
|
2990
|
+
});
|
|
2991
|
+
|
|
2992
|
+
it('should show notice when streaming', async () => {
|
|
2993
|
+
const { tab, forkCallback } = setupForkTest();
|
|
2994
|
+
|
|
2995
|
+
tab.state.isStreaming = true;
|
|
2996
|
+
tab.state.messages = [
|
|
2997
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'user-u' },
|
|
2998
|
+
];
|
|
2999
|
+
|
|
3000
|
+
await forkCallback('u1');
|
|
3001
|
+
|
|
3002
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
3003
|
+
});
|
|
3004
|
+
|
|
3005
|
+
it('should show notice when message ID not found', async () => {
|
|
3006
|
+
const { forkCallback, forkRequestCallback } = setupForkTest();
|
|
3007
|
+
|
|
3008
|
+
await forkCallback('nonexistent');
|
|
3009
|
+
|
|
3010
|
+
expect(forkRequestCallback).not.toHaveBeenCalled();
|
|
3011
|
+
expect(mockNotice).toHaveBeenCalledWith('Fork failed: Message not found');
|
|
3012
|
+
});
|
|
3013
|
+
|
|
3014
|
+
it('should show notice when user message has no userMessageId', async () => {
|
|
3015
|
+
const { tab, forkCallback, forkRequestCallback } = setupForkTest();
|
|
3016
|
+
|
|
3017
|
+
tab.state.messages = [
|
|
3018
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 1 },
|
|
3019
|
+
];
|
|
3020
|
+
|
|
3021
|
+
await forkCallback('u1');
|
|
3022
|
+
|
|
3023
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
3024
|
+
expect(forkRequestCallback).not.toHaveBeenCalled();
|
|
3025
|
+
});
|
|
3026
|
+
|
|
3027
|
+
it('should show notice when no assistant response follows the user message', async () => {
|
|
3028
|
+
const { tab, forkCallback, forkRequestCallback } = setupForkTest();
|
|
3029
|
+
|
|
3030
|
+
// User message without a following assistant response with UUID
|
|
3031
|
+
tab.state.messages = [
|
|
3032
|
+
{ id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' },
|
|
3033
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' },
|
|
3034
|
+
// No assistant response after u1
|
|
3035
|
+
];
|
|
3036
|
+
|
|
3037
|
+
await forkCallback('u1');
|
|
3038
|
+
|
|
3039
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
3040
|
+
expect(forkRequestCallback).not.toHaveBeenCalled();
|
|
3041
|
+
});
|
|
3042
|
+
|
|
3043
|
+
it('should show notice when no session ID is available', async () => {
|
|
3044
|
+
const plugin = createMockPlugin({
|
|
3045
|
+
getConversationSync: jest.fn().mockReturnValue(null),
|
|
3046
|
+
});
|
|
3047
|
+
const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin });
|
|
3048
|
+
|
|
3049
|
+
tab.state.messages = [
|
|
3050
|
+
{ id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' },
|
|
3051
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' },
|
|
3052
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' },
|
|
3053
|
+
];
|
|
3054
|
+
// No service and no conversation
|
|
3055
|
+
tab.service = null;
|
|
3056
|
+
|
|
3057
|
+
await forkCallback('u1');
|
|
3058
|
+
|
|
3059
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
3060
|
+
expect(forkRequestCallback).not.toHaveBeenCalled();
|
|
3061
|
+
});
|
|
3062
|
+
|
|
3063
|
+
it('should call forkRequestCallback with correct ForkContext on success', async () => {
|
|
3064
|
+
const plugin = createMockPlugin({
|
|
3065
|
+
getConversationSync: jest.fn().mockReturnValue({
|
|
3066
|
+
title: 'My Conversation',
|
|
3067
|
+
currentNote: 'notes/test.md',
|
|
3068
|
+
}),
|
|
3069
|
+
});
|
|
3070
|
+
const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin });
|
|
3071
|
+
|
|
3072
|
+
tab.state.messages = [
|
|
3073
|
+
{ id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' },
|
|
3074
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' },
|
|
3075
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' },
|
|
3076
|
+
{ id: 'u2', role: 'user', content: 'world', timestamp: 4, userMessageId: 'user-u2' },
|
|
3077
|
+
{ id: 'a2', role: 'assistant', content: 'resp2', timestamp: 5, assistantMessageId: 'asst-2' },
|
|
3078
|
+
];
|
|
3079
|
+
|
|
3080
|
+
// Service has a session ID
|
|
3081
|
+
tab.service = {
|
|
3082
|
+
getSessionId: jest.fn().mockReturnValue('session-abc'),
|
|
3083
|
+
resolveSessionIdForFork: jest.fn().mockReturnValue('session-abc'),
|
|
3084
|
+
} as any;
|
|
3085
|
+
tab.conversationId = 'conv-1';
|
|
3086
|
+
|
|
3087
|
+
await forkCallback('u2');
|
|
3088
|
+
|
|
3089
|
+
expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({
|
|
3090
|
+
sourceSessionId: 'session-abc',
|
|
3091
|
+
resumeAt: 'asst-1', // prev assistant UUID before u2
|
|
3092
|
+
sourceTitle: 'My Conversation',
|
|
3093
|
+
currentNote: 'notes/test.md',
|
|
3094
|
+
forkAtUserMessage: 2, // u2 is the 2nd user message
|
|
3095
|
+
}));
|
|
3096
|
+
|
|
3097
|
+
// Messages should be deep-cloned and sliced before the fork point
|
|
3098
|
+
const ctx = forkRequestCallback.mock.calls[0][0];
|
|
3099
|
+
expect(ctx.messages).toHaveLength(3); // a0, u1, a1 (before u2)
|
|
3100
|
+
expect(ctx.messages.map((m: any) => m.id)).toEqual(['a0', 'u1', 'a1']);
|
|
3101
|
+
});
|
|
3102
|
+
|
|
3103
|
+
it('should fall back to conversation session ID when service has none', async () => {
|
|
3104
|
+
const plugin = createMockPlugin({
|
|
3105
|
+
getConversationSync: jest.fn().mockReturnValue({
|
|
3106
|
+
providerState: { providerSessionId: 'conv-session-xyz' },
|
|
3107
|
+
title: 'Fallback Chat',
|
|
3108
|
+
}),
|
|
3109
|
+
});
|
|
3110
|
+
const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin });
|
|
3111
|
+
|
|
3112
|
+
tab.state.messages = [
|
|
3113
|
+
{ id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' },
|
|
3114
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' },
|
|
3115
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' },
|
|
3116
|
+
];
|
|
3117
|
+
tab.service = null;
|
|
3118
|
+
tab.conversationId = 'conv-1';
|
|
3119
|
+
|
|
3120
|
+
await forkCallback('u1');
|
|
3121
|
+
|
|
3122
|
+
expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({
|
|
3123
|
+
sourceSessionId: 'conv-session-xyz',
|
|
3124
|
+
}));
|
|
3125
|
+
});
|
|
3126
|
+
|
|
3127
|
+
it('should produce deep-cloned messages that do not share references with originals', async () => {
|
|
3128
|
+
const plugin = createMockPlugin({
|
|
3129
|
+
getConversationSync: jest.fn().mockReturnValue({ title: 'Test' }),
|
|
3130
|
+
});
|
|
3131
|
+
const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin });
|
|
3132
|
+
|
|
3133
|
+
const originalMsg = { id: 'a0', role: 'assistant' as const, content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' };
|
|
3134
|
+
tab.state.messages = [
|
|
3135
|
+
originalMsg,
|
|
3136
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' },
|
|
3137
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' },
|
|
3138
|
+
];
|
|
3139
|
+
tab.service = { getSessionId: jest.fn().mockReturnValue('session-1'), resolveSessionIdForFork: jest.fn().mockReturnValue('session-1') } as any;
|
|
3140
|
+
tab.conversationId = 'conv-1';
|
|
3141
|
+
|
|
3142
|
+
await forkCallback('u1');
|
|
3143
|
+
|
|
3144
|
+
const ctx = forkRequestCallback.mock.calls[0][0];
|
|
3145
|
+
// Deep clone should not share references
|
|
3146
|
+
expect(ctx.messages[0]).not.toBe(originalMsg);
|
|
3147
|
+
expect(ctx.messages[0]).toEqual(originalMsg);
|
|
3148
|
+
});
|
|
3149
|
+
|
|
3150
|
+
it('should fork at first user message with empty messages before fork', async () => {
|
|
3151
|
+
const plugin = createMockPlugin({
|
|
3152
|
+
getConversationSync: jest.fn().mockReturnValue({ title: 'First Fork' }),
|
|
3153
|
+
});
|
|
3154
|
+
const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin });
|
|
3155
|
+
|
|
3156
|
+
tab.state.messages = [
|
|
3157
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'user-u1' },
|
|
3158
|
+
{ id: 'a1', role: 'assistant', content: 'hi', timestamp: 2, assistantMessageId: 'asst-1' },
|
|
3159
|
+
];
|
|
3160
|
+
tab.service = { getSessionId: jest.fn().mockReturnValue('session-1'), resolveSessionIdForFork: jest.fn().mockReturnValue('session-1') } as any;
|
|
3161
|
+
tab.conversationId = 'conv-1';
|
|
3162
|
+
|
|
3163
|
+
await forkCallback('u1');
|
|
3164
|
+
|
|
3165
|
+
// No assistant message before u1, so findRewindContext returns no prevAssistantUuid
|
|
3166
|
+
expect(forkRequestCallback).not.toHaveBeenCalled();
|
|
3167
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
3168
|
+
});
|
|
3169
|
+
|
|
3170
|
+
it('should fall back to conversation forkSource.sessionId when no sessionId or providerSessionId', async () => {
|
|
3171
|
+
const plugin = createMockPlugin({
|
|
3172
|
+
getConversationSync: jest.fn().mockReturnValue({
|
|
3173
|
+
title: 'Nested Fork',
|
|
3174
|
+
providerState: { forkSource: { sessionId: 'original-source-session', resumeAt: 'asst-prev' } },
|
|
3175
|
+
}),
|
|
3176
|
+
});
|
|
3177
|
+
const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin });
|
|
3178
|
+
|
|
3179
|
+
tab.state.messages = [
|
|
3180
|
+
{ id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' },
|
|
3181
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' },
|
|
3182
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' },
|
|
3183
|
+
];
|
|
3184
|
+
tab.service = null;
|
|
3185
|
+
tab.conversationId = 'conv-1';
|
|
3186
|
+
|
|
3187
|
+
await forkCallback('u1');
|
|
3188
|
+
|
|
3189
|
+
expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({
|
|
3190
|
+
sourceSessionId: 'original-source-session',
|
|
3191
|
+
}));
|
|
3192
|
+
});
|
|
3193
|
+
|
|
3194
|
+
it('should prefer service session ID over conversation metadata', async () => {
|
|
3195
|
+
const plugin = createMockPlugin({
|
|
3196
|
+
getConversationSync: jest.fn().mockReturnValue({
|
|
3197
|
+
title: 'Test',
|
|
3198
|
+
providerState: { providerSessionId: 'conv-session' },
|
|
3199
|
+
sessionId: 'old-session',
|
|
3200
|
+
}),
|
|
3201
|
+
});
|
|
3202
|
+
const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin });
|
|
3203
|
+
|
|
3204
|
+
tab.state.messages = [
|
|
3205
|
+
{ id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' },
|
|
3206
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' },
|
|
3207
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' },
|
|
3208
|
+
];
|
|
3209
|
+
tab.service = { getSessionId: jest.fn().mockReturnValue('service-session'), resolveSessionIdForFork: jest.fn().mockReturnValue('service-session') } as any;
|
|
3210
|
+
tab.conversationId = 'conv-1';
|
|
3211
|
+
|
|
3212
|
+
await forkCallback('u1');
|
|
3213
|
+
|
|
3214
|
+
expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({
|
|
3215
|
+
sourceSessionId: 'service-session',
|
|
3216
|
+
}));
|
|
3217
|
+
});
|
|
3218
|
+
|
|
3219
|
+
it('should set forkAtUserMessage to 1 for the first user message', async () => {
|
|
3220
|
+
const plugin = createMockPlugin({
|
|
3221
|
+
getConversationSync: jest.fn().mockReturnValue({ title: 'Test' }),
|
|
3222
|
+
});
|
|
3223
|
+
const { tab, forkCallback, forkRequestCallback } = setupForkTest({ plugin });
|
|
3224
|
+
|
|
3225
|
+
tab.state.messages = [
|
|
3226
|
+
{ id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' },
|
|
3227
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u1' },
|
|
3228
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' },
|
|
3229
|
+
];
|
|
3230
|
+
tab.service = { getSessionId: jest.fn().mockReturnValue('session-1'), resolveSessionIdForFork: jest.fn().mockReturnValue('session-1') } as any;
|
|
3231
|
+
tab.conversationId = 'conv-1';
|
|
3232
|
+
|
|
3233
|
+
await forkCallback('u1');
|
|
3234
|
+
|
|
3235
|
+
expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({
|
|
3236
|
+
forkAtUserMessage: 1,
|
|
3237
|
+
}));
|
|
3238
|
+
});
|
|
3239
|
+
|
|
3240
|
+
it('should not set forkCallback on renderer when no forkRequestCallback provided', () => {
|
|
3241
|
+
const options = createMockOptions();
|
|
3242
|
+
const tab = createTab(options);
|
|
3243
|
+
const mockComponent = {} as any;
|
|
3244
|
+
|
|
3245
|
+
initializeTabUI(tab, options.plugin);
|
|
3246
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
3247
|
+
|
|
3248
|
+
const { MessageRenderer } = jest.requireMock('@/features/chat/rendering/MessageRenderer') as { MessageRenderer: jest.Mock };
|
|
3249
|
+
const lastCall = MessageRenderer.mock.calls[MessageRenderer.mock.calls.length - 1];
|
|
3250
|
+
const forkCallback = lastCall[4];
|
|
3251
|
+
|
|
3252
|
+
expect(forkCallback).toBeUndefined();
|
|
3253
|
+
});
|
|
3254
|
+
});
|
|
3255
|
+
|
|
3256
|
+
describe('Tab - handleForkAll (via /fork command)', () => {
|
|
3257
|
+
|
|
3258
|
+
function setupForkAllTest(overrides: Record<string, any> = {}) {
|
|
3259
|
+
const options = createMockOptions(overrides);
|
|
3260
|
+
const tab = createTab(options);
|
|
3261
|
+
const mockComponent = {} as any;
|
|
3262
|
+
const forkRequestCallback = jest.fn().mockResolvedValue(undefined);
|
|
3263
|
+
|
|
3264
|
+
initializeTabUI(tab, options.plugin);
|
|
3265
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager, forkRequestCallback);
|
|
3266
|
+
|
|
3267
|
+
// Extract onForkAll from InputController constructor call
|
|
3268
|
+
const { InputController } = jest.requireMock('@/features/chat/controllers/InputController') as { InputController: jest.Mock };
|
|
3269
|
+
const lastCall = InputController.mock.calls[InputController.mock.calls.length - 1];
|
|
3270
|
+
const config = lastCall[0];
|
|
3271
|
+
const onForkAll = config.onForkAll as (() => Promise<void>) | undefined;
|
|
3272
|
+
|
|
3273
|
+
return { tab, onForkAll: onForkAll!, forkRequestCallback, plugin: options.plugin };
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
beforeEach(() => {
|
|
3277
|
+
mockNotice.mockClear();
|
|
3278
|
+
});
|
|
3279
|
+
|
|
3280
|
+
it('should call forkRequestCallback with all messages and last assistant UUID', async () => {
|
|
3281
|
+
const plugin = createMockPlugin({
|
|
3282
|
+
getConversationSync: jest.fn().mockReturnValue({
|
|
3283
|
+
title: 'My Conversation',
|
|
3284
|
+
currentNote: 'notes/test.md',
|
|
3285
|
+
}),
|
|
3286
|
+
});
|
|
3287
|
+
const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin });
|
|
3288
|
+
|
|
3289
|
+
tab.state.messages = [
|
|
3290
|
+
{ id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' },
|
|
3291
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u1' },
|
|
3292
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' },
|
|
3293
|
+
{ id: 'u2', role: 'user', content: 'world', timestamp: 4, userMessageId: 'user-u2' },
|
|
3294
|
+
{ id: 'a2', role: 'assistant', content: 'resp2', timestamp: 5, assistantMessageId: 'asst-2' },
|
|
3295
|
+
];
|
|
3296
|
+
tab.service = { getSessionId: jest.fn().mockReturnValue('session-abc'), resolveSessionIdForFork: jest.fn().mockReturnValue('session-abc') } as any;
|
|
3297
|
+
tab.conversationId = 'conv-1';
|
|
3298
|
+
|
|
3299
|
+
await onForkAll();
|
|
3300
|
+
|
|
3301
|
+
expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({
|
|
3302
|
+
sourceSessionId: 'session-abc',
|
|
3303
|
+
resumeAt: 'asst-2', // last assistant UUID
|
|
3304
|
+
sourceTitle: 'My Conversation',
|
|
3305
|
+
currentNote: 'notes/test.md',
|
|
3306
|
+
}));
|
|
3307
|
+
|
|
3308
|
+
const ctx = forkRequestCallback.mock.calls[0][0];
|
|
3309
|
+
expect(ctx.messages).toHaveLength(5); // all messages
|
|
3310
|
+
expect(ctx.messages.map((m: any) => m.id)).toEqual(['a0', 'u1', 'a1', 'u2', 'a2']);
|
|
3311
|
+
expect(ctx.forkAtUserMessage).toBe(3); // 2 user messages + 1
|
|
3312
|
+
});
|
|
3313
|
+
|
|
3314
|
+
it('should include trailing user + interrupt messages and not count interrupt for fork number', async () => {
|
|
3315
|
+
const plugin = createMockPlugin({
|
|
3316
|
+
getConversationSync: jest.fn().mockReturnValue({
|
|
3317
|
+
title: 'My Conversation',
|
|
3318
|
+
currentNote: 'notes/test.md',
|
|
3319
|
+
}),
|
|
3320
|
+
});
|
|
3321
|
+
const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin });
|
|
3322
|
+
|
|
3323
|
+
tab.state.messages = [
|
|
3324
|
+
{ id: 'a0', role: 'assistant', content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' },
|
|
3325
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u1' },
|
|
3326
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' },
|
|
3327
|
+
{ id: 'u2', role: 'user', content: 'world', timestamp: 4, userMessageId: 'user-u2' },
|
|
3328
|
+
{ id: 'a2', role: 'assistant', content: 'resp2', timestamp: 5, assistantMessageId: 'asst-2' },
|
|
3329
|
+
{ id: 'u3', role: 'user', content: 'more', timestamp: 6, userMessageId: 'user-u3' },
|
|
3330
|
+
{ id: 'int-1', role: 'user', content: '[Request interrupted by user]', timestamp: 7, userMessageId: 'user-int', isInterrupt: true },
|
|
3331
|
+
];
|
|
3332
|
+
tab.service = { getSessionId: jest.fn().mockReturnValue('session-abc'), resolveSessionIdForFork: jest.fn().mockReturnValue('session-abc') } as any;
|
|
3333
|
+
tab.conversationId = 'conv-1';
|
|
3334
|
+
|
|
3335
|
+
await onForkAll();
|
|
3336
|
+
|
|
3337
|
+
expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({
|
|
3338
|
+
sourceSessionId: 'session-abc',
|
|
3339
|
+
resumeAt: 'asst-2',
|
|
3340
|
+
forkAtUserMessage: 4, // u1, u2, u3 + 1 (interrupt excluded)
|
|
3341
|
+
}));
|
|
3342
|
+
|
|
3343
|
+
const ctx = forkRequestCallback.mock.calls[0][0];
|
|
3344
|
+
expect(ctx.messages).toHaveLength(7);
|
|
3345
|
+
expect(ctx.messages.map((m: any) => m.id)).toEqual(['a0', 'u1', 'a1', 'u2', 'a2', 'u3', 'int-1']);
|
|
3346
|
+
});
|
|
3347
|
+
|
|
3348
|
+
it('should show notice when streaming', async () => {
|
|
3349
|
+
const { tab, onForkAll, forkRequestCallback } = setupForkAllTest();
|
|
3350
|
+
|
|
3351
|
+
tab.state.isStreaming = true;
|
|
3352
|
+
tab.state.messages = [
|
|
3353
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'user-u' },
|
|
3354
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 2, assistantMessageId: 'asst-1' },
|
|
3355
|
+
];
|
|
3356
|
+
|
|
3357
|
+
await onForkAll();
|
|
3358
|
+
|
|
3359
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
3360
|
+
expect(forkRequestCallback).not.toHaveBeenCalled();
|
|
3361
|
+
});
|
|
3362
|
+
|
|
3363
|
+
it('should show notice when no messages', async () => {
|
|
3364
|
+
const { tab, onForkAll, forkRequestCallback } = setupForkAllTest();
|
|
3365
|
+
|
|
3366
|
+
tab.state.messages = [];
|
|
3367
|
+
|
|
3368
|
+
await onForkAll();
|
|
3369
|
+
|
|
3370
|
+
expect(mockNotice).toHaveBeenCalledWith('Cannot fork: no messages in conversation');
|
|
3371
|
+
expect(forkRequestCallback).not.toHaveBeenCalled();
|
|
3372
|
+
});
|
|
3373
|
+
|
|
3374
|
+
it('should show notice when no assistant message has assistantMessageId', async () => {
|
|
3375
|
+
const { tab, onForkAll, forkRequestCallback } = setupForkAllTest();
|
|
3376
|
+
|
|
3377
|
+
tab.state.messages = [
|
|
3378
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'user-u' },
|
|
3379
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 2 },
|
|
3380
|
+
];
|
|
3381
|
+
|
|
3382
|
+
await onForkAll();
|
|
3383
|
+
|
|
3384
|
+
expect(mockNotice).toHaveBeenCalledWith('Cannot fork: no assistant response with identifiers');
|
|
3385
|
+
expect(forkRequestCallback).not.toHaveBeenCalled();
|
|
3386
|
+
});
|
|
3387
|
+
|
|
3388
|
+
it('should show notice when no session ID is available', async () => {
|
|
3389
|
+
const plugin = createMockPlugin({
|
|
3390
|
+
getConversationSync: jest.fn().mockReturnValue(null),
|
|
3391
|
+
});
|
|
3392
|
+
const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin });
|
|
3393
|
+
|
|
3394
|
+
tab.state.messages = [
|
|
3395
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'user-u' },
|
|
3396
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 2, assistantMessageId: 'asst-1' },
|
|
3397
|
+
];
|
|
3398
|
+
tab.service = null;
|
|
3399
|
+
|
|
3400
|
+
await onForkAll();
|
|
3401
|
+
|
|
3402
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
3403
|
+
expect(forkRequestCallback).not.toHaveBeenCalled();
|
|
3404
|
+
});
|
|
3405
|
+
|
|
3406
|
+
it('should fall back to conversation session ID when service has none', async () => {
|
|
3407
|
+
const plugin = createMockPlugin({
|
|
3408
|
+
getConversationSync: jest.fn().mockReturnValue({
|
|
3409
|
+
providerState: { providerSessionId: 'conv-session-xyz' },
|
|
3410
|
+
title: 'Fallback Chat',
|
|
3411
|
+
}),
|
|
3412
|
+
});
|
|
3413
|
+
const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin });
|
|
3414
|
+
|
|
3415
|
+
tab.state.messages = [
|
|
3416
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'user-u' },
|
|
3417
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 2, assistantMessageId: 'asst-1' },
|
|
3418
|
+
];
|
|
3419
|
+
tab.service = null;
|
|
3420
|
+
tab.conversationId = 'conv-1';
|
|
3421
|
+
|
|
3422
|
+
await onForkAll();
|
|
3423
|
+
|
|
3424
|
+
expect(forkRequestCallback).toHaveBeenCalledWith(expect.objectContaining({
|
|
3425
|
+
sourceSessionId: 'conv-session-xyz',
|
|
3426
|
+
}));
|
|
3427
|
+
});
|
|
3428
|
+
|
|
3429
|
+
it('should deep-clone messages (not share references)', async () => {
|
|
3430
|
+
const plugin = createMockPlugin({
|
|
3431
|
+
getConversationSync: jest.fn().mockReturnValue({ title: 'Test' }),
|
|
3432
|
+
});
|
|
3433
|
+
const { tab, onForkAll, forkRequestCallback } = setupForkAllTest({ plugin });
|
|
3434
|
+
|
|
3435
|
+
const originalMsg = { id: 'a0', role: 'assistant' as const, content: 'hi', timestamp: 1, assistantMessageId: 'asst-0' };
|
|
3436
|
+
tab.state.messages = [
|
|
3437
|
+
originalMsg,
|
|
3438
|
+
{ id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' },
|
|
3439
|
+
{ id: 'a1', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'asst-1' },
|
|
3440
|
+
];
|
|
3441
|
+
tab.service = { getSessionId: jest.fn().mockReturnValue('session-1'), resolveSessionIdForFork: jest.fn().mockReturnValue('session-1') } as any;
|
|
3442
|
+
tab.conversationId = 'conv-1';
|
|
3443
|
+
|
|
3444
|
+
await onForkAll();
|
|
3445
|
+
|
|
3446
|
+
const ctx = forkRequestCallback.mock.calls[0][0];
|
|
3447
|
+
expect(ctx.messages[0]).not.toBe(originalMsg);
|
|
3448
|
+
expect(ctx.messages[0]).toEqual(originalMsg);
|
|
3449
|
+
});
|
|
3450
|
+
|
|
3451
|
+
it('should not set onForkAll on InputController when no forkRequestCallback provided', () => {
|
|
3452
|
+
const options = createMockOptions();
|
|
3453
|
+
const tab = createTab(options);
|
|
3454
|
+
const mockComponent = {} as any;
|
|
3455
|
+
|
|
3456
|
+
initializeTabUI(tab, options.plugin);
|
|
3457
|
+
initializeTabControllers(tab, options.plugin, mockComponent, options.mcpManager);
|
|
3458
|
+
|
|
3459
|
+
const { InputController } = jest.requireMock('@/features/chat/controllers/InputController') as { InputController: jest.Mock };
|
|
3460
|
+
const lastCall = InputController.mock.calls[InputController.mock.calls.length - 1];
|
|
3461
|
+
const config = lastCall[0];
|
|
3462
|
+
expect(config.onForkAll).toBeUndefined();
|
|
3463
|
+
});
|
|
3464
|
+
});
|
|
3465
|
+
|
|
3466
|
+
describe('Tab - Blank Tab Model Selector', () => {
|
|
3467
|
+
afterEach(() => {
|
|
3468
|
+
ProviderWorkspaceRegistry.clear();
|
|
3469
|
+
jest.restoreAllMocks();
|
|
3470
|
+
});
|
|
3471
|
+
|
|
3472
|
+
it('returns Claude-only models when Codex is disabled', () => {
|
|
3473
|
+
const claudeModels = [
|
|
3474
|
+
{ value: 'haiku', label: 'Haiku' },
|
|
3475
|
+
{ value: 'sonnet', label: 'Sonnet' },
|
|
3476
|
+
];
|
|
3477
|
+
jest.spyOn(ProviderRegistry, 'getEnabledProviderIds').mockReturnValue(['claude']);
|
|
3478
|
+
jest.spyOn(ProviderRegistry, 'getProviderDisplayName').mockImplementation((providerId) => (
|
|
3479
|
+
providerId === 'claude' ? 'Claude' : 'Codex'
|
|
3480
|
+
));
|
|
3481
|
+
jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockImplementation((providerId?: string) => ({
|
|
3482
|
+
getModelOptions: () => providerId === 'claude' ? claudeModels : [],
|
|
3483
|
+
getProviderIcon: jest.fn().mockReturnValue(null),
|
|
3484
|
+
} as any));
|
|
3485
|
+
|
|
3486
|
+
const result = getBlankTabModelOptions({ codexEnabled: false });
|
|
3487
|
+
expect(result).toEqual(claudeModels.map(m => ({ ...m, group: 'Claude' })));
|
|
3488
|
+
});
|
|
3489
|
+
|
|
3490
|
+
it('returns Claude + Codex models when Codex is enabled', () => {
|
|
3491
|
+
const claudeModels = [
|
|
3492
|
+
{ value: 'haiku', label: 'Haiku' },
|
|
3493
|
+
{ value: 'sonnet', label: 'Sonnet' },
|
|
3494
|
+
];
|
|
3495
|
+
const codexModels = [
|
|
3496
|
+
{ value: DEFAULT_CODEX_PRIMARY_MODEL, label: DEFAULT_CODEX_PRIMARY_MODEL_LABEL },
|
|
3497
|
+
];
|
|
3498
|
+
|
|
3499
|
+
jest.spyOn(ProviderRegistry, 'getEnabledProviderIds').mockReturnValue(['codex', 'claude']);
|
|
3500
|
+
jest.spyOn(ProviderRegistry, 'getProviderDisplayName').mockImplementation((providerId) => (
|
|
3501
|
+
providerId === 'codex' ? 'Codex' : 'Claude'
|
|
3502
|
+
));
|
|
3503
|
+
jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockImplementation((providerId?: string) => ({
|
|
3504
|
+
getModelOptions: () => providerId === 'codex' ? codexModels : claudeModels,
|
|
3505
|
+
getProviderIcon: jest.fn().mockReturnValue(null),
|
|
3506
|
+
} as any));
|
|
3507
|
+
|
|
3508
|
+
const result = getBlankTabModelOptions({ codexEnabled: true });
|
|
3509
|
+
expect(result).toEqual([
|
|
3510
|
+
...codexModels.map(m => ({ ...m, group: 'Codex' })),
|
|
3511
|
+
...claudeModels.map(m => ({ ...m, group: 'Claude' })),
|
|
3512
|
+
]);
|
|
3513
|
+
});
|
|
3514
|
+
});
|
|
3515
|
+
|
|
3516
|
+
describe('Tab - Cross-Provider Model Rejection', () => {
|
|
3517
|
+
it('rejects cross-provider model change on bound tab via toolbar onModelChange', async () => {
|
|
3518
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
3519
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
3520
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
3521
|
+
|
|
3522
|
+
const plugin = createMockPlugin();
|
|
3523
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
3524
|
+
initializeTabUI(tab, plugin);
|
|
3525
|
+
|
|
3526
|
+
// Simulate bound Claude tab
|
|
3527
|
+
tab.lifecycleState = 'bound_cold';
|
|
3528
|
+
tab.providerId = 'claude';
|
|
3529
|
+
tab.conversationId = 'conv-1';
|
|
3530
|
+
|
|
3531
|
+
// Get the onModelChange callback from toolbar
|
|
3532
|
+
const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as {
|
|
3533
|
+
createInputToolbar: jest.Mock;
|
|
3534
|
+
};
|
|
3535
|
+
const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1];
|
|
3536
|
+
expect(toolbarCallbacks).toBeDefined();
|
|
3537
|
+
|
|
3538
|
+
// Attempt cross-provider model change (Claude -> Codex)
|
|
3539
|
+
await toolbarCallbacks.onModelChange(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
3540
|
+
|
|
3541
|
+
// Should show a Notice rejecting it
|
|
3542
|
+
expect(Notice).toHaveBeenCalledWith(expect.stringContaining('Cannot switch provider'));
|
|
3543
|
+
// Provider should remain Claude
|
|
3544
|
+
expect(tab.providerId).toBe('claude');
|
|
3545
|
+
});
|
|
3546
|
+
|
|
3547
|
+
it('allows same-provider model change on bound tab', async () => {
|
|
3548
|
+
(Notice as unknown as jest.Mock).mockClear();
|
|
3549
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
3550
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
3551
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
3552
|
+
jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockReturnValue({
|
|
3553
|
+
getModelOptions: jest.fn().mockReturnValue([]),
|
|
3554
|
+
ownsModel: jest.fn((model: string) => model.startsWith('gpt-') || /^o\d/.test(model)),
|
|
3555
|
+
isAdaptiveReasoningModel: jest.fn().mockReturnValue(false),
|
|
3556
|
+
getReasoningOptions: jest.fn().mockReturnValue([]),
|
|
3557
|
+
getDefaultReasoningValue: jest.fn().mockReturnValue('off'),
|
|
3558
|
+
getContextWindowSize: jest.fn().mockReturnValue(200000),
|
|
3559
|
+
isDefaultModel: jest.fn().mockReturnValue(false),
|
|
3560
|
+
applyModelDefaults: jest.fn(),
|
|
3561
|
+
normalizeModelVariant: jest.fn((model: string) => model),
|
|
3562
|
+
getCustomModelIds: jest.fn().mockReturnValue(new Set()),
|
|
3563
|
+
} as any);
|
|
3564
|
+
|
|
3565
|
+
const plugin = createMockPlugin();
|
|
3566
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
3567
|
+
initializeTabUI(tab, plugin);
|
|
3568
|
+
|
|
3569
|
+
// Simulate bound Claude tab
|
|
3570
|
+
tab.lifecycleState = 'bound_cold';
|
|
3571
|
+
tab.providerId = 'claude';
|
|
3572
|
+
tab.conversationId = 'conv-1';
|
|
3573
|
+
|
|
3574
|
+
const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as {
|
|
3575
|
+
createInputToolbar: jest.Mock;
|
|
3576
|
+
};
|
|
3577
|
+
const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1];
|
|
3578
|
+
|
|
3579
|
+
// Same-provider model change (Claude -> Claude)
|
|
3580
|
+
await toolbarCallbacks.onModelChange('opus');
|
|
3581
|
+
|
|
3582
|
+
expect(Notice).not.toHaveBeenCalled();
|
|
3583
|
+
expect(plugin.saveSettings).toHaveBeenCalled();
|
|
3584
|
+
});
|
|
3585
|
+
});
|
|
3586
|
+
|
|
3587
|
+
describe('Tab - Blank Tab Draft Model Change', () => {
|
|
3588
|
+
it('updates draft model and provider without creating runtime', async () => {
|
|
3589
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
3590
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
3591
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
3592
|
+
jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockReturnValue({
|
|
3593
|
+
getModelOptions: jest.fn().mockReturnValue([]),
|
|
3594
|
+
ownsModel: jest.fn((model: string) => model.startsWith('gpt-') || /^o\d/.test(model)),
|
|
3595
|
+
isAdaptiveReasoningModel: jest.fn().mockReturnValue(false),
|
|
3596
|
+
getReasoningOptions: jest.fn().mockReturnValue([]),
|
|
3597
|
+
getDefaultReasoningValue: jest.fn().mockReturnValue('off'),
|
|
3598
|
+
getContextWindowSize: jest.fn().mockReturnValue(200000),
|
|
3599
|
+
isDefaultModel: jest.fn().mockReturnValue(false),
|
|
3600
|
+
applyModelDefaults: jest.fn(),
|
|
3601
|
+
normalizeModelVariant: jest.fn((model: string) => model),
|
|
3602
|
+
getCustomModelIds: jest.fn().mockReturnValue(new Set()),
|
|
3603
|
+
} as any);
|
|
3604
|
+
|
|
3605
|
+
const plugin = createMockPlugin();
|
|
3606
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
3607
|
+
initializeTabUI(tab, plugin);
|
|
3608
|
+
|
|
3609
|
+
expect(tab.lifecycleState).toBe('blank');
|
|
3610
|
+
expect(tab.service).toBeNull();
|
|
3611
|
+
|
|
3612
|
+
const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as {
|
|
3613
|
+
createInputToolbar: jest.Mock;
|
|
3614
|
+
};
|
|
3615
|
+
const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1];
|
|
3616
|
+
|
|
3617
|
+
// Switch to Codex model on blank tab
|
|
3618
|
+
await toolbarCallbacks.onModelChange(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
3619
|
+
|
|
3620
|
+
expect(tab.draftModel).toBe(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
3621
|
+
expect(tab.providerId).toBe('codex');
|
|
3622
|
+
// No runtime should have been created
|
|
3623
|
+
expect(tab.service).toBeNull();
|
|
3624
|
+
expect(tab.serviceInitialized).toBe(false);
|
|
3625
|
+
expect(tab.lifecycleState).toBe('blank');
|
|
3626
|
+
});
|
|
3627
|
+
|
|
3628
|
+
it('refreshes the service-tier toggle when the model changes on a blank tab', async () => {
|
|
3629
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
3630
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
3631
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
3632
|
+
jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockReturnValue({
|
|
3633
|
+
getModelOptions: jest.fn().mockReturnValue([]),
|
|
3634
|
+
ownsModel: jest.fn((model: string) => model.startsWith('gpt-') || /^o\d/.test(model)),
|
|
3635
|
+
isAdaptiveReasoningModel: jest.fn().mockReturnValue(false),
|
|
3636
|
+
getReasoningOptions: jest.fn().mockReturnValue([]),
|
|
3637
|
+
getDefaultReasoningValue: jest.fn().mockReturnValue('off'),
|
|
3638
|
+
getContextWindowSize: jest.fn().mockReturnValue(200000),
|
|
3639
|
+
isDefaultModel: jest.fn().mockReturnValue(false),
|
|
3640
|
+
applyModelDefaults: jest.fn(),
|
|
3641
|
+
normalizeModelVariant: jest.fn((model: string) => model),
|
|
3642
|
+
getCustomModelIds: jest.fn().mockReturnValue(new Set()),
|
|
3643
|
+
} as any);
|
|
3644
|
+
|
|
3645
|
+
const plugin = createMockPlugin();
|
|
3646
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
3647
|
+
initializeTabUI(tab, plugin);
|
|
3648
|
+
|
|
3649
|
+
const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as {
|
|
3650
|
+
createInputToolbar: jest.Mock;
|
|
3651
|
+
};
|
|
3652
|
+
const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1];
|
|
3653
|
+
|
|
3654
|
+
mockServiceTierToggle.updateDisplay.mockClear();
|
|
3655
|
+
|
|
3656
|
+
await toolbarCallbacks.onModelChange(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
3657
|
+
|
|
3658
|
+
expect(mockServiceTierToggle.updateDisplay).toHaveBeenCalled();
|
|
3659
|
+
});
|
|
3660
|
+
|
|
3661
|
+
it('awaits async provider warmup callbacks before resolving blank-tab provider changes', async () => {
|
|
3662
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
3663
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
3664
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
3665
|
+
|
|
3666
|
+
const plugin = createMockPlugin();
|
|
3667
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
3668
|
+
let releaseWarmup!: () => void;
|
|
3669
|
+
const onProviderChanged = jest.fn().mockImplementation(() => new Promise<void>((resolve) => {
|
|
3670
|
+
releaseWarmup = resolve;
|
|
3671
|
+
}));
|
|
3672
|
+
initializeTabUI(tab, plugin, { onProviderChanged });
|
|
3673
|
+
|
|
3674
|
+
const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as {
|
|
3675
|
+
createInputToolbar: jest.Mock;
|
|
3676
|
+
};
|
|
3677
|
+
const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1];
|
|
3678
|
+
|
|
3679
|
+
let settled = false;
|
|
3680
|
+
const changePromise = toolbarCallbacks.onModelChange('gpt-5.4')
|
|
3681
|
+
.then(() => { settled = true; });
|
|
3682
|
+
|
|
3683
|
+
await Promise.resolve();
|
|
3684
|
+
await Promise.resolve();
|
|
3685
|
+
|
|
3686
|
+
expect(onProviderChanged).toHaveBeenCalledWith('codex');
|
|
3687
|
+
expect(settled).toBe(false);
|
|
3688
|
+
|
|
3689
|
+
releaseWarmup();
|
|
3690
|
+
await changePromise;
|
|
3691
|
+
|
|
3692
|
+
expect(settled).toBe(true);
|
|
3693
|
+
});
|
|
3694
|
+
|
|
3695
|
+
it('does not trigger provider warmup when a blank-tab model switch stays on OpenCode', async () => {
|
|
3696
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
3697
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
3698
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
3699
|
+
jest.spyOn(ProviderRegistry, 'resolveProviderForModel').mockImplementation((model: string) => {
|
|
3700
|
+
if (model.startsWith('opencode:')) {
|
|
3701
|
+
return 'opencode';
|
|
3702
|
+
}
|
|
3703
|
+
if (model.startsWith('gpt-') || /^o\d/.test(model)) {
|
|
3704
|
+
return 'codex';
|
|
3705
|
+
}
|
|
3706
|
+
return 'claude';
|
|
3707
|
+
});
|
|
3708
|
+
|
|
3709
|
+
const plugin = createMockPlugin();
|
|
3710
|
+
plugin.settings.providerConfigs = {
|
|
3711
|
+
opencode: {
|
|
3712
|
+
enabled: true,
|
|
3713
|
+
},
|
|
3714
|
+
};
|
|
3715
|
+
plugin.settings.savedProviderModel = {
|
|
3716
|
+
...plugin.settings.savedProviderModel,
|
|
3717
|
+
opencode: 'opencode:openai/gpt-5',
|
|
3718
|
+
};
|
|
3719
|
+
|
|
3720
|
+
const tab = createTab(createMockOptions({
|
|
3721
|
+
draftModel: 'opencode:openai/gpt-5',
|
|
3722
|
+
plugin,
|
|
3723
|
+
}));
|
|
3724
|
+
|
|
3725
|
+
let releaseWarmup!: () => void;
|
|
3726
|
+
const onProviderChanged = jest.fn().mockImplementation(() => new Promise<void>((resolve) => {
|
|
3727
|
+
releaseWarmup = resolve;
|
|
3728
|
+
}));
|
|
3729
|
+
initializeTabUI(tab, plugin, { onProviderChanged });
|
|
3730
|
+
|
|
3731
|
+
const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as {
|
|
3732
|
+
createInputToolbar: jest.Mock;
|
|
3733
|
+
};
|
|
3734
|
+
const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1];
|
|
3735
|
+
|
|
3736
|
+
let settled = false;
|
|
3737
|
+
const changePromise = toolbarCallbacks.onModelChange('opencode:anthropic/claude-sonnet-4')
|
|
3738
|
+
.then(() => { settled = true; });
|
|
3739
|
+
|
|
3740
|
+
await Promise.resolve();
|
|
3741
|
+
await Promise.resolve();
|
|
3742
|
+
|
|
3743
|
+
expect(tab.providerId).toBe('opencode');
|
|
3744
|
+
expect(tab.draftModel).toBe('opencode:anthropic/claude-sonnet-4');
|
|
3745
|
+
expect(onProviderChanged).not.toHaveBeenCalled();
|
|
3746
|
+
|
|
3747
|
+
await changePromise;
|
|
3748
|
+
expect(settled).toBe(true);
|
|
3749
|
+
|
|
3750
|
+
if (releaseWarmup) {
|
|
3751
|
+
releaseWarmup();
|
|
3752
|
+
}
|
|
3753
|
+
});
|
|
3754
|
+
|
|
3755
|
+
it('preserves the saved Codex fast preference when switching away and back', async () => {
|
|
3756
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
3757
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
3758
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
3759
|
+
|
|
3760
|
+
const plugin = createMockPlugin();
|
|
3761
|
+
plugin.settings.settingsProvider = 'codex';
|
|
3762
|
+
plugin.settings.model = DEFAULT_CODEX_PRIMARY_MODEL;
|
|
3763
|
+
plugin.settings.effortLevel = 'medium';
|
|
3764
|
+
plugin.settings.serviceTier = 'fast';
|
|
3765
|
+
plugin.settings.savedProviderModel = {
|
|
3766
|
+
claude: 'claude-sonnet-4-5',
|
|
3767
|
+
codex: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
3768
|
+
};
|
|
3769
|
+
plugin.settings.savedProviderEffort = {
|
|
3770
|
+
claude: 'high',
|
|
3771
|
+
codex: 'medium',
|
|
3772
|
+
};
|
|
3773
|
+
plugin.settings.savedProviderServiceTier = {
|
|
3774
|
+
claude: 'default',
|
|
3775
|
+
codex: 'fast',
|
|
3776
|
+
};
|
|
3777
|
+
plugin.settings.savedProviderThinkingBudget = {
|
|
3778
|
+
claude: 'low',
|
|
3779
|
+
codex: 'off',
|
|
3780
|
+
};
|
|
3781
|
+
|
|
3782
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
3783
|
+
initializeTabUI(tab, plugin);
|
|
3784
|
+
|
|
3785
|
+
const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as {
|
|
3786
|
+
createInputToolbar: jest.Mock;
|
|
3787
|
+
};
|
|
3788
|
+
const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1];
|
|
3789
|
+
|
|
3790
|
+
await toolbarCallbacks.onModelChange('gpt-5.4-mini');
|
|
3791
|
+
expect(plugin.settings.savedProviderServiceTier.codex).toBe('fast');
|
|
3792
|
+
|
|
3793
|
+
await toolbarCallbacks.onModelChange(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
3794
|
+
expect(plugin.settings.savedProviderServiceTier.codex).toBe('fast');
|
|
3795
|
+
});
|
|
3796
|
+
|
|
3797
|
+
it('swaps dropdown provider catalog on blank tab model change', async () => {
|
|
3798
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
3799
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
3800
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
3801
|
+
jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockReturnValue({
|
|
3802
|
+
getModelOptions: jest.fn().mockReturnValue([]),
|
|
3803
|
+
ownsModel: jest.fn((model: string) => model.startsWith('gpt-') || /^o\d/.test(model)),
|
|
3804
|
+
isAdaptiveReasoningModel: jest.fn().mockReturnValue(false),
|
|
3805
|
+
getReasoningOptions: jest.fn().mockReturnValue([]),
|
|
3806
|
+
getDefaultReasoningValue: jest.fn().mockReturnValue('off'),
|
|
3807
|
+
getContextWindowSize: jest.fn().mockReturnValue(200000),
|
|
3808
|
+
isDefaultModel: jest.fn().mockReturnValue(false),
|
|
3809
|
+
applyModelDefaults: jest.fn(),
|
|
3810
|
+
normalizeModelVariant: jest.fn((model: string) => model),
|
|
3811
|
+
getCustomModelIds: jest.fn().mockReturnValue(new Set()),
|
|
3812
|
+
} as any);
|
|
3813
|
+
|
|
3814
|
+
const codexCatalog = {
|
|
3815
|
+
listDropdownEntries: jest.fn().mockResolvedValue([]),
|
|
3816
|
+
listVaultEntries: jest.fn(),
|
|
3817
|
+
saveVaultEntry: jest.fn(),
|
|
3818
|
+
deleteVaultEntry: jest.fn(),
|
|
3819
|
+
getDropdownConfig: jest.fn().mockReturnValue({
|
|
3820
|
+
triggerChars: ['/', '$'],
|
|
3821
|
+
builtInPrefix: '/',
|
|
3822
|
+
skillPrefix: '$',
|
|
3823
|
+
commandPrefix: '/',
|
|
3824
|
+
}),
|
|
3825
|
+
refresh: jest.fn(),
|
|
3826
|
+
};
|
|
3827
|
+
const managerGetEntries = jest.fn().mockResolvedValue([
|
|
3828
|
+
{
|
|
3829
|
+
id: 'codex-skill-analyze',
|
|
3830
|
+
providerId: 'codex',
|
|
3831
|
+
kind: 'skill',
|
|
3832
|
+
name: 'analyze',
|
|
3833
|
+
description: 'Analyze',
|
|
3834
|
+
content: 'Analyze code',
|
|
3835
|
+
scope: 'vault',
|
|
3836
|
+
source: 'user',
|
|
3837
|
+
isEditable: true,
|
|
3838
|
+
isDeletable: true,
|
|
3839
|
+
displayPrefix: '$',
|
|
3840
|
+
insertPrefix: '$',
|
|
3841
|
+
},
|
|
3842
|
+
]);
|
|
3843
|
+
|
|
3844
|
+
ProviderWorkspaceRegistry.setServices('codex', { commandCatalog: codexCatalog as any });
|
|
3845
|
+
|
|
3846
|
+
const plugin = createMockPlugin();
|
|
3847
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
3848
|
+
initializeTabUI(tab, plugin, {
|
|
3849
|
+
getProviderCatalogConfig: () => (
|
|
3850
|
+
tab.providerId === 'codex'
|
|
3851
|
+
? {
|
|
3852
|
+
config: codexCatalog.getDropdownConfig(),
|
|
3853
|
+
getEntries: managerGetEntries,
|
|
3854
|
+
}
|
|
3855
|
+
: null
|
|
3856
|
+
),
|
|
3857
|
+
});
|
|
3858
|
+
|
|
3859
|
+
// Mock setProviderCatalog on the dropdown
|
|
3860
|
+
const setProviderCatalogSpy = jest.fn();
|
|
3861
|
+
tab.ui.slashCommandDropdown!.setProviderCatalog = setProviderCatalogSpy;
|
|
3862
|
+
|
|
3863
|
+
const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as {
|
|
3864
|
+
createInputToolbar: jest.Mock;
|
|
3865
|
+
};
|
|
3866
|
+
const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1];
|
|
3867
|
+
|
|
3868
|
+
// Switch to Codex model → should swap catalog
|
|
3869
|
+
await toolbarCallbacks.onModelChange(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
3870
|
+
|
|
3871
|
+
expect(setProviderCatalogSpy).toHaveBeenCalledTimes(1);
|
|
3872
|
+
const [config, getEntries] = setProviderCatalogSpy.mock.calls[0];
|
|
3873
|
+
expect(config.triggerChars).toEqual(['/', '$']);
|
|
3874
|
+
expect(config.skillPrefix).toBe('$');
|
|
3875
|
+
expect(typeof getEntries).toBe('function');
|
|
3876
|
+
await getEntries();
|
|
3877
|
+
expect(managerGetEntries).toHaveBeenCalledTimes(1);
|
|
3878
|
+
expect(codexCatalog.listDropdownEntries).not.toHaveBeenCalled();
|
|
3879
|
+
});
|
|
3880
|
+
|
|
3881
|
+
it('updates hidden commands on blank tab model change', async () => {
|
|
3882
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
3883
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
3884
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
3885
|
+
jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockReturnValue({
|
|
3886
|
+
getModelOptions: jest.fn().mockReturnValue([]),
|
|
3887
|
+
ownsModel: jest.fn((model: string) => model.startsWith('gpt-') || /^o\d/.test(model)),
|
|
3888
|
+
isAdaptiveReasoningModel: jest.fn().mockReturnValue(false),
|
|
3889
|
+
getReasoningOptions: jest.fn().mockReturnValue([]),
|
|
3890
|
+
getDefaultReasoningValue: jest.fn().mockReturnValue('off'),
|
|
3891
|
+
getContextWindowSize: jest.fn().mockReturnValue(200000),
|
|
3892
|
+
isDefaultModel: jest.fn().mockReturnValue(false),
|
|
3893
|
+
applyModelDefaults: jest.fn(),
|
|
3894
|
+
normalizeModelVariant: jest.fn((model: string) => model),
|
|
3895
|
+
getCustomModelIds: jest.fn().mockReturnValue(new Set()),
|
|
3896
|
+
} as any);
|
|
3897
|
+
|
|
3898
|
+
const codexCatalog = {
|
|
3899
|
+
listDropdownEntries: jest.fn().mockResolvedValue([]),
|
|
3900
|
+
listVaultEntries: jest.fn(),
|
|
3901
|
+
saveVaultEntry: jest.fn(),
|
|
3902
|
+
deleteVaultEntry: jest.fn(),
|
|
3903
|
+
getDropdownConfig: jest.fn().mockReturnValue({
|
|
3904
|
+
providerId: 'codex',
|
|
3905
|
+
triggerChars: ['/', '$'],
|
|
3906
|
+
builtInPrefix: '/',
|
|
3907
|
+
skillPrefix: '$',
|
|
3908
|
+
commandPrefix: '/',
|
|
3909
|
+
}),
|
|
3910
|
+
refresh: jest.fn(),
|
|
3911
|
+
};
|
|
3912
|
+
|
|
3913
|
+
ProviderWorkspaceRegistry.setServices('codex', { commandCatalog: codexCatalog as any });
|
|
3914
|
+
|
|
3915
|
+
const plugin = createMockPlugin({
|
|
3916
|
+
settings: {
|
|
3917
|
+
excludedTags: [],
|
|
3918
|
+
model: 'claude-sonnet-4-5',
|
|
3919
|
+
thinkingBudget: 'low',
|
|
3920
|
+
effortLevel: 'high',
|
|
3921
|
+
permissionMode: 'yolo',
|
|
3922
|
+
keyboardNavigation: {
|
|
3923
|
+
scrollUpKey: 'k',
|
|
3924
|
+
scrollDownKey: 'j',
|
|
3925
|
+
focusInputKey: 'i',
|
|
3926
|
+
},
|
|
3927
|
+
persistentExternalContextPaths: [],
|
|
3928
|
+
settingsProvider: 'claude',
|
|
3929
|
+
codexEnabled: true,
|
|
3930
|
+
savedProviderModel: {
|
|
3931
|
+
claude: 'claude-sonnet-4-5',
|
|
3932
|
+
codex: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
3933
|
+
},
|
|
3934
|
+
savedProviderEffort: {
|
|
3935
|
+
claude: 'high',
|
|
3936
|
+
codex: 'medium',
|
|
3937
|
+
},
|
|
3938
|
+
savedProviderThinkingBudget: {
|
|
3939
|
+
claude: 'low',
|
|
3940
|
+
codex: 'off',
|
|
3941
|
+
},
|
|
3942
|
+
hiddenProviderCommands: {
|
|
3943
|
+
claude: ['commit'],
|
|
3944
|
+
codex: ['analyze'],
|
|
3945
|
+
},
|
|
3946
|
+
},
|
|
3947
|
+
});
|
|
3948
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
3949
|
+
initializeTabUI(tab, plugin);
|
|
3950
|
+
|
|
3951
|
+
const setProviderCatalogSpy = jest.fn();
|
|
3952
|
+
const setHiddenCommandsSpy = jest.fn();
|
|
3953
|
+
tab.ui.slashCommandDropdown!.setProviderCatalog = setProviderCatalogSpy;
|
|
3954
|
+
tab.ui.slashCommandDropdown!.setHiddenCommands = setHiddenCommandsSpy;
|
|
3955
|
+
|
|
3956
|
+
const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as {
|
|
3957
|
+
createInputToolbar: jest.Mock;
|
|
3958
|
+
};
|
|
3959
|
+
const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1];
|
|
3960
|
+
|
|
3961
|
+
await toolbarCallbacks.onModelChange(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
3962
|
+
|
|
3963
|
+
expect(setHiddenCommandsSpy).toHaveBeenCalledWith(new Set(['analyze']));
|
|
3964
|
+
});
|
|
3965
|
+
|
|
3966
|
+
it('rebinds provider helper services and clears stale runtime on blank tab provider change', async () => {
|
|
3967
|
+
const createInstructionRefineServiceSpy = jest.spyOn(ProviderRegistry, 'createInstructionRefineService')
|
|
3968
|
+
.mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
3969
|
+
const createTitleGenerationServiceSpy = jest.spyOn(ProviderRegistry, 'createTitleGenerationService')
|
|
3970
|
+
.mockReturnValue({ cancel: jest.fn() } as any);
|
|
3971
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
3972
|
+
jest.spyOn(ProviderRegistry, 'getChatUIConfig').mockReturnValue({
|
|
3973
|
+
getModelOptions: jest.fn().mockReturnValue([]),
|
|
3974
|
+
ownsModel: jest.fn((model: string) => model.startsWith('gpt-') || /^o\d/.test(model)),
|
|
3975
|
+
isAdaptiveReasoningModel: jest.fn().mockReturnValue(false),
|
|
3976
|
+
getReasoningOptions: jest.fn().mockReturnValue([]),
|
|
3977
|
+
getDefaultReasoningValue: jest.fn().mockReturnValue('off'),
|
|
3978
|
+
getContextWindowSize: jest.fn().mockReturnValue(200000),
|
|
3979
|
+
isDefaultModel: jest.fn().mockReturnValue(false),
|
|
3980
|
+
applyModelDefaults: jest.fn(),
|
|
3981
|
+
normalizeModelVariant: jest.fn((model: string) => model),
|
|
3982
|
+
getCustomModelIds: jest.fn().mockReturnValue(new Set()),
|
|
3983
|
+
} as any);
|
|
3984
|
+
|
|
3985
|
+
const plugin = createMockPlugin();
|
|
3986
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
3987
|
+
initializeTabUI(tab, plugin);
|
|
3988
|
+
|
|
3989
|
+
const staleService = createMockClaudianService({ providerId: 'codex' });
|
|
3990
|
+
tab.service = staleService as any;
|
|
3991
|
+
tab.serviceInitialized = false;
|
|
3992
|
+
|
|
3993
|
+
const toolbarModule = jest.requireMock('@/features/chat/ui/InputToolbar') as {
|
|
3994
|
+
createInputToolbar: jest.Mock;
|
|
3995
|
+
};
|
|
3996
|
+
const toolbarCallbacks = toolbarModule.createInputToolbar.mock.calls.at(-1)?.[1];
|
|
3997
|
+
|
|
3998
|
+
const initialInstructionCalls = createInstructionRefineServiceSpy.mock.calls.length;
|
|
3999
|
+
const initialTitleCalls = createTitleGenerationServiceSpy.mock.calls.length;
|
|
4000
|
+
|
|
4001
|
+
await toolbarCallbacks.onModelChange(DEFAULT_CODEX_PRIMARY_MODEL);
|
|
4002
|
+
await toolbarCallbacks.onModelChange('opus');
|
|
4003
|
+
|
|
4004
|
+
expect(staleService.cleanup).toHaveBeenCalledTimes(1);
|
|
4005
|
+
expect(tab.service).toBeNull();
|
|
4006
|
+
expect(tab.serviceInitialized).toBe(false);
|
|
4007
|
+
expect(tab.providerId).toBe('claude');
|
|
4008
|
+
expect(createInstructionRefineServiceSpy.mock.calls.length).toBeGreaterThan(initialInstructionCalls);
|
|
4009
|
+
expect(createTitleGenerationServiceSpy.mock.calls.length).toBe(initialTitleCalls);
|
|
4010
|
+
});
|
|
4011
|
+
});
|
|
4012
|
+
|
|
4013
|
+
describe('Tab - First Send Binding', () => {
|
|
4014
|
+
it('derives provider from draft model on first send (Claude)', async () => {
|
|
4015
|
+
const mockEnsureReady = jest.fn().mockResolvedValue(true);
|
|
4016
|
+
const runtimeModule = jest.requireMock('@/providers/claude/runtime/ClaudeChatRuntime') as { ClaudianService: jest.Mock };
|
|
4017
|
+
runtimeModule.ClaudianService.mockImplementationOnce(() => createMockClaudianService({ ensureReady: mockEnsureReady }));
|
|
4018
|
+
const createChatRuntimeSpy = jest.spyOn(ProviderRegistry, 'createChatRuntime')
|
|
4019
|
+
.mockReturnValue(createMockClaudianService() as any);
|
|
4020
|
+
|
|
4021
|
+
const plugin = createMockPlugin();
|
|
4022
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
4023
|
+
|
|
4024
|
+
tab.draftModel = 'sonnet';
|
|
4025
|
+
tab.lifecycleState = 'blank';
|
|
4026
|
+
|
|
4027
|
+
await initializeTabService(tab, plugin, createMockMcpManager());
|
|
4028
|
+
|
|
4029
|
+
expect(createChatRuntimeSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
4030
|
+
providerId: 'claude',
|
|
4031
|
+
}));
|
|
4032
|
+
expect(tab.lifecycleState).toBe('bound_active');
|
|
4033
|
+
expect(tab.draftModel).toBeNull();
|
|
4034
|
+
});
|
|
4035
|
+
|
|
4036
|
+
it('derives provider from draft model on first send (Codex)', async () => {
|
|
4037
|
+
const createChatRuntimeSpy = jest.spyOn(ProviderRegistry, 'createChatRuntime')
|
|
4038
|
+
.mockReturnValue(createMockClaudianService({ providerId: 'codex' }) as any);
|
|
4039
|
+
|
|
4040
|
+
const plugin = createMockPlugin();
|
|
4041
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
4042
|
+
|
|
4043
|
+
tab.draftModel = DEFAULT_CODEX_PRIMARY_MODEL;
|
|
4044
|
+
tab.providerId = 'codex';
|
|
4045
|
+
tab.lifecycleState = 'blank';
|
|
4046
|
+
|
|
4047
|
+
await initializeTabService(tab, plugin, createMockMcpManager());
|
|
4048
|
+
|
|
4049
|
+
expect(createChatRuntimeSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
4050
|
+
providerId: 'codex',
|
|
4051
|
+
}));
|
|
4052
|
+
expect(tab.lifecycleState).toBe('bound_active');
|
|
4053
|
+
expect(tab.draftModel).toBeNull();
|
|
4054
|
+
});
|
|
4055
|
+
});
|
|
4056
|
+
|
|
4057
|
+
describe('Tab - History Bind Without Runtime', () => {
|
|
4058
|
+
it('ensureServiceForConversation binds to bound_cold without starting runtime', async () => {
|
|
4059
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
4060
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
4061
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
4062
|
+
|
|
4063
|
+
const plugin = createMockPlugin();
|
|
4064
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
4065
|
+
initializeTabUI(tab, plugin);
|
|
4066
|
+
initializeTabControllers(tab, plugin, {} as any, createMockMcpManager());
|
|
4067
|
+
|
|
4068
|
+
const convCtrlModule = jest.requireMock('@/features/chat/controllers/ConversationController') as {
|
|
4069
|
+
ConversationController: jest.Mock;
|
|
4070
|
+
};
|
|
4071
|
+
const deps = convCtrlModule.ConversationController.mock.calls.at(-1)?.[0];
|
|
4072
|
+
const ensureServiceForConversation = deps?.ensureServiceForConversation;
|
|
4073
|
+
|
|
4074
|
+
const conversation = {
|
|
4075
|
+
id: 'conv-history',
|
|
4076
|
+
providerId: 'codex' as const,
|
|
4077
|
+
messages: [{ id: 'msg-1', role: 'user' as const, content: 'hi', timestamp: Date.now() }],
|
|
4078
|
+
};
|
|
4079
|
+
|
|
4080
|
+
await ensureServiceForConversation(conversation);
|
|
4081
|
+
|
|
4082
|
+
expect(tab.lifecycleState).toBe('bound_cold');
|
|
4083
|
+
expect(tab.providerId).toBe('codex');
|
|
4084
|
+
expect(tab.conversationId).toBe('conv-history');
|
|
4085
|
+
expect(tab.draftModel).toBeNull();
|
|
4086
|
+
// No runtime created
|
|
4087
|
+
expect(tab.serviceInitialized).toBe(false);
|
|
4088
|
+
});
|
|
4089
|
+
|
|
4090
|
+
it('ensureServiceForConversation updates hidden commands when the provider changes', async () => {
|
|
4091
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
4092
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
4093
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
4094
|
+
|
|
4095
|
+
const codexCatalog = {
|
|
4096
|
+
listDropdownEntries: jest.fn().mockResolvedValue([]),
|
|
4097
|
+
listVaultEntries: jest.fn(),
|
|
4098
|
+
saveVaultEntry: jest.fn(),
|
|
4099
|
+
deleteVaultEntry: jest.fn(),
|
|
4100
|
+
getDropdownConfig: jest.fn().mockReturnValue({
|
|
4101
|
+
providerId: 'codex',
|
|
4102
|
+
triggerChars: ['/', '$'],
|
|
4103
|
+
builtInPrefix: '/',
|
|
4104
|
+
skillPrefix: '$',
|
|
4105
|
+
commandPrefix: '/',
|
|
4106
|
+
}),
|
|
4107
|
+
refresh: jest.fn(),
|
|
4108
|
+
};
|
|
4109
|
+
const managerGetEntries = jest.fn().mockResolvedValue([
|
|
4110
|
+
{
|
|
4111
|
+
id: 'codex-skill-analyze',
|
|
4112
|
+
providerId: 'codex',
|
|
4113
|
+
kind: 'skill',
|
|
4114
|
+
name: 'analyze',
|
|
4115
|
+
description: 'Analyze',
|
|
4116
|
+
content: 'Analyze code',
|
|
4117
|
+
scope: 'vault',
|
|
4118
|
+
source: 'user',
|
|
4119
|
+
isEditable: true,
|
|
4120
|
+
isDeletable: true,
|
|
4121
|
+
displayPrefix: '$',
|
|
4122
|
+
insertPrefix: '$',
|
|
4123
|
+
},
|
|
4124
|
+
]);
|
|
4125
|
+
ProviderWorkspaceRegistry.setServices('codex', { commandCatalog: codexCatalog as any });
|
|
4126
|
+
|
|
4127
|
+
const plugin = createMockPlugin({
|
|
4128
|
+
settings: {
|
|
4129
|
+
excludedTags: [],
|
|
4130
|
+
model: 'claude-sonnet-4-5',
|
|
4131
|
+
thinkingBudget: 'low',
|
|
4132
|
+
effortLevel: 'high',
|
|
4133
|
+
permissionMode: 'yolo',
|
|
4134
|
+
keyboardNavigation: {
|
|
4135
|
+
scrollUpKey: 'k',
|
|
4136
|
+
scrollDownKey: 'j',
|
|
4137
|
+
focusInputKey: 'i',
|
|
4138
|
+
},
|
|
4139
|
+
persistentExternalContextPaths: [],
|
|
4140
|
+
settingsProvider: 'claude',
|
|
4141
|
+
codexEnabled: true,
|
|
4142
|
+
savedProviderModel: {
|
|
4143
|
+
claude: 'claude-sonnet-4-5',
|
|
4144
|
+
codex: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
4145
|
+
},
|
|
4146
|
+
savedProviderEffort: {
|
|
4147
|
+
claude: 'high',
|
|
4148
|
+
codex: 'medium',
|
|
4149
|
+
},
|
|
4150
|
+
savedProviderThinkingBudget: {
|
|
4151
|
+
claude: 'low',
|
|
4152
|
+
codex: 'off',
|
|
4153
|
+
},
|
|
4154
|
+
hiddenProviderCommands: {
|
|
4155
|
+
claude: ['commit'],
|
|
4156
|
+
codex: ['analyze'],
|
|
4157
|
+
},
|
|
4158
|
+
},
|
|
4159
|
+
});
|
|
4160
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
4161
|
+
initializeTabUI(tab, plugin, {
|
|
4162
|
+
getProviderCatalogConfig: () => (
|
|
4163
|
+
tab.providerId === 'codex'
|
|
4164
|
+
? {
|
|
4165
|
+
config: codexCatalog.getDropdownConfig(),
|
|
4166
|
+
getEntries: managerGetEntries,
|
|
4167
|
+
}
|
|
4168
|
+
: null
|
|
4169
|
+
),
|
|
4170
|
+
});
|
|
4171
|
+
initializeTabControllers(
|
|
4172
|
+
tab,
|
|
4173
|
+
plugin,
|
|
4174
|
+
{} as any,
|
|
4175
|
+
createMockMcpManager(),
|
|
4176
|
+
undefined,
|
|
4177
|
+
undefined,
|
|
4178
|
+
() => (
|
|
4179
|
+
tab.providerId === 'codex'
|
|
4180
|
+
? {
|
|
4181
|
+
config: codexCatalog.getDropdownConfig(),
|
|
4182
|
+
getEntries: managerGetEntries,
|
|
4183
|
+
}
|
|
4184
|
+
: null
|
|
4185
|
+
),
|
|
4186
|
+
);
|
|
4187
|
+
|
|
4188
|
+
const setProviderCatalogSpy = jest.fn();
|
|
4189
|
+
const setHiddenCommandsSpy = jest.fn();
|
|
4190
|
+
tab.ui.slashCommandDropdown!.setProviderCatalog = setProviderCatalogSpy;
|
|
4191
|
+
tab.ui.slashCommandDropdown!.setHiddenCommands = setHiddenCommandsSpy;
|
|
4192
|
+
|
|
4193
|
+
const convCtrlModule = jest.requireMock('@/features/chat/controllers/ConversationController') as {
|
|
4194
|
+
ConversationController: jest.Mock;
|
|
4195
|
+
};
|
|
4196
|
+
const deps = convCtrlModule.ConversationController.mock.calls.at(-1)?.[0];
|
|
4197
|
+
const ensureServiceForConversation = deps?.ensureServiceForConversation;
|
|
4198
|
+
|
|
4199
|
+
await ensureServiceForConversation({
|
|
4200
|
+
id: 'conv-history',
|
|
4201
|
+
providerId: 'codex' as const,
|
|
4202
|
+
messages: [{ id: 'msg-1', role: 'user' as const, content: 'hi', timestamp: Date.now() }],
|
|
4203
|
+
});
|
|
4204
|
+
|
|
4205
|
+
expect(setProviderCatalogSpy).toHaveBeenCalledTimes(1);
|
|
4206
|
+
expect(setHiddenCommandsSpy).toHaveBeenCalledWith(new Set(['analyze']));
|
|
4207
|
+
const [, getEntries] = setProviderCatalogSpy.mock.calls[0];
|
|
4208
|
+
await getEntries();
|
|
4209
|
+
expect(managerGetEntries).toHaveBeenCalledTimes(1);
|
|
4210
|
+
expect(codexCatalog.listDropdownEntries).not.toHaveBeenCalled();
|
|
4211
|
+
});
|
|
4212
|
+
});
|
|
4213
|
+
|
|
4214
|
+
describe('Tab - Destroy Lifecycle Transition', () => {
|
|
4215
|
+
it('transitions to closing state and cleans up runtime', async () => {
|
|
4216
|
+
const mockCleanup = jest.fn();
|
|
4217
|
+
const options = createMockOptions();
|
|
4218
|
+
const tab = createTab(options);
|
|
4219
|
+
|
|
4220
|
+
tab.lifecycleState = 'bound_active';
|
|
4221
|
+
tab.service = { cleanup: mockCleanup } as any;
|
|
4222
|
+
tab.serviceInitialized = true;
|
|
4223
|
+
|
|
4224
|
+
await destroyTab(tab);
|
|
4225
|
+
|
|
4226
|
+
expect(tab.lifecycleState).toBe('closing');
|
|
4227
|
+
expect(mockCleanup).toHaveBeenCalled();
|
|
4228
|
+
expect(tab.service).toBeNull();
|
|
4229
|
+
});
|
|
4230
|
+
|
|
4231
|
+
it('does not fail when destroying a blank tab with no runtime', async () => {
|
|
4232
|
+
const options = createMockOptions();
|
|
4233
|
+
const tab = createTab(options);
|
|
4234
|
+
|
|
4235
|
+
expect(tab.lifecycleState).toBe('blank');
|
|
4236
|
+
expect(tab.service).toBeNull();
|
|
4237
|
+
|
|
4238
|
+
await destroyTab(tab);
|
|
4239
|
+
|
|
4240
|
+
expect(tab.lifecycleState).toBe('closing');
|
|
4241
|
+
});
|
|
4242
|
+
|
|
4243
|
+
it('does not fail when destroying a bound_cold tab with no runtime', async () => {
|
|
4244
|
+
const options = createMockOptions({
|
|
4245
|
+
conversation: {
|
|
4246
|
+
id: 'conv-1',
|
|
4247
|
+
providerId: 'claude' as any,
|
|
4248
|
+
title: 'Test',
|
|
4249
|
+
messages: [],
|
|
4250
|
+
sessionId: null,
|
|
4251
|
+
createdAt: Date.now(),
|
|
4252
|
+
updatedAt: Date.now(),
|
|
4253
|
+
},
|
|
4254
|
+
});
|
|
4255
|
+
const tab = createTab(options);
|
|
4256
|
+
|
|
4257
|
+
expect(tab.lifecycleState).toBe('bound_cold');
|
|
4258
|
+
expect(tab.service).toBeNull();
|
|
4259
|
+
|
|
4260
|
+
await destroyTab(tab);
|
|
4261
|
+
|
|
4262
|
+
expect(tab.lifecycleState).toBe('closing');
|
|
4263
|
+
});
|
|
4264
|
+
});
|
|
4265
|
+
|
|
4266
|
+
describe('Tab - InputController getTabProviderId wiring', () => {
|
|
4267
|
+
it('wires getTabProviderId to InputController deps', () => {
|
|
4268
|
+
jest.spyOn(ProviderRegistry, 'createInstructionRefineService').mockReturnValue({ cancel: jest.fn(), resetConversation: jest.fn() } as any);
|
|
4269
|
+
jest.spyOn(ProviderRegistry, 'createTitleGenerationService').mockReturnValue({ cancel: jest.fn() } as any);
|
|
4270
|
+
jest.spyOn(ProviderRegistry, 'getTaskResultInterpreter').mockReturnValue({} as any);
|
|
4271
|
+
|
|
4272
|
+
const plugin = createMockPlugin();
|
|
4273
|
+
const tab = createTab(createMockOptions({ plugin }));
|
|
4274
|
+
initializeTabUI(tab, plugin);
|
|
4275
|
+
initializeTabControllers(tab, plugin, {} as any, createMockMcpManager());
|
|
4276
|
+
|
|
4277
|
+
const { InputController } = jest.requireMock('@/features/chat/controllers/InputController') as { InputController: jest.Mock };
|
|
4278
|
+
const lastCall = InputController.mock.calls[InputController.mock.calls.length - 1];
|
|
4279
|
+
const config = lastCall[0];
|
|
4280
|
+
expect(config.getTabProviderId).toBeDefined();
|
|
4281
|
+
expect(typeof config.getTabProviderId).toBe('function');
|
|
4282
|
+
|
|
4283
|
+
// For a blank tab with default model, should resolve to claude
|
|
4284
|
+
const result = config.getTabProviderId();
|
|
4285
|
+
expect(result).toBe('claude');
|
|
4286
|
+
});
|
|
4287
|
+
});
|