@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.
Files changed (687) hide show
  1. package/.env.local.example +2 -0
  2. package/.github/workflows/ci.yml +59 -0
  3. package/.github/workflows/claude-code-review.yml +57 -0
  4. package/.github/workflows/claude.yml +50 -0
  5. package/.github/workflows/duplicate-issues.yml +22 -0
  6. package/.github/workflows/release.yml +73 -0
  7. package/.github/workflows/stale.yml +21 -0
  8. package/.node-version +1 -0
  9. package/AGENTS.md +3 -0
  10. package/CLAUDE.md +80 -0
  11. package/LICENSE +21 -0
  12. package/README.md +190 -0
  13. package/assets/Preview.png +0 -0
  14. package/assets/sponsors/MOMA.png +0 -0
  15. package/bun.lock +1618 -0
  16. package/esbuild.config.mjs +195 -0
  17. package/eslint.config.mjs +143 -0
  18. package/jest.config.js +41 -0
  19. package/main.js +104915 -0
  20. package/manifest.json +10 -0
  21. package/package.json +65 -0
  22. package/scripts/build-css.mjs +119 -0
  23. package/scripts/build.mjs +19 -0
  24. package/scripts/postinstall.mjs +25 -0
  25. package/scripts/rendererSafeUnref.js +205 -0
  26. package/scripts/run-jest.js +19 -0
  27. package/scripts/sync-version.js +16 -0
  28. package/src/app/settings/ClaudianSettingsStorage.ts +435 -0
  29. package/src/app/settings/defaultSettings.ts +54 -0
  30. package/src/app/storage/SharedStorageService.ts +106 -0
  31. package/src/core/CLAUDE.md +84 -0
  32. package/src/core/auxiliary/AuxQueryRunner.ts +11 -0
  33. package/src/core/auxiliary/QueryBackedInlineEditService.ts +73 -0
  34. package/src/core/auxiliary/QueryBackedInstructionRefineService.ts +78 -0
  35. package/src/core/auxiliary/QueryBackedTitleGenerationService.ts +90 -0
  36. package/src/core/bootstrap/SessionStorage.ts +179 -0
  37. package/src/core/bootstrap/StoragePaths.ts +7 -0
  38. package/src/core/bootstrap/storage.ts +20 -0
  39. package/src/core/commands/builtInCommands.ts +141 -0
  40. package/src/core/mcp/McpConfigParser.ts +102 -0
  41. package/src/core/mcp/McpServerManager.ts +119 -0
  42. package/src/core/mcp/McpTester.ts +310 -0
  43. package/src/core/prompt/inlineEdit.ts +252 -0
  44. package/src/core/prompt/instructionRefine.ts +72 -0
  45. package/src/core/prompt/mainAgent.ts +213 -0
  46. package/src/core/prompt/titleGeneration.ts +44 -0
  47. package/src/core/providers/ProviderRegistry.ts +253 -0
  48. package/src/core/providers/ProviderSettingsCoordinator.ts +434 -0
  49. package/src/core/providers/ProviderWorkspaceRegistry.ts +119 -0
  50. package/src/core/providers/commands/ProviderCommandCatalog.ts +21 -0
  51. package/src/core/providers/commands/ProviderCommandEntry.ts +33 -0
  52. package/src/core/providers/commands/hiddenCommands.ts +74 -0
  53. package/src/core/providers/modelRouting.ts +17 -0
  54. package/src/core/providers/modelSelection.ts +72 -0
  55. package/src/core/providers/providerConfig.ts +34 -0
  56. package/src/core/providers/providerEnvironment.ts +364 -0
  57. package/src/core/providers/types.ts +544 -0
  58. package/src/core/runtime/ChatRuntime.ts +66 -0
  59. package/src/core/runtime/QueuedTurn.ts +97 -0
  60. package/src/core/runtime/types.ts +118 -0
  61. package/src/core/security/ApprovalManager.ts +142 -0
  62. package/src/core/storage/HomeFileAdapter.ts +75 -0
  63. package/src/core/storage/VaultFileAdapter.ts +132 -0
  64. package/src/core/tools/todo.ts +65 -0
  65. package/src/core/tools/toolIcons.ts +80 -0
  66. package/src/core/tools/toolInput.ts +119 -0
  67. package/src/core/tools/toolNames.ts +149 -0
  68. package/src/core/tools/toolResultContent.ts +26 -0
  69. package/src/core/types/agent.ts +28 -0
  70. package/src/core/types/chat.ts +177 -0
  71. package/src/core/types/diff.ts +31 -0
  72. package/src/core/types/index.ts +78 -0
  73. package/src/core/types/mcp.ts +97 -0
  74. package/src/core/types/plugins.ts +9 -0
  75. package/src/core/types/provider.ts +1 -0
  76. package/src/core/types/settings.ts +152 -0
  77. package/src/core/types/tools.ts +80 -0
  78. package/src/features/chat/CLAUDE.md +136 -0
  79. package/src/features/chat/ClaudianView.ts +762 -0
  80. package/src/features/chat/constants.ts +114 -0
  81. package/src/features/chat/controllers/BrowserSelectionController.ts +295 -0
  82. package/src/features/chat/controllers/CanvasSelectionController.ts +142 -0
  83. package/src/features/chat/controllers/ConversationController.ts +1103 -0
  84. package/src/features/chat/controllers/InputController.ts +1707 -0
  85. package/src/features/chat/controllers/NavigationController.ts +209 -0
  86. package/src/features/chat/controllers/SelectionController.ts +430 -0
  87. package/src/features/chat/controllers/StreamController.ts +1560 -0
  88. package/src/features/chat/controllers/contextRowVisibility.ts +18 -0
  89. package/src/features/chat/rendering/DiffRenderer.ts +134 -0
  90. package/src/features/chat/rendering/InlineAskUserQuestion.ts +702 -0
  91. package/src/features/chat/rendering/InlineExitPlanMode.ts +263 -0
  92. package/src/features/chat/rendering/InlinePlanApproval.ts +183 -0
  93. package/src/features/chat/rendering/MessageRenderer.ts +907 -0
  94. package/src/features/chat/rendering/SubagentRenderer.ts +678 -0
  95. package/src/features/chat/rendering/ThinkingBlockRenderer.ts +126 -0
  96. package/src/features/chat/rendering/TodoListRenderer.ts +5 -0
  97. package/src/features/chat/rendering/ToolCallRenderer.ts +1161 -0
  98. package/src/features/chat/rendering/WriteEditRenderer.ts +232 -0
  99. package/src/features/chat/rendering/collapsible.ts +101 -0
  100. package/src/features/chat/rendering/subagentLifecycleResolution.ts +25 -0
  101. package/src/features/chat/rendering/todoUtils.ts +29 -0
  102. package/src/features/chat/rewind.ts +31 -0
  103. package/src/features/chat/services/BangBashService.ts +56 -0
  104. package/src/features/chat/services/SubagentManager.ts +1107 -0
  105. package/src/features/chat/state/ChatState.ts +436 -0
  106. package/src/features/chat/state/types.ts +138 -0
  107. package/src/features/chat/tabs/Tab.ts +1886 -0
  108. package/src/features/chat/tabs/TabBar.ts +179 -0
  109. package/src/features/chat/tabs/TabManager.ts +1021 -0
  110. package/src/features/chat/tabs/providerResolution.ts +34 -0
  111. package/src/features/chat/tabs/types.ts +287 -0
  112. package/src/features/chat/ui/BangBashModeManager.ts +121 -0
  113. package/src/features/chat/ui/FileContext.ts +385 -0
  114. package/src/features/chat/ui/ImageContext.ts +366 -0
  115. package/src/features/chat/ui/InputToolbar.ts +1244 -0
  116. package/src/features/chat/ui/InstructionModeManager.ts +158 -0
  117. package/src/features/chat/ui/NavigationSidebar.ts +126 -0
  118. package/src/features/chat/ui/StatusPanel.ts +589 -0
  119. package/src/features/chat/ui/file-context/state/FileContextState.ts +83 -0
  120. package/src/features/chat/ui/file-context/view/FileChipsView.ts +70 -0
  121. package/src/features/chat/ui/textareaResize.ts +47 -0
  122. package/src/features/chat/utils/usageInfo.ts +26 -0
  123. package/src/features/inline-edit/ui/InlineEditModal.ts +895 -0
  124. package/src/features/inline-edit/ui/inlineEditMarkdownPreview.ts +55 -0
  125. package/src/features/settings/ClaudianSettings.ts +672 -0
  126. package/src/features/settings/keyboardNavigation.ts +60 -0
  127. package/src/features/settings/ui/EnvSnippetManager.ts +430 -0
  128. package/src/features/settings/ui/EnvironmentSettingsSection.ts +85 -0
  129. package/src/features/settings/ui/McpServerModal.ts +335 -0
  130. package/src/features/settings/ui/McpSettingsManager.ts +400 -0
  131. package/src/features/settings/ui/McpTestModal.ts +346 -0
  132. package/src/i18n/constants.ts +58 -0
  133. package/src/i18n/i18n.ts +140 -0
  134. package/src/i18n/locales/de.json +322 -0
  135. package/src/i18n/locales/en.json +322 -0
  136. package/src/i18n/locales/es.json +322 -0
  137. package/src/i18n/locales/fr.json +322 -0
  138. package/src/i18n/locales/ja.json +322 -0
  139. package/src/i18n/locales/ko.json +322 -0
  140. package/src/i18n/locales/pt.json +322 -0
  141. package/src/i18n/locales/ru.json +322 -0
  142. package/src/i18n/locales/zh-CN.json +322 -0
  143. package/src/i18n/locales/zh-TW.json +322 -0
  144. package/src/i18n/types.ts +248 -0
  145. package/src/main.ts +772 -0
  146. package/src/providers/acp/AcpClientConnection.ts +361 -0
  147. package/src/providers/acp/AcpJsonRpcTransport.ts +427 -0
  148. package/src/providers/acp/AcpSessionConfig.ts +139 -0
  149. package/src/providers/acp/AcpSessionUpdateNormalizer.ts +371 -0
  150. package/src/providers/acp/AcpSubprocess.ts +155 -0
  151. package/src/providers/acp/AcpToolStreamAdapter.ts +132 -0
  152. package/src/providers/acp/buildAcpUsageInfo.ts +41 -0
  153. package/src/providers/acp/index.ts +9 -0
  154. package/src/providers/acp/methodNames.ts +50 -0
  155. package/src/providers/acp/types.ts +566 -0
  156. package/src/providers/claude/CLAUDE.md +78 -0
  157. package/src/providers/claude/agents/AgentManager.ts +225 -0
  158. package/src/providers/claude/agents/AgentStorage.ts +101 -0
  159. package/src/providers/claude/app/ClaudeWorkspaceServices.ts +90 -0
  160. package/src/providers/claude/auxiliary/ClaudeInlineEditService.ts +122 -0
  161. package/src/providers/claude/auxiliary/ClaudeInstructionRefineService.ts +90 -0
  162. package/src/providers/claude/auxiliary/ClaudeTitleGenerationService.ts +129 -0
  163. package/src/providers/claude/auxiliary/extractAssistantText.ts +28 -0
  164. package/src/providers/claude/capabilities.ts +17 -0
  165. package/src/providers/claude/cli/findClaudeCLIPath.ts +261 -0
  166. package/src/providers/claude/commands/ClaudeCommandCatalog.ts +149 -0
  167. package/src/providers/claude/commands/probeRuntimeCommands.ts +86 -0
  168. package/src/providers/claude/env/ClaudeSettingsReconciler.ts +90 -0
  169. package/src/providers/claude/env/claudeModelEnv.ts +86 -0
  170. package/src/providers/claude/history/ClaudeConversationHistoryService.ts +446 -0
  171. package/src/providers/claude/history/ClaudeHistoryStore.ts +170 -0
  172. package/src/providers/claude/history/sdkAsyncSubagent.ts +92 -0
  173. package/src/providers/claude/history/sdkBranchFilter.ts +271 -0
  174. package/src/providers/claude/history/sdkHistoryTypes.ts +61 -0
  175. package/src/providers/claude/history/sdkMessageParsing.ts +413 -0
  176. package/src/providers/claude/history/sdkSessionPaths.ts +98 -0
  177. package/src/providers/claude/history/sdkSubagentSidecar.ts +261 -0
  178. package/src/providers/claude/hooks/SubagentHooks.ts +31 -0
  179. package/src/providers/claude/modelLabels.ts +77 -0
  180. package/src/providers/claude/modelOptions.ts +113 -0
  181. package/src/providers/claude/modelSelection.ts +17 -0
  182. package/src/providers/claude/plugins/PluginManager.ts +194 -0
  183. package/src/providers/claude/prompt/ClaudeTurnEncoder.ts +46 -0
  184. package/src/providers/claude/registration.ts +39 -0
  185. package/src/providers/claude/runtime/ClaudeApprovalHandler.ts +153 -0
  186. package/src/providers/claude/runtime/ClaudeChatRuntime.ts +1812 -0
  187. package/src/providers/claude/runtime/ClaudeCliResolver.ts +94 -0
  188. package/src/providers/claude/runtime/ClaudeDynamicUpdates.ts +164 -0
  189. package/src/providers/claude/runtime/ClaudeMessageChannel.ts +209 -0
  190. package/src/providers/claude/runtime/ClaudeQueryOptionsBuilder.ts +315 -0
  191. package/src/providers/claude/runtime/ClaudeRewindService.ts +220 -0
  192. package/src/providers/claude/runtime/ClaudeSessionManager.ts +92 -0
  193. package/src/providers/claude/runtime/ClaudeTaskResultInterpreter.ts +172 -0
  194. package/src/providers/claude/runtime/ClaudeUserMessageFactory.ts +83 -0
  195. package/src/providers/claude/runtime/claudeColdStartQuery.ts +152 -0
  196. package/src/providers/claude/runtime/customSpawn.ts +87 -0
  197. package/src/providers/claude/runtime/types.ts +134 -0
  198. package/src/providers/claude/sdk/messages.ts +17 -0
  199. package/src/providers/claude/sdk/toolResultContent.ts +4 -0
  200. package/src/providers/claude/sdk/typeGuards.ts +14 -0
  201. package/src/providers/claude/sdk/types.ts +15 -0
  202. package/src/providers/claude/security/ClaudePermissionUpdates.ts +44 -0
  203. package/src/providers/claude/settings.ts +138 -0
  204. package/src/providers/claude/storage/AgentVaultStorage.ts +101 -0
  205. package/src/providers/claude/storage/CCSettingsStorage.ts +153 -0
  206. package/src/providers/claude/storage/ClaudianSettingsStorage.ts +6 -0
  207. package/src/providers/claude/storage/McpStorage.ts +139 -0
  208. package/src/providers/claude/storage/SessionStorage.ts +5 -0
  209. package/src/providers/claude/storage/SkillStorage.ts +61 -0
  210. package/src/providers/claude/storage/SlashCommandStorage.ts +96 -0
  211. package/src/providers/claude/storage/StorageService.ts +185 -0
  212. package/src/providers/claude/stream/toolInputStreamState.ts +318 -0
  213. package/src/providers/claude/stream/transformClaudeMessage.ts +586 -0
  214. package/src/providers/claude/types/agent.ts +2 -0
  215. package/src/providers/claude/types/models.ts +168 -0
  216. package/src/providers/claude/types/plugins.ts +14 -0
  217. package/src/providers/claude/types/providerState.ts +16 -0
  218. package/src/providers/claude/types/settings.ts +98 -0
  219. package/src/providers/claude/ui/AgentSettings.ts +389 -0
  220. package/src/providers/claude/ui/ClaudeChatUIConfig.ts +113 -0
  221. package/src/providers/claude/ui/ClaudeSettingsTab.ts +407 -0
  222. package/src/providers/claude/ui/PluginSettingsManager.ts +149 -0
  223. package/src/providers/claude/ui/SlashCommandSettings.ts +527 -0
  224. package/src/providers/codex/CLAUDE.md +64 -0
  225. package/src/providers/codex/agents/CodexAgentMentionProvider.ts +33 -0
  226. package/src/providers/codex/app/CodexWorkspaceServices.ts +76 -0
  227. package/src/providers/codex/auxiliary/CodexInlineEditService.ts +9 -0
  228. package/src/providers/codex/auxiliary/CodexInstructionRefineService.ts +9 -0
  229. package/src/providers/codex/auxiliary/CodexTaskResultInterpreter.ts +29 -0
  230. package/src/providers/codex/auxiliary/CodexTitleGenerationService.ts +22 -0
  231. package/src/providers/codex/capabilities.ts +16 -0
  232. package/src/providers/codex/commands/CodexSkillCatalog.ts +180 -0
  233. package/src/providers/codex/env/CodexSettingsReconciler.ts +68 -0
  234. package/src/providers/codex/history/CodexConversationHistoryService.ts +212 -0
  235. package/src/providers/codex/history/CodexHistoryStore.ts +1672 -0
  236. package/src/providers/codex/modelOptions.ts +99 -0
  237. package/src/providers/codex/modelSelection.ts +17 -0
  238. package/src/providers/codex/normalization/codexSubagentNormalization.ts +227 -0
  239. package/src/providers/codex/normalization/codexToolNormalization.ts +390 -0
  240. package/src/providers/codex/prompt/encodeCodexTurn.ts +57 -0
  241. package/src/providers/codex/registration.ts +29 -0
  242. package/src/providers/codex/runtime/CodexAppServerProcess.ts +105 -0
  243. package/src/providers/codex/runtime/CodexAuxQueryRunner.ts +180 -0
  244. package/src/providers/codex/runtime/CodexBinaryLocator.ts +49 -0
  245. package/src/providers/codex/runtime/CodexChatRuntime.ts +1296 -0
  246. package/src/providers/codex/runtime/CodexCliResolver.ts +65 -0
  247. package/src/providers/codex/runtime/CodexExecutionTargetResolver.ts +104 -0
  248. package/src/providers/codex/runtime/CodexLaunchSpecBuilder.ts +85 -0
  249. package/src/providers/codex/runtime/CodexNotificationRouter.ts +1033 -0
  250. package/src/providers/codex/runtime/CodexPathMapper.ts +155 -0
  251. package/src/providers/codex/runtime/CodexRpcTransport.ts +171 -0
  252. package/src/providers/codex/runtime/CodexRuntimeContext.ts +109 -0
  253. package/src/providers/codex/runtime/CodexServerRequestRouter.ts +331 -0
  254. package/src/providers/codex/runtime/CodexSessionFileTail.ts +792 -0
  255. package/src/providers/codex/runtime/CodexSessionManager.ts +39 -0
  256. package/src/providers/codex/runtime/codexAppServerSupport.ts +58 -0
  257. package/src/providers/codex/runtime/codexAppServerTypes.ts +705 -0
  258. package/src/providers/codex/runtime/codexLaunchTypes.ts +30 -0
  259. package/src/providers/codex/settings.ts +236 -0
  260. package/src/providers/codex/skills/CodexSkillListingService.ts +173 -0
  261. package/src/providers/codex/storage/CodexSkillStorage.ts +250 -0
  262. package/src/providers/codex/storage/CodexSubagentStorage.ts +212 -0
  263. package/src/providers/codex/types/index.ts +16 -0
  264. package/src/providers/codex/types/models.ts +46 -0
  265. package/src/providers/codex/types/subagent.ts +23 -0
  266. package/src/providers/codex/ui/CodexChatUIConfig.ts +128 -0
  267. package/src/providers/codex/ui/CodexSettingsTab.ts +432 -0
  268. package/src/providers/codex/ui/CodexSkillSettings.ts +275 -0
  269. package/src/providers/codex/ui/CodexSubagentSettings.ts +400 -0
  270. package/src/providers/defaultProviderConfigs.ts +14 -0
  271. package/src/providers/index.ts +30 -0
  272. package/src/providers/opencode/agents/OpencodeAgentMentionProvider.ts +42 -0
  273. package/src/providers/opencode/app/OpencodeRuntimeCommandLoader.ts +67 -0
  274. package/src/providers/opencode/app/OpencodeWorkspaceServices.ts +55 -0
  275. package/src/providers/opencode/auxiliary/OpencodeInlineEditService.ts +13 -0
  276. package/src/providers/opencode/auxiliary/OpencodeInstructionRefineService.ts +12 -0
  277. package/src/providers/opencode/auxiliary/OpencodeTaskResultInterpreter.ts +29 -0
  278. package/src/providers/opencode/auxiliary/OpencodeTitleGenerationService.ts +27 -0
  279. package/src/providers/opencode/capabilities.ts +16 -0
  280. package/src/providers/opencode/commands/OpencodeCommandCatalog.ts +92 -0
  281. package/src/providers/opencode/discoveryState.ts +135 -0
  282. package/src/providers/opencode/env/OpencodeSettingsReconciler.ts +178 -0
  283. package/src/providers/opencode/history/OpencodeConversationHistoryService.ts +84 -0
  284. package/src/providers/opencode/history/OpencodeHistoryStore.ts +472 -0
  285. package/src/providers/opencode/history/OpencodeSqliteReader.ts +284 -0
  286. package/src/providers/opencode/internal/compareCollections.ts +72 -0
  287. package/src/providers/opencode/internal/providerProjection.ts +15 -0
  288. package/src/providers/opencode/models.ts +378 -0
  289. package/src/providers/opencode/modes.ts +150 -0
  290. package/src/providers/opencode/normalization/opencodeToolNormalization.ts +406 -0
  291. package/src/providers/opencode/registration.ts +27 -0
  292. package/src/providers/opencode/runtime/OpencodeAuxQueryRunner.ts +436 -0
  293. package/src/providers/opencode/runtime/OpencodeChatRuntime.ts +1603 -0
  294. package/src/providers/opencode/runtime/OpencodeCliResolver.ts +57 -0
  295. package/src/providers/opencode/runtime/OpencodeLaunchArtifacts.ts +231 -0
  296. package/src/providers/opencode/runtime/OpencodePaths.ts +113 -0
  297. package/src/providers/opencode/runtime/OpencodeRuntimeEnvironment.ts +18 -0
  298. package/src/providers/opencode/runtime/buildOpencodePrompt.ts +66 -0
  299. package/src/providers/opencode/settings.ts +427 -0
  300. package/src/providers/opencode/storage/OpencodeAgentStorage.ts +346 -0
  301. package/src/providers/opencode/types/agent.ts +37 -0
  302. package/src/providers/opencode/types/index.ts +9 -0
  303. package/src/providers/opencode/ui/OpencodeAgentSettings.ts +579 -0
  304. package/src/providers/opencode/ui/OpencodeChatUIConfig.ts +316 -0
  305. package/src/providers/opencode/ui/OpencodeSettingsTab.ts +674 -0
  306. package/src/providers/pi/app/PiRuntimeCommandLoader.ts +57 -0
  307. package/src/providers/pi/app/PiWorkspaceServices.ts +39 -0
  308. package/src/providers/pi/auxiliary/PiInlineEditService.ts +9 -0
  309. package/src/providers/pi/auxiliary/PiInstructionRefineService.ts +9 -0
  310. package/src/providers/pi/auxiliary/PiTaskResultInterpreter.ts +29 -0
  311. package/src/providers/pi/auxiliary/PiTitleGenerationService.ts +19 -0
  312. package/src/providers/pi/capabilities.ts +16 -0
  313. package/src/providers/pi/commands/PiCommandCatalog.ts +92 -0
  314. package/src/providers/pi/env/PiSettingsReconciler.ts +180 -0
  315. package/src/providers/pi/history/PiConversationHistoryService.ts +123 -0
  316. package/src/providers/pi/history/PiHistoryStore.ts +664 -0
  317. package/src/providers/pi/internal/compareCollections.ts +4 -0
  318. package/src/providers/pi/internal/providerProjection.ts +18 -0
  319. package/src/providers/pi/models.ts +302 -0
  320. package/src/providers/pi/normalizations/piEventNormalization.ts +211 -0
  321. package/src/providers/pi/normalizations/piToolNormalization.ts +97 -0
  322. package/src/providers/pi/registration.ts +30 -0
  323. package/src/providers/pi/runtime/PiAuxQueryRunner.ts +216 -0
  324. package/src/providers/pi/runtime/PiChatRuntime.ts +1064 -0
  325. package/src/providers/pi/runtime/PiCliResolver.ts +53 -0
  326. package/src/providers/pi/runtime/PiExtensionUiBridge.ts +161 -0
  327. package/src/providers/pi/runtime/PiJsonl.ts +71 -0
  328. package/src/providers/pi/runtime/PiLaunchSpec.ts +70 -0
  329. package/src/providers/pi/runtime/PiModelDiscoveryService.ts +92 -0
  330. package/src/providers/pi/runtime/PiRpcPayloads.ts +18 -0
  331. package/src/providers/pi/runtime/PiRpcTransport.ts +243 -0
  332. package/src/providers/pi/runtime/PiSubprocess.ts +159 -0
  333. package/src/providers/pi/runtime/buildPiPrompt.ts +62 -0
  334. package/src/providers/pi/runtime/buildPiUsageInfo.ts +69 -0
  335. package/src/providers/pi/settings.ts +468 -0
  336. package/src/providers/pi/types.ts +64 -0
  337. package/src/providers/pi/ui/ObsidianPiExtensionUiRenderer.ts +251 -0
  338. package/src/providers/pi/ui/PiChatUIConfig.ts +265 -0
  339. package/src/providers/pi/ui/PiExtensionUiRenderer.ts +12 -0
  340. package/src/providers/pi/ui/PiSettingsTab.ts +642 -0
  341. package/src/shared/components/ResumeSessionDropdown.ts +185 -0
  342. package/src/shared/components/SelectableDropdown.ts +140 -0
  343. package/src/shared/components/SelectionHighlight.ts +77 -0
  344. package/src/shared/components/SlashCommandDropdown.ts +421 -0
  345. package/src/shared/icons.ts +180 -0
  346. package/src/shared/mention/MentionDropdownController.ts +627 -0
  347. package/src/shared/mention/VaultMentionCache.ts +106 -0
  348. package/src/shared/mention/VaultMentionDataProvider.ts +51 -0
  349. package/src/shared/mention/types.ts +67 -0
  350. package/src/shared/modals/ConfirmModal.ts +60 -0
  351. package/src/shared/modals/ForkTargetModal.ts +47 -0
  352. package/src/shared/modals/InstructionConfirmModal.ts +281 -0
  353. package/src/style/CLAUDE.md +49 -0
  354. package/src/style/accessibility.css +40 -0
  355. package/src/style/base/animations.css +44 -0
  356. package/src/style/base/container.css +20 -0
  357. package/src/style/base/variables.css +46 -0
  358. package/src/style/base/visibility.css +15 -0
  359. package/src/style/components/code.css +97 -0
  360. package/src/style/components/context-footer.css +76 -0
  361. package/src/style/components/header.css +27 -0
  362. package/src/style/components/history.css +221 -0
  363. package/src/style/components/input.css +312 -0
  364. package/src/style/components/messages.css +262 -0
  365. package/src/style/components/nav-sidebar.css +58 -0
  366. package/src/style/components/status-panel.css +202 -0
  367. package/src/style/components/subagent.css +248 -0
  368. package/src/style/components/tabs.css +112 -0
  369. package/src/style/components/thinking.css +88 -0
  370. package/src/style/components/toolcalls.css +278 -0
  371. package/src/style/features/ask-user-question.css +315 -0
  372. package/src/style/features/diff.css +197 -0
  373. package/src/style/features/file-context.css +188 -0
  374. package/src/style/features/file-link.css +22 -0
  375. package/src/style/features/image-context.css +179 -0
  376. package/src/style/features/image-embed.css +40 -0
  377. package/src/style/features/image-modal.css +52 -0
  378. package/src/style/features/inline-edit.css +278 -0
  379. package/src/style/features/plan-mode.css +103 -0
  380. package/src/style/features/resume-session.css +119 -0
  381. package/src/style/features/slash-commands.css +91 -0
  382. package/src/style/index.css +63 -0
  383. package/src/style/modals/fork-target.css +21 -0
  384. package/src/style/modals/instruction.css +161 -0
  385. package/src/style/modals/mcp-modal.css +241 -0
  386. package/src/style/settings/agent-settings.css +2 -0
  387. package/src/style/settings/base.css +300 -0
  388. package/src/style/settings/env-snippets.css +366 -0
  389. package/src/style/settings/mcp-settings.css +211 -0
  390. package/src/style/settings/plugin-settings.css +164 -0
  391. package/src/style/settings/provider-model-picker.css +367 -0
  392. package/src/style/settings/slash-settings.css +16 -0
  393. package/src/style/toolbar/external-context.css +177 -0
  394. package/src/style/toolbar/mcp-selector.css +176 -0
  395. package/src/style/toolbar/mode-selector.css +19 -0
  396. package/src/style/toolbar/model-selector.css +99 -0
  397. package/src/style/toolbar/permission-toggle.css +56 -0
  398. package/src/style/toolbar/service-tier-toggle.css +39 -0
  399. package/src/style/toolbar/thinking-selector.css +83 -0
  400. package/src/types/smol-toml.d.ts +4 -0
  401. package/src/utils/agent.ts +50 -0
  402. package/src/utils/animationFrame.ts +46 -0
  403. package/src/utils/browser.ts +46 -0
  404. package/src/utils/canvas.ts +14 -0
  405. package/src/utils/cliBinaryLocator.ts +97 -0
  406. package/src/utils/context.ts +117 -0
  407. package/src/utils/contextMentionResolver.ts +154 -0
  408. package/src/utils/date.ts +31 -0
  409. package/src/utils/diff.ts +384 -0
  410. package/src/utils/editor.ts +104 -0
  411. package/src/utils/electronCompat.ts +53 -0
  412. package/src/utils/env.ts +465 -0
  413. package/src/utils/externalContext.ts +143 -0
  414. package/src/utils/externalContextScanner.ts +135 -0
  415. package/src/utils/fileLink.ts +263 -0
  416. package/src/utils/frontmatter.ts +194 -0
  417. package/src/utils/imageEmbed.ts +139 -0
  418. package/src/utils/inlineEdit.ts +22 -0
  419. package/src/utils/interrupt.ts +23 -0
  420. package/src/utils/markdown.ts +25 -0
  421. package/src/utils/markdownMath.ts +130 -0
  422. package/src/utils/mcp.ts +96 -0
  423. package/src/utils/obsidianCompat.ts +23 -0
  424. package/src/utils/path.ts +342 -0
  425. package/src/utils/session.ts +240 -0
  426. package/src/utils/slashCommand.ts +152 -0
  427. package/src/utils/subagentJsonl.ts +52 -0
  428. package/src/utils/windowsCmdShim.ts +98 -0
  429. package/tests/__mocks__/claude-agent-sdk.ts +317 -0
  430. package/tests/__mocks__/codex-sdk.ts +88 -0
  431. package/tests/__mocks__/obsidian.ts +434 -0
  432. package/tests/helpers/mockElement.ts +403 -0
  433. package/tests/helpers/sdkMessages.ts +291 -0
  434. package/tests/integration/core/agent/ClaudianService.test.ts +1845 -0
  435. package/tests/integration/core/mcp/mcp.test.ts +905 -0
  436. package/tests/integration/features/chat/imagePersistence.test.ts +38 -0
  437. package/tests/integration/main.test.ts +1701 -0
  438. package/tests/setupWindow.ts +26 -0
  439. package/tests/tsconfig.json +7 -0
  440. package/tests/unit/core/commands/builtInCommands.test.ts +239 -0
  441. package/tests/unit/core/mcp/McpServerManager.test.ts +405 -0
  442. package/tests/unit/core/mcp/McpTester.test.ts +282 -0
  443. package/tests/unit/core/mcp/createNodeFetch.test.ts +188 -0
  444. package/tests/unit/core/providers/ProviderRegistry.test.ts +275 -0
  445. package/tests/unit/core/providers/ProviderSettingsCoordinator.test.ts +490 -0
  446. package/tests/unit/core/providers/ProviderWorkspaceRegistry.test.ts +84 -0
  447. package/tests/unit/core/providers/modelRouting.test.ts +91 -0
  448. package/tests/unit/core/providers/modelSelection.test.ts +155 -0
  449. package/tests/unit/core/providers/providerEnvironment.test.ts +162 -0
  450. package/tests/unit/core/providers/tabLifecycle.test.ts +217 -0
  451. package/tests/unit/core/security/ApprovalManager.test.ts +152 -0
  452. package/tests/unit/core/storage/VaultFileAdapter.test.ts +535 -0
  453. package/tests/unit/core/tools/todo.test.ts +227 -0
  454. package/tests/unit/core/tools/toolIcons.test.ts +75 -0
  455. package/tests/unit/core/tools/toolInput.test.ts +350 -0
  456. package/tests/unit/core/tools/toolNames.test.ts +464 -0
  457. package/tests/unit/core/types/mcp.test.ts +115 -0
  458. package/tests/unit/features/chat/ClaudianView.test.ts +404 -0
  459. package/tests/unit/features/chat/controllers/BrowserSelectionController.test.ts +179 -0
  460. package/tests/unit/features/chat/controllers/CanvasSelectionController.test.ts +216 -0
  461. package/tests/unit/features/chat/controllers/ConversationController.test.ts +2764 -0
  462. package/tests/unit/features/chat/controllers/InputController.test.ts +3188 -0
  463. package/tests/unit/features/chat/controllers/NavigationController.test.ts +640 -0
  464. package/tests/unit/features/chat/controllers/SelectionController.test.ts +695 -0
  465. package/tests/unit/features/chat/controllers/StreamController.test.ts +2534 -0
  466. package/tests/unit/features/chat/controllers/contextRowVisibility.test.ts +46 -0
  467. package/tests/unit/features/chat/controllers/index.test.ts +16 -0
  468. package/tests/unit/features/chat/rendering/DiffRenderer.test.ts +355 -0
  469. package/tests/unit/features/chat/rendering/InlineAskUserQuestion.test.ts +1035 -0
  470. package/tests/unit/features/chat/rendering/InlineExitPlanMode.test.ts +191 -0
  471. package/tests/unit/features/chat/rendering/InlinePlanApproval.test.ts +126 -0
  472. package/tests/unit/features/chat/rendering/MessageRenderer.test.ts +2004 -0
  473. package/tests/unit/features/chat/rendering/SubagentRenderer.test.ts +917 -0
  474. package/tests/unit/features/chat/rendering/ThinkingBlockRenderer.test.ts +124 -0
  475. package/tests/unit/features/chat/rendering/TodoListRenderer.test.ts +173 -0
  476. package/tests/unit/features/chat/rendering/ToolCallRenderer.test.ts +909 -0
  477. package/tests/unit/features/chat/rendering/WriteEditRenderer.test.ts +474 -0
  478. package/tests/unit/features/chat/rendering/collapsible.test.ts +158 -0
  479. package/tests/unit/features/chat/rendering/todoUtils.test.ts +105 -0
  480. package/tests/unit/features/chat/rewind.test.ts +56 -0
  481. package/tests/unit/features/chat/services/BangBashService.test.ts +142 -0
  482. package/tests/unit/features/chat/services/InstructionRefineService.test.ts +371 -0
  483. package/tests/unit/features/chat/services/SubagentManager.test.ts +1759 -0
  484. package/tests/unit/features/chat/services/TitleGenerationService.test.ts +480 -0
  485. package/tests/unit/features/chat/state/ChatState.test.ts +581 -0
  486. package/tests/unit/features/chat/tabs/Tab.test.ts +4287 -0
  487. package/tests/unit/features/chat/tabs/TabBar.test.ts +357 -0
  488. package/tests/unit/features/chat/tabs/TabManager.test.ts +2962 -0
  489. package/tests/unit/features/chat/tabs/index.test.ts +11 -0
  490. package/tests/unit/features/chat/ui/BangBashModeManager.test.ts +321 -0
  491. package/tests/unit/features/chat/ui/ExternalContextSelector.test.ts +555 -0
  492. package/tests/unit/features/chat/ui/FileContextManager.test.ts +876 -0
  493. package/tests/unit/features/chat/ui/ImageContext.test.ts +777 -0
  494. package/tests/unit/features/chat/ui/InputToolbar.test.ts +1139 -0
  495. package/tests/unit/features/chat/ui/InstructionModeManager.test.ts +243 -0
  496. package/tests/unit/features/chat/ui/NavigationSidebar.test.ts +570 -0
  497. package/tests/unit/features/chat/ui/StatusPanel.test.ts +953 -0
  498. package/tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts +155 -0
  499. package/tests/unit/features/chat/ui/textareaResize.test.ts +102 -0
  500. package/tests/unit/features/chat/utils/usageInfo.test.ts +56 -0
  501. package/tests/unit/features/inline-edit/InlineEditService.test.ts +1199 -0
  502. package/tests/unit/features/inline-edit/ui/InlineEditModal.openAndWait.test.ts +1482 -0
  503. package/tests/unit/features/inline-edit/ui/InlineEditModal.test.ts +495 -0
  504. package/tests/unit/features/inline-edit/ui/inlineEditMarkdownPreview.test.ts +92 -0
  505. package/tests/unit/features/settings/AgentSettings.test.ts +82 -0
  506. package/tests/unit/features/settings/keyboardNavigation.test.ts +73 -0
  507. package/tests/unit/features/settings/ui/CodexSkillSettings.test.ts +294 -0
  508. package/tests/unit/features/settings/ui/CodexSubagentSettings.test.ts +207 -0
  509. package/tests/unit/i18n/constants.test.ts +43 -0
  510. package/tests/unit/i18n/i18n.test.ts +244 -0
  511. package/tests/unit/i18n/locales.test.ts +134 -0
  512. package/tests/unit/providers/acp/AcpClientConnection.test.ts +248 -0
  513. package/tests/unit/providers/acp/AcpJsonRpcTransport.test.ts +186 -0
  514. package/tests/unit/providers/acp/AcpSessionConfig.test.ts +247 -0
  515. package/tests/unit/providers/acp/AcpSessionUpdateNormalizer.test.ts +145 -0
  516. package/tests/unit/providers/acp/AcpSubprocess.test.ts +105 -0
  517. package/tests/unit/providers/acp/buildAcpUsageInfo.test.ts +51 -0
  518. package/tests/unit/providers/claude/agents/AgentManager.test.ts +590 -0
  519. package/tests/unit/providers/claude/agents/AgentStorage.test.ts +434 -0
  520. package/tests/unit/providers/claude/agents/index.test.ts +10 -0
  521. package/tests/unit/providers/claude/commands/ClaudeCommandCatalog.test.ts +396 -0
  522. package/tests/unit/providers/claude/commands/probeRuntimeCommands.test.ts +92 -0
  523. package/tests/unit/providers/claude/env/ClaudeSettingsReconciler.test.ts +57 -0
  524. package/tests/unit/providers/claude/env/claudeModelEnv.test.ts +228 -0
  525. package/tests/unit/providers/claude/hooks/SubagentHooks.test.ts +83 -0
  526. package/tests/unit/providers/claude/plugins/PluginManager.test.ts +832 -0
  527. package/tests/unit/providers/claude/plugins/index.test.ts +7 -0
  528. package/tests/unit/providers/claude/prompt/ClaudeTurnEncoder.test.ts +145 -0
  529. package/tests/unit/providers/claude/prompt/instructionRefine.test.ts +185 -0
  530. package/tests/unit/providers/claude/prompt/systemPrompt.test.ts +163 -0
  531. package/tests/unit/providers/claude/prompt/titleGeneration.test.ts +20 -0
  532. package/tests/unit/providers/claude/runtime/ClaudeTaskResultInterpreter.test.ts +28 -0
  533. package/tests/unit/providers/claude/runtime/ClaudianService.test.ts +3796 -0
  534. package/tests/unit/providers/claude/runtime/MessageChannel.test.ts +421 -0
  535. package/tests/unit/providers/claude/runtime/QueryOptionsBuilder.test.ts +775 -0
  536. package/tests/unit/providers/claude/runtime/SessionManager.test.ts +182 -0
  537. package/tests/unit/providers/claude/runtime/claudeColdStartQuery.test.ts +331 -0
  538. package/tests/unit/providers/claude/runtime/customSpawn.test.ts +374 -0
  539. package/tests/unit/providers/claude/runtime/index.test.ts +13 -0
  540. package/tests/unit/providers/claude/runtime/types.test.ts +190 -0
  541. package/tests/unit/providers/claude/sdk/typeGuards.test.ts +50 -0
  542. package/tests/unit/providers/claude/security/ClaudePermissionUpdates.test.ts +198 -0
  543. package/tests/unit/providers/claude/storage/AgentVaultStorage.test.ts +413 -0
  544. package/tests/unit/providers/claude/storage/CCSettingsStorage.test.ts +408 -0
  545. package/tests/unit/providers/claude/storage/ClaudianSettingsStorage.test.ts +653 -0
  546. package/tests/unit/providers/claude/storage/McpStorage.test.ts +619 -0
  547. package/tests/unit/providers/claude/storage/SessionStorage.test.ts +680 -0
  548. package/tests/unit/providers/claude/storage/SkillStorage.test.ts +275 -0
  549. package/tests/unit/providers/claude/storage/SlashCommandStorage.test.ts +612 -0
  550. package/tests/unit/providers/claude/storage/storage.test.ts +360 -0
  551. package/tests/unit/providers/claude/storage/storageService.convenience.test.ts +447 -0
  552. package/tests/unit/providers/claude/stream/transformSDKMessage.test.ts +1729 -0
  553. package/tests/unit/providers/claude/types/types.test.ts +726 -0
  554. package/tests/unit/providers/claude/ui/ClaudeChatUIConfig.test.ts +173 -0
  555. package/tests/unit/providers/claude/ui/ClaudeSettingsTab.test.ts +466 -0
  556. package/tests/unit/providers/codex/agents/CodexAgentMentionProvider.test.ts +89 -0
  557. package/tests/unit/providers/codex/auxiliary/CodexInstructionRefineService.test.ts +81 -0
  558. package/tests/unit/providers/codex/capabilities.test.ts +39 -0
  559. package/tests/unit/providers/codex/commands/CodexSkillCatalog.test.ts +413 -0
  560. package/tests/unit/providers/codex/env/CodexSettingsReconciler.test.ts +106 -0
  561. package/tests/unit/providers/codex/fixtures/codex-session-abort.jsonl +9 -0
  562. package/tests/unit/providers/codex/fixtures/codex-session-agent-lifecycle.jsonl +12 -0
  563. package/tests/unit/providers/codex/fixtures/codex-session-persisted-tools.jsonl +15 -0
  564. package/tests/unit/providers/codex/fixtures/codex-session-simple.jsonl +10 -0
  565. package/tests/unit/providers/codex/fixtures/codex-session-tools.jsonl +12 -0
  566. package/tests/unit/providers/codex/fixtures/codex-session-websearch-persisted.jsonl +3 -0
  567. package/tests/unit/providers/codex/fixtures/codex-session-websearch.jsonl +7 -0
  568. package/tests/unit/providers/codex/history/CodexConversationHistoryService.test.ts +690 -0
  569. package/tests/unit/providers/codex/history/CodexHistoryStore.test.ts +2202 -0
  570. package/tests/unit/providers/codex/normalization/codexSubagentNormalization.test.ts +81 -0
  571. package/tests/unit/providers/codex/normalization/codexToolNormalization.test.ts +322 -0
  572. package/tests/unit/providers/codex/prompt/encodeCodexTurn.test.ts +168 -0
  573. package/tests/unit/providers/codex/runtime/CodexAppServerProcess.test.ts +255 -0
  574. package/tests/unit/providers/codex/runtime/CodexAuxQueryRunner.test.ts +130 -0
  575. package/tests/unit/providers/codex/runtime/CodexBinaryLocator.test.ts +90 -0
  576. package/tests/unit/providers/codex/runtime/CodexChatRuntime.test.ts +2445 -0
  577. package/tests/unit/providers/codex/runtime/CodexCliResolver.test.ts +103 -0
  578. package/tests/unit/providers/codex/runtime/CodexExecutionTargetResolver.test.ts +105 -0
  579. package/tests/unit/providers/codex/runtime/CodexLaunchSpecBuilder.test.ts +150 -0
  580. package/tests/unit/providers/codex/runtime/CodexNotificationRouter.test.ts +1248 -0
  581. package/tests/unit/providers/codex/runtime/CodexPathMapper.test.ts +51 -0
  582. package/tests/unit/providers/codex/runtime/CodexRpcTransport.test.ts +220 -0
  583. package/tests/unit/providers/codex/runtime/CodexRuntimeContext.test.ts +107 -0
  584. package/tests/unit/providers/codex/runtime/CodexServerRequestRouter.test.ts +537 -0
  585. package/tests/unit/providers/codex/runtime/CodexSessionFileTail.test.ts +1305 -0
  586. package/tests/unit/providers/codex/runtime/CodexSessionManager.test.ts +82 -0
  587. package/tests/unit/providers/codex/runtime/codexAppServerTypes.test.ts +336 -0
  588. package/tests/unit/providers/codex/settings.test.ts +189 -0
  589. package/tests/unit/providers/codex/skills/CodexSkillListingService.test.ts +178 -0
  590. package/tests/unit/providers/codex/storage/CodexSkillStorage.test.ts +342 -0
  591. package/tests/unit/providers/codex/storage/CodexSubagentStorage.test.ts +376 -0
  592. package/tests/unit/providers/codex/ui/CodexChatUIConfig.test.ts +211 -0
  593. package/tests/unit/providers/codex/ui/CodexSettingsTab.test.ts +578 -0
  594. package/tests/unit/providers/defaultProviderConfigs.test.ts +18 -0
  595. package/tests/unit/providers/opencode/OpencodeAuxQueryRunner.test.ts +355 -0
  596. package/tests/unit/providers/opencode/OpencodeChatRuntime.test.ts +808 -0
  597. package/tests/unit/providers/opencode/OpencodeCliResolver.test.ts +93 -0
  598. package/tests/unit/providers/opencode/OpencodeCommandCatalog.test.ts +75 -0
  599. package/tests/unit/providers/opencode/OpencodeConversationHistoryService.test.ts +103 -0
  600. package/tests/unit/providers/opencode/OpencodeHistoryStore.test.ts +418 -0
  601. package/tests/unit/providers/opencode/OpencodeLaunchArtifacts.test.ts +268 -0
  602. package/tests/unit/providers/opencode/OpencodePaths.test.ts +35 -0
  603. package/tests/unit/providers/opencode/OpencodeRuntimeCommandLoader.test.ts +129 -0
  604. package/tests/unit/providers/opencode/OpencodeSettingsReconciler.test.ts +96 -0
  605. package/tests/unit/providers/opencode/OpencodeSettingsTab.test.ts +649 -0
  606. package/tests/unit/providers/opencode/OpencodeSqliteReader.test.ts +185 -0
  607. package/tests/unit/providers/opencode/agents/OpencodeAgentMentionProvider.test.ts +56 -0
  608. package/tests/unit/providers/opencode/buildOpencodePrompt.test.ts +87 -0
  609. package/tests/unit/providers/opencode/capabilities.test.ts +39 -0
  610. package/tests/unit/providers/opencode/models.test.ts +273 -0
  611. package/tests/unit/providers/opencode/modes.test.ts +151 -0
  612. package/tests/unit/providers/opencode/opencodeToolNormalization.test.ts +197 -0
  613. package/tests/unit/providers/opencode/settings.test.ts +469 -0
  614. package/tests/unit/providers/opencode/storage/OpencodeAgentStorage.test.ts +377 -0
  615. package/tests/unit/providers/opencode/ui/OpencodeAgentSettings.test.ts +91 -0
  616. package/tests/unit/providers/pi/PiRuntimeCommandLoader.test.ts +149 -0
  617. package/tests/unit/providers/pi/capabilities.test.ts +20 -0
  618. package/tests/unit/providers/pi/commands/PiCommandCatalog.test.ts +69 -0
  619. package/tests/unit/providers/pi/env/PiSettingsReconciler.test.ts +61 -0
  620. package/tests/unit/providers/pi/history/PiConversationHistoryService.test.ts +147 -0
  621. package/tests/unit/providers/pi/history/PiHistoryStore.test.ts +523 -0
  622. package/tests/unit/providers/pi/models.test.ts +96 -0
  623. package/tests/unit/providers/pi/registration.test.ts +54 -0
  624. package/tests/unit/providers/pi/runtime/PiAuxQueryRunner.test.ts +172 -0
  625. package/tests/unit/providers/pi/runtime/PiChatRuntime.test.ts +830 -0
  626. package/tests/unit/providers/pi/runtime/PiCliResolver.test.ts +105 -0
  627. package/tests/unit/providers/pi/runtime/PiEventNormalization.test.ts +147 -0
  628. package/tests/unit/providers/pi/runtime/PiExtensionUiBridge.test.ts +98 -0
  629. package/tests/unit/providers/pi/runtime/PiJsonl.test.ts +42 -0
  630. package/tests/unit/providers/pi/runtime/PiLaunchSpec.test.ts +92 -0
  631. package/tests/unit/providers/pi/runtime/PiModelDiscoveryService.test.ts +135 -0
  632. package/tests/unit/providers/pi/runtime/PiRpcPayloads.test.ts +15 -0
  633. package/tests/unit/providers/pi/runtime/PiRpcTransport.test.ts +116 -0
  634. package/tests/unit/providers/pi/runtime/PiSubprocess.test.ts +151 -0
  635. package/tests/unit/providers/pi/runtime/buildPiPrompt.test.ts +84 -0
  636. package/tests/unit/providers/pi/runtime/buildPiUsageInfo.test.ts +69 -0
  637. package/tests/unit/providers/pi/settings.test.ts +253 -0
  638. package/tests/unit/providers/pi/ui/PiChatUIConfig.test.ts +161 -0
  639. package/tests/unit/providers/pi/ui/PiSettingsTab.test.ts +492 -0
  640. package/tests/unit/scripts/rendererSafeUnref.test.ts +101 -0
  641. package/tests/unit/shared/components/ResumeSessionDropdown.test.ts +356 -0
  642. package/tests/unit/shared/components/SelectableDropdown.test.ts +406 -0
  643. package/tests/unit/shared/components/SlashCommandDropdown.provider.test.ts +354 -0
  644. package/tests/unit/shared/components/SlashCommandDropdown.test.ts +508 -0
  645. package/tests/unit/shared/icons.test.ts +56 -0
  646. package/tests/unit/shared/index.test.ts +46 -0
  647. package/tests/unit/shared/mention/MentionDropdownController.test.ts +823 -0
  648. package/tests/unit/shared/mention/VaultFileCache.test.ts +195 -0
  649. package/tests/unit/shared/mention/VaultFolderCache.test.ts +143 -0
  650. package/tests/unit/shared/mention/VaultMentionDataProvider.test.ts +87 -0
  651. package/tests/unit/shared/modals/ConfirmModal.test.ts +111 -0
  652. package/tests/unit/shared/modals/ForkTargetModal.test.ts +101 -0
  653. package/tests/unit/shared/modals/InstructionConfirmModal.test.ts +305 -0
  654. package/tests/unit/utils/agent.test.ts +395 -0
  655. package/tests/unit/utils/animationFrame.test.ts +59 -0
  656. package/tests/unit/utils/browser.test.ts +73 -0
  657. package/tests/unit/utils/canvas.test.ts +54 -0
  658. package/tests/unit/utils/claudeCli.test.ts +294 -0
  659. package/tests/unit/utils/cliBinaryLocator.test.ts +33 -0
  660. package/tests/unit/utils/context.test.ts +288 -0
  661. package/tests/unit/utils/contextMentionResolver.test.ts +270 -0
  662. package/tests/unit/utils/date.test.ts +80 -0
  663. package/tests/unit/utils/diff.test.ts +291 -0
  664. package/tests/unit/utils/editor.test.ts +249 -0
  665. package/tests/unit/utils/electronCompat.test.ts +87 -0
  666. package/tests/unit/utils/env.test.ts +1240 -0
  667. package/tests/unit/utils/externalContext.test.ts +336 -0
  668. package/tests/unit/utils/externalContextScanner.test.ts +186 -0
  669. package/tests/unit/utils/fileLink.dom.test.ts +273 -0
  670. package/tests/unit/utils/fileLink.handler.test.ts +64 -0
  671. package/tests/unit/utils/fileLink.test.ts +233 -0
  672. package/tests/unit/utils/frontmatter.test.ts +434 -0
  673. package/tests/unit/utils/imageEmbed.test.ts +407 -0
  674. package/tests/unit/utils/inlineEdit.test.ts +63 -0
  675. package/tests/unit/utils/interrupt.test.ts +73 -0
  676. package/tests/unit/utils/markdown.test.ts +35 -0
  677. package/tests/unit/utils/markdownMath.test.ts +54 -0
  678. package/tests/unit/utils/mcp.test.ts +256 -0
  679. package/tests/unit/utils/obsidianCompat.test.ts +18 -0
  680. package/tests/unit/utils/path.test.ts +677 -0
  681. package/tests/unit/utils/sdkSession.test.ts +2359 -0
  682. package/tests/unit/utils/session.test.ts +971 -0
  683. package/tests/unit/utils/slashCommand.test.ts +778 -0
  684. package/tests/unit/utils/utils.test.ts +809 -0
  685. package/tsconfig.jest.json +8 -0
  686. package/tsconfig.json +26 -0
  687. package/versions.json +4 -0
@@ -0,0 +1,1701 @@
1
+
2
+ import { TOOL_SUBAGENT } from '@/core/tools/toolNames';
3
+ import { VIEW_TYPE_CLAUDIAN } from '@/core/types';
4
+ import * as sdkSession from '@/providers/claude/history/ClaudeHistoryStore';
5
+ import { DEFAULT_SETTINGS } from '@/providers/claude/types/settings';
6
+
7
+ // Mock fs for ClaudianService
8
+ jest.mock('fs');
9
+
10
+ // Now import the plugin after mocking
11
+ import ClaudianPlugin from '@/main';
12
+
13
+ describe('ClaudianPlugin', () => {
14
+ let plugin: ClaudianPlugin;
15
+ let mockApp: any;
16
+ let mockManifest: any;
17
+
18
+ function getRegisteredCommand(commandId: string) {
19
+ const call = (plugin.addCommand as jest.Mock).mock.calls.find(
20
+ ([config]) => config.id === commandId,
21
+ );
22
+
23
+ if (!call) {
24
+ throw new Error(`Command ${commandId} was not registered`);
25
+ }
26
+
27
+ return call[0];
28
+ }
29
+
30
+ beforeEach(() => {
31
+ // Reset mocks
32
+ jest.clearAllMocks();
33
+
34
+ mockApp = {
35
+ vault: {
36
+ adapter: {
37
+ basePath: '/test/vault',
38
+ exists: jest.fn().mockResolvedValue(false),
39
+ read: jest.fn().mockResolvedValue(''),
40
+ write: jest.fn().mockResolvedValue(undefined),
41
+ remove: jest.fn().mockResolvedValue(undefined),
42
+ mkdir: jest.fn().mockResolvedValue(undefined),
43
+ list: jest.fn().mockResolvedValue({ files: [], folders: [] }),
44
+ stat: jest.fn().mockResolvedValue(null),
45
+ rename: jest.fn().mockResolvedValue(undefined),
46
+ },
47
+ },
48
+ workspace: {
49
+ getLeavesOfType: jest.fn().mockReturnValue([]),
50
+ getRightLeaf: jest.fn().mockReturnValue({
51
+ setViewState: jest.fn().mockResolvedValue(undefined),
52
+ }),
53
+ getLeftLeaf: jest.fn().mockReturnValue({
54
+ setViewState: jest.fn().mockResolvedValue(undefined),
55
+ }),
56
+ getLeaf: jest.fn().mockReturnValue({
57
+ setViewState: jest.fn().mockResolvedValue(undefined),
58
+ }),
59
+ setActiveLeaf: jest.fn(),
60
+ revealLeaf: jest.fn(),
61
+ },
62
+ };
63
+
64
+ mockManifest = {
65
+ id: 'claudian',
66
+ name: 'Claudian',
67
+ version: '0.1.0',
68
+ };
69
+
70
+ // Create plugin instance with mocked app
71
+ plugin = new ClaudianPlugin(mockApp, mockManifest);
72
+ (plugin.loadData as jest.Mock).mockResolvedValue({});
73
+ });
74
+
75
+ describe('onload', () => {
76
+ it('should initialize settings with defaults', async () => {
77
+ await plugin.onload();
78
+
79
+ expect(plugin.settings).toBeDefined();
80
+ expect(plugin.settings.permissionMode).toBe(DEFAULT_SETTINGS.permissionMode);
81
+ expect(plugin.settings.hiddenProviderCommands).toEqual(DEFAULT_SETTINGS.hiddenProviderCommands);
82
+ });
83
+
84
+ // Note: With multi-tab, agentService is per-tab via TabManager, not on plugin
85
+
86
+ it('should register the view', async () => {
87
+ await plugin.onload();
88
+
89
+ expect((plugin.registerView as jest.Mock)).toHaveBeenCalledWith(
90
+ VIEW_TYPE_CLAUDIAN,
91
+ expect.any(Function)
92
+ );
93
+ });
94
+
95
+ it('should add ribbon icon', async () => {
96
+ await plugin.onload();
97
+
98
+ expect((plugin.addRibbonIcon as jest.Mock)).toHaveBeenCalledWith(
99
+ 'bot',
100
+ 'Open Claudian',
101
+ expect.any(Function)
102
+ );
103
+ });
104
+
105
+ it('should add command to open view', async () => {
106
+ await plugin.onload();
107
+
108
+ expect((plugin.addCommand as jest.Mock)).toHaveBeenCalledWith({
109
+ id: 'open-view',
110
+ name: 'Open chat view',
111
+ callback: expect.any(Function),
112
+ });
113
+ });
114
+
115
+ });
116
+
117
+ describe('onunload', () => {
118
+ // Note: With multi-tab, cleanup is handled per-tab via ClaudianView.onClose()
119
+ it('should complete without error', async () => {
120
+ await plugin.onload();
121
+
122
+ expect(() => plugin.onunload()).not.toThrow();
123
+ });
124
+ });
125
+
126
+ describe('activateView', () => {
127
+ it('should reveal existing leaf if view already exists', async () => {
128
+ const mockLeaf = { id: 'existing-leaf' };
129
+ mockApp.workspace.getLeavesOfType.mockReturnValue([mockLeaf]);
130
+
131
+ await plugin.onload();
132
+ await plugin.activateView();
133
+
134
+ expect(mockApp.workspace.revealLeaf).toHaveBeenCalledWith(mockLeaf);
135
+ });
136
+
137
+ it('should create new leaf in right sidebar by default if view does not exist', async () => {
138
+ const mockRightLeaf = {
139
+ setViewState: jest.fn().mockResolvedValue(undefined),
140
+ };
141
+ mockApp.workspace.getLeavesOfType.mockReturnValue([]);
142
+ mockApp.workspace.getRightLeaf.mockReturnValue(mockRightLeaf);
143
+
144
+ await plugin.onload();
145
+ await plugin.activateView();
146
+
147
+ expect(mockApp.workspace.getRightLeaf).toHaveBeenCalledWith(false);
148
+ expect(mockRightLeaf.setViewState).toHaveBeenCalledWith({
149
+ type: VIEW_TYPE_CLAUDIAN,
150
+ active: true,
151
+ });
152
+ });
153
+
154
+ it('should create new leaf in left sidebar when chatViewPlacement is left-sidebar', async () => {
155
+ const mockLeftLeaf = {
156
+ setViewState: jest.fn().mockResolvedValue(undefined),
157
+ };
158
+ mockApp.workspace.getLeavesOfType.mockReturnValue([]);
159
+ mockApp.workspace.getLeftLeaf.mockReturnValue(mockLeftLeaf);
160
+
161
+ await plugin.onload();
162
+ plugin.settings.chatViewPlacement = 'left-sidebar';
163
+ await plugin.activateView();
164
+
165
+ expect(mockApp.workspace.getLeftLeaf).toHaveBeenCalledWith(false);
166
+ expect(mockApp.workspace.getRightLeaf).not.toHaveBeenCalled();
167
+ expect(mockApp.workspace.getLeaf).not.toHaveBeenCalled();
168
+ expect(mockLeftLeaf.setViewState).toHaveBeenCalledWith({
169
+ type: VIEW_TYPE_CLAUDIAN,
170
+ active: true,
171
+ });
172
+ });
173
+
174
+ it('should handle null right leaf gracefully', async () => {
175
+ mockApp.workspace.getLeavesOfType.mockReturnValue([]);
176
+ mockApp.workspace.getRightLeaf.mockReturnValue(null);
177
+
178
+ await plugin.onload();
179
+
180
+ // Should not throw
181
+ await expect(plugin.activateView()).resolves.not.toThrow();
182
+ });
183
+
184
+ it('should create new leaf in main editor area when chatViewPlacement is main-tab', async () => {
185
+ const mockMainLeaf = {
186
+ setViewState: jest.fn().mockResolvedValue(undefined),
187
+ };
188
+ mockApp.workspace.getLeavesOfType.mockReturnValue([]);
189
+ mockApp.workspace.getLeaf.mockReturnValue(mockMainLeaf);
190
+
191
+ await plugin.onload();
192
+ plugin.settings.chatViewPlacement = 'main-tab';
193
+ await plugin.activateView();
194
+
195
+ expect(mockApp.workspace.getLeaf).toHaveBeenCalledWith('tab');
196
+ expect(mockApp.workspace.getRightLeaf).not.toHaveBeenCalled();
197
+ expect(mockApp.workspace.getLeftLeaf).not.toHaveBeenCalled();
198
+ expect(mockMainLeaf.setViewState).toHaveBeenCalledWith({
199
+ type: VIEW_TYPE_CLAUDIAN,
200
+ active: true,
201
+ });
202
+ });
203
+
204
+ it('should handle null main leaf gracefully when chatViewPlacement is main-tab', async () => {
205
+ mockApp.workspace.getLeavesOfType.mockReturnValue([]);
206
+ mockApp.workspace.getLeaf.mockReturnValue(null);
207
+
208
+ await plugin.onload();
209
+ plugin.settings.chatViewPlacement = 'main-tab';
210
+
211
+ await expect(plugin.activateView()).resolves.not.toThrow();
212
+ });
213
+ });
214
+
215
+ describe('loadSettings', () => {
216
+ it('should merge saved data with defaults', async () => {
217
+ // Mock claudian-settings.json exists with custom values (Claudian-specific settings)
218
+ mockApp.vault.adapter.exists.mockImplementation(async (path: string) => {
219
+ return path === '.claudian/claudian-settings.json';
220
+ });
221
+ mockApp.vault.adapter.read.mockImplementation(async (path: string) => {
222
+ if (path === '.claudian/claudian-settings.json') {
223
+ return JSON.stringify({
224
+ userName: 'TestUser',
225
+ });
226
+ }
227
+ return '';
228
+ });
229
+
230
+ await plugin.loadSettings();
231
+
232
+ expect(plugin.settings.userName).toBe('TestUser');
233
+ expect(plugin.settings.hiddenProviderCommands).toEqual(DEFAULT_SETTINGS.hiddenProviderCommands);
234
+ });
235
+
236
+ it('should strip legacy blocklist fields when loading old settings', async () => {
237
+ mockApp.vault.adapter.exists.mockImplementation(async (path: string) => {
238
+ return path === '.claudian/claudian-settings.json';
239
+ });
240
+ mockApp.vault.adapter.read.mockImplementation(async (path: string) => {
241
+ if (path === '.claudian/claudian-settings.json') {
242
+ return JSON.stringify({
243
+ enableBlocklist: false,
244
+ blockedCommands: { unix: ['rm -rf', ' '] },
245
+ });
246
+ }
247
+ return '';
248
+ });
249
+
250
+ await plugin.loadSettings();
251
+
252
+ expect('enableBlocklist' in plugin.settings).toBe(false);
253
+ expect('blockedCommands' in plugin.settings).toBe(false);
254
+ expect(mockApp.vault.adapter.write).toHaveBeenCalledWith(
255
+ '.claudian/claudian-settings.json',
256
+ expect.any(String),
257
+ );
258
+ const writeCall = (mockApp.vault.adapter.write as jest.Mock).mock.calls.find(
259
+ ([path]) => path === '.claudian/claudian-settings.json',
260
+ );
261
+ expect(writeCall).toBeDefined();
262
+ const content = JSON.parse(writeCall[1]);
263
+ expect(content).not.toHaveProperty('enableBlocklist');
264
+ expect(content).not.toHaveProperty('blockedCommands');
265
+ });
266
+
267
+ it('should use defaults when no saved data', async () => {
268
+ // No settings file exists
269
+ mockApp.vault.adapter.exists.mockResolvedValue(false);
270
+ (plugin.loadData as jest.Mock).mockResolvedValue(null);
271
+
272
+ await plugin.loadSettings();
273
+
274
+ expect(plugin.settings).toEqual(DEFAULT_SETTINGS);
275
+ });
276
+
277
+ it('should use defaults when loadData returns empty object', async () => {
278
+ // No settings file exists
279
+ mockApp.vault.adapter.exists.mockResolvedValue(false);
280
+ (plugin.loadData as jest.Mock).mockResolvedValue({});
281
+
282
+ await plugin.loadSettings();
283
+
284
+ expect(plugin.settings).toEqual(DEFAULT_SETTINGS);
285
+ });
286
+
287
+ it('should migrate legacy openInMainTab true to main-tab placement', async () => {
288
+ mockApp.vault.adapter.exists.mockImplementation(async (path: string) => {
289
+ return path === '.claudian/claudian-settings.json';
290
+ });
291
+ mockApp.vault.adapter.read.mockImplementation(async (path: string) => {
292
+ if (path === '.claudian/claudian-settings.json') {
293
+ return JSON.stringify({ openInMainTab: true });
294
+ }
295
+ return '';
296
+ });
297
+
298
+ await plugin.loadSettings();
299
+
300
+ expect(plugin.settings.chatViewPlacement).toBe('main-tab');
301
+ const writeCall = (mockApp.vault.adapter.write as jest.Mock).mock.calls.find(
302
+ ([path]) => path === '.claudian/claudian-settings.json',
303
+ );
304
+ expect(writeCall).toBeDefined();
305
+ const content = JSON.parse(writeCall[1]);
306
+ expect(content.chatViewPlacement).toBe('main-tab');
307
+ expect(content).not.toHaveProperty('openInMainTab');
308
+ });
309
+
310
+ it('should reconcile model from environment and persist when changed', async () => {
311
+ // Mock claudian-settings.json with environment variables
312
+ mockApp.vault.adapter.exists.mockImplementation(async (path: string) => {
313
+ return path === '.claudian/claudian-settings.json';
314
+ });
315
+ mockApp.vault.adapter.read.mockImplementation(async (path: string) => {
316
+ if (path === '.claudian/claudian-settings.json') {
317
+ return JSON.stringify({
318
+ environmentVariables: 'ANTHROPIC_MODEL=custom-model',
319
+ lastEnvHash: '',
320
+ });
321
+ }
322
+ return '';
323
+ });
324
+
325
+ const saveSpy = jest.spyOn(plugin, 'saveSettings');
326
+ await plugin.loadSettings();
327
+
328
+ expect(plugin.settings.model).toBe('claude-code/custom-model');
329
+ expect(saveSpy).toHaveBeenCalled();
330
+ });
331
+ });
332
+
333
+ describe('saveSettings', () => {
334
+ it('should save settings to file', async () => {
335
+ await plugin.onload();
336
+
337
+ await plugin.saveSettings();
338
+
339
+ // Claudian-specific settings should be written to .claudian/claudian-settings.json
340
+ expect(mockApp.vault.adapter.write).toHaveBeenCalledWith(
341
+ '.claudian/claudian-settings.json',
342
+ expect.any(String)
343
+ );
344
+
345
+ // The written content should include state fields
346
+ const writeCall = (mockApp.vault.adapter.write as jest.Mock).mock.calls.find(
347
+ ([path]) => path === '.claudian/claudian-settings.json'
348
+ );
349
+ expect(writeCall).toBeDefined();
350
+ const content = JSON.parse(writeCall[1]);
351
+ expect(content).not.toHaveProperty('activeConversationId');
352
+ expect(content).toHaveProperty('providerConfigs.claude.environmentHash');
353
+ expect(content).toHaveProperty('providerConfigs.claude.lastModel');
354
+ expect(content).toHaveProperty('lastCustomModel');
355
+ expect(content).not.toHaveProperty('enableBlocklist');
356
+ expect(content).not.toHaveProperty('blockedCommands');
357
+ // Permissions are now in .claude/settings.json (CC format), not claudian-settings.json
358
+ expect(content).not.toHaveProperty('permissions');
359
+ });
360
+ });
361
+
362
+ describe('applyEnvironmentVariables', () => {
363
+ it('updates runtime env vars when changed', async () => {
364
+ await plugin.onload();
365
+
366
+ await plugin.applyEnvironmentVariables('shared', 'A=2');
367
+ expect(plugin.getEnvironmentVariablesForScope('shared')).toBe('A=2');
368
+
369
+ await plugin.applyEnvironmentVariables('shared', 'A=3');
370
+ expect(plugin.getEnvironmentVariablesForScope('shared')).toBe('A=3');
371
+
372
+ // No change - should not update
373
+ const currentEnv = plugin.getEnvironmentVariablesForScope('shared');
374
+ await plugin.applyEnvironmentVariables('shared', 'A=3');
375
+ expect(plugin.getEnvironmentVariablesForScope('shared')).toBe(currentEnv);
376
+ });
377
+
378
+ it('invalidates sessions when env hash changes', async () => {
379
+ await plugin.onload();
380
+
381
+ const conv = await plugin.createConversation({ sessionId: 'session-123' });
382
+ const saveMetadataSpy = jest.spyOn(plugin.storage.sessions, 'saveMetadata');
383
+ saveMetadataSpy.mockClear();
384
+
385
+ await plugin.applyEnvironmentVariables('provider:claude', 'ANTHROPIC_MODEL=claude-sonnet-4-5');
386
+
387
+ const updated = await plugin.getConversationById(conv.id);
388
+ expect(updated?.sessionId).toBeNull();
389
+ expect(saveMetadataSpy).toHaveBeenCalled();
390
+ });
391
+
392
+ it('broadcasts ensureReady with force when env changes without model change', async () => {
393
+ await plugin.onload();
394
+
395
+ // Mock getView to return a view with tabManager
396
+ const mockSyncConversationState = jest.fn();
397
+ const mockEnsureReady = jest.fn().mockResolvedValue(true);
398
+ const mockTabManager = {
399
+ getAllTabs: jest.fn().mockReturnValue([{
400
+ providerId: 'claude',
401
+ conversationId: null,
402
+ state: { isStreaming: false },
403
+ serviceInitialized: true,
404
+ service: {
405
+ ensureReady: mockEnsureReady,
406
+ syncConversationState: mockSyncConversationState,
407
+ },
408
+ ui: { externalContextSelector: { getExternalContexts: jest.fn().mockReturnValue([]) } },
409
+ }]),
410
+ };
411
+ const mockView = {
412
+ getTabManager: jest.fn().mockReturnValue(mockTabManager),
413
+ invalidateProviderCommandCaches: jest.fn(),
414
+ refreshModelSelector: jest.fn(),
415
+ };
416
+ jest.spyOn(plugin, 'getView').mockReturnValue(mockView as any);
417
+
418
+ // Change env but not in a way that affects model
419
+ await plugin.applyEnvironmentVariables('shared', 'SOME_VAR=value');
420
+
421
+ expect(mockSyncConversationState).toHaveBeenCalledWith(null, []);
422
+ expect(mockEnsureReady).toHaveBeenCalledWith({ force: true });
423
+ });
424
+
425
+ it('syncs live external contexts before restarting invalidated Claude runtimes', async () => {
426
+ await plugin.onload();
427
+
428
+ const conversation = await plugin.createConversation({
429
+ providerId: 'claude',
430
+ sessionId: 'session-123',
431
+ });
432
+ await plugin.updateConversation(conversation.id, {
433
+ externalContextPaths: ['/saved/context'],
434
+ messages: [{
435
+ content: 'hi',
436
+ id: 'msg-1',
437
+ role: 'user',
438
+ timestamp: Date.now(),
439
+ userMessageId: 'msg-1',
440
+ }],
441
+ });
442
+
443
+ const mockSyncConversationState = jest.fn();
444
+ const mockResetSession = jest.fn();
445
+ const mockEnsureReady = jest.fn().mockResolvedValue(true);
446
+ const mockTabManager = {
447
+ getAllTabs: jest.fn().mockReturnValue([{
448
+ conversationId: conversation.id,
449
+ providerId: 'claude',
450
+ state: { isStreaming: false },
451
+ serviceInitialized: true,
452
+ service: {
453
+ ensureReady: mockEnsureReady,
454
+ resetSession: mockResetSession,
455
+ syncConversationState: mockSyncConversationState,
456
+ },
457
+ ui: { externalContextSelector: { getExternalContexts: jest.fn().mockReturnValue(['/live/context']) } },
458
+ }]),
459
+ };
460
+ const mockView = {
461
+ getTabManager: jest.fn().mockReturnValue(mockTabManager),
462
+ invalidateProviderCommandCaches: jest.fn(),
463
+ refreshModelSelector: jest.fn(),
464
+ };
465
+ jest.spyOn(plugin, 'getView').mockReturnValue(mockView as any);
466
+
467
+ await plugin.applyEnvironmentVariables('provider:claude', 'ANTHROPIC_MODEL=claude-sonnet-4-5');
468
+
469
+ expect(mockSyncConversationState).toHaveBeenCalledWith(
470
+ expect.objectContaining({ id: conversation.id }),
471
+ ['/live/context'],
472
+ );
473
+ expect(mockResetSession).toHaveBeenCalledTimes(1);
474
+ expect(mockEnsureReady).toHaveBeenCalledWith();
475
+ });
476
+ });
477
+
478
+ describe('ribbon icon callback', () => {
479
+ it('reveals existing view when ribbon icon is clicked', async () => {
480
+ await plugin.onload();
481
+ const mockLeaf = { id: 'existing' };
482
+ mockApp.workspace.getLeavesOfType.mockReturnValue([mockLeaf]);
483
+
484
+ const ribbonCallback = (plugin.addRibbonIcon as jest.Mock).mock.calls[0][2];
485
+ await ribbonCallback();
486
+
487
+ expect(mockApp.workspace.revealLeaf).toHaveBeenCalledWith(mockLeaf);
488
+ });
489
+ });
490
+
491
+ describe('command callback', () => {
492
+ it('reveals existing view when command is executed', async () => {
493
+ await plugin.onload();
494
+ const mockLeaf = { id: 'existing' };
495
+ mockApp.workspace.getLeavesOfType.mockReturnValue([mockLeaf]);
496
+
497
+ const commandConfig = (plugin.addCommand as jest.Mock).mock.calls[0][0];
498
+ await commandConfig.callback();
499
+
500
+ expect(mockApp.workspace.revealLeaf).toHaveBeenCalledWith(mockLeaf);
501
+ });
502
+ });
503
+
504
+ describe('new-tab command', () => {
505
+ it('opens the view without creating a duplicate tab when no tab layout is persisted', async () => {
506
+ await plugin.onload();
507
+
508
+ const createNewTab = jest.fn().mockResolvedValue(undefined);
509
+ const mockView = {
510
+ createNewTab,
511
+ };
512
+
513
+ let viewOpened = false;
514
+ jest.spyOn(plugin, 'activateView').mockImplementation(async () => {
515
+ viewOpened = true;
516
+ });
517
+ jest.spyOn(plugin, 'getView').mockImplementation(() => (
518
+ viewOpened ? mockView as any : null
519
+ ));
520
+
521
+ const command = getRegisteredCommand('new-tab');
522
+
523
+ expect(command.checkCallback(true)).toBe(true);
524
+ expect(command.checkCallback(false)).toBe(true);
525
+
526
+ await new Promise<void>((resolve) => setImmediate(resolve));
527
+
528
+ expect(plugin.activateView).toHaveBeenCalledTimes(1);
529
+ expect(createNewTab).not.toHaveBeenCalled();
530
+ });
531
+
532
+ it('creates a new tab after reopening a persisted tab layout', async () => {
533
+ (plugin.loadData as jest.Mock).mockResolvedValue({
534
+ tabManagerState: {
535
+ openTabs: [
536
+ { tabId: 'tab-1', conversationId: null },
537
+ ],
538
+ activeTabId: 'tab-1',
539
+ },
540
+ });
541
+
542
+ await plugin.onload();
543
+
544
+ const createNewTab = jest.fn().mockResolvedValue(undefined);
545
+ const mockView = {
546
+ createNewTab,
547
+ };
548
+
549
+ let viewOpened = false;
550
+ jest.spyOn(plugin, 'activateView').mockImplementation(async () => {
551
+ viewOpened = true;
552
+ });
553
+ jest.spyOn(plugin, 'getView').mockImplementation(() => (
554
+ viewOpened ? mockView as any : null
555
+ ));
556
+
557
+ const command = getRegisteredCommand('new-tab');
558
+
559
+ expect(command.checkCallback(true)).toBe(true);
560
+ expect(command.checkCallback(false)).toBe(true);
561
+
562
+ await new Promise<void>((resolve) => setImmediate(resolve));
563
+
564
+ expect(plugin.activateView).toHaveBeenCalledTimes(1);
565
+ expect(createNewTab).toHaveBeenCalledTimes(1);
566
+ });
567
+
568
+ it('stays unavailable when the open view is already at the tab limit', async () => {
569
+ await plugin.onload();
570
+
571
+ const mockView = {
572
+ getTabManager: jest.fn().mockReturnValue({
573
+ canCreateTab: jest.fn().mockReturnValue(false),
574
+ }),
575
+ };
576
+
577
+ jest.spyOn(plugin, 'getView').mockReturnValue(mockView as any);
578
+
579
+ const command = getRegisteredCommand('new-tab');
580
+
581
+ expect(command.checkCallback(true)).toBe(false);
582
+ });
583
+
584
+ it('keeps tab commands unavailable while a Claudian leaf view is not initialized', async () => {
585
+ await plugin.onload();
586
+
587
+ mockApp.workspace.getLeavesOfType.mockReturnValue([{ view: {} }]);
588
+
589
+ for (const commandId of ['new-tab', 'new-session', 'close-current-tab']) {
590
+ const command = getRegisteredCommand(commandId);
591
+
592
+ expect(() => command.checkCallback(true)).not.toThrow();
593
+ expect(command.checkCallback(true)).toBe(false);
594
+ }
595
+ });
596
+
597
+ it('stays unavailable when reopening the persisted layout would already hit the tab limit', async () => {
598
+ (plugin.loadData as jest.Mock).mockResolvedValue({
599
+ tabManagerState: {
600
+ openTabs: [
601
+ { tabId: 'tab-1', conversationId: null },
602
+ { tabId: 'tab-2', conversationId: null },
603
+ { tabId: 'tab-3', conversationId: null },
604
+ ],
605
+ activeTabId: 'tab-3',
606
+ },
607
+ });
608
+
609
+ await plugin.onload();
610
+
611
+ jest.spyOn(plugin, 'getView').mockReturnValue(null);
612
+
613
+ const command = getRegisteredCommand('new-tab');
614
+
615
+ expect(command.checkCallback(true)).toBe(false);
616
+ });
617
+ });
618
+
619
+ describe('createConversation', () => {
620
+ it('should create a new conversation with unique ID', async () => {
621
+ await plugin.onload();
622
+
623
+ const conv = await plugin.createConversation();
624
+
625
+ expect(conv.id).toMatch(/^conv-\d+-[a-z0-9]+$/);
626
+ expect(conv.messages).toEqual([]);
627
+ expect(conv.sessionId).toBeNull();
628
+ });
629
+
630
+ it('should allow retrieving created conversation by ID', async () => {
631
+ await plugin.onload();
632
+
633
+ const conv = await plugin.createConversation();
634
+ const fetched = await plugin.getConversationById(conv.id);
635
+
636
+ expect(fetched?.id).toBe(conv.id);
637
+ });
638
+
639
+ it('should generate default title with timestamp', async () => {
640
+ await plugin.onload();
641
+
642
+ const conv = await plugin.createConversation();
643
+
644
+ // Title should contain month and time
645
+ expect(conv.title).toBeTruthy();
646
+ expect(conv.title.length).toBeGreaterThan(0);
647
+ });
648
+
649
+ // Note: Session management is now per-tab via TabManager
650
+ });
651
+
652
+ describe('switchConversation', () => {
653
+ it('should switch to existing conversation', async () => {
654
+ await plugin.onload();
655
+
656
+ const conv1 = await plugin.createConversation();
657
+ await plugin.createConversation(); // Create second conversation to switch from
658
+
659
+ const result = await plugin.switchConversation(conv1.id);
660
+
661
+ expect(result?.id).toBe(conv1.id);
662
+ });
663
+
664
+ // Note: Session ID restoration is now handled per-tab via TabManager
665
+
666
+ it('should return null for non-existent conversation', async () => {
667
+ await plugin.onload();
668
+
669
+ const result = await plugin.switchConversation('non-existent-id');
670
+
671
+ expect(result).toBeNull();
672
+ });
673
+ });
674
+
675
+ describe('deleteConversation', () => {
676
+ it('should delete conversation by ID', async () => {
677
+ await plugin.onload();
678
+
679
+ const conv = await plugin.createConversation();
680
+ const convId = conv.id;
681
+
682
+ // Create another so we have at least one left
683
+ await plugin.createConversation();
684
+
685
+ await plugin.deleteConversation(convId);
686
+
687
+ const list = plugin.getConversationList();
688
+ expect(list.find(c => c.id === convId)).toBeUndefined();
689
+ });
690
+
691
+ it('should allow deleting last conversation without recreating', async () => {
692
+ await plugin.onload();
693
+
694
+ const conv = await plugin.createConversation();
695
+ await plugin.deleteConversation(conv.id);
696
+
697
+ const list = plugin.getConversationList();
698
+ expect(list.find(c => c.id === conv.id)).toBeUndefined();
699
+ });
700
+ });
701
+
702
+ describe('renameConversation', () => {
703
+ it('should rename conversation', async () => {
704
+ await plugin.onload();
705
+
706
+ const conv = await plugin.createConversation();
707
+
708
+ await plugin.renameConversation(conv.id, 'New Title');
709
+
710
+ const updated = await plugin.getConversationById(conv.id);
711
+ expect(updated?.title).toBe('New Title');
712
+ });
713
+
714
+ it('should use default title if empty string provided', async () => {
715
+ await plugin.onload();
716
+
717
+ const conv = await plugin.createConversation();
718
+
719
+ await plugin.renameConversation(conv.id, ' ');
720
+
721
+ const updated = await plugin.getConversationById(conv.id);
722
+ expect(updated?.title).toBeTruthy();
723
+ });
724
+ });
725
+
726
+ describe('updateConversation', () => {
727
+ it('should update conversation messages', async () => {
728
+ await plugin.onload();
729
+
730
+ const conv = await plugin.createConversation();
731
+ const messages = [
732
+ { id: 'msg-1', role: 'user' as const, content: 'Hello', timestamp: Date.now() },
733
+ ];
734
+
735
+ await plugin.updateConversation(conv.id, { messages });
736
+
737
+ const updated = await plugin.getConversationById(conv.id);
738
+ expect(updated?.messages).toEqual(messages);
739
+ });
740
+
741
+ it('should update conversation sessionId', async () => {
742
+ await plugin.onload();
743
+
744
+ const conv = await plugin.createConversation();
745
+
746
+ await plugin.updateConversation(conv.id, { sessionId: 'new-session-id' });
747
+
748
+ const updated = await plugin.getConversationById(conv.id);
749
+ expect(updated?.sessionId).toBe('new-session-id');
750
+ });
751
+
752
+ it('should update updatedAt timestamp', async () => {
753
+ await plugin.onload();
754
+
755
+ const conv = await plugin.createConversation();
756
+ const originalUpdatedAt = conv.updatedAt;
757
+
758
+ // Small delay to ensure timestamp differs
759
+ await new Promise(resolve => setTimeout(resolve, 10));
760
+
761
+ await plugin.updateConversation(conv.id, { title: 'Changed' });
762
+
763
+ const updated = await plugin.getConversationById(conv.id);
764
+ expect(updated?.updatedAt).toBeGreaterThan(originalUpdatedAt);
765
+ });
766
+ });
767
+
768
+ describe('getConversationList', () => {
769
+ it('should return conversation metadata', async () => {
770
+ await plugin.onload();
771
+
772
+ await plugin.createConversation();
773
+
774
+ const list = plugin.getConversationList();
775
+
776
+ expect(list.length).toBeGreaterThan(0);
777
+ expect(list[0]).toHaveProperty('id');
778
+ expect(list[0]).toHaveProperty('title');
779
+ expect(list[0]).toHaveProperty('messageCount');
780
+ expect(list[0]).toHaveProperty('preview');
781
+ });
782
+
783
+ it('should return preview from first user message', async () => {
784
+ await plugin.onload();
785
+
786
+ const conv = await plugin.createConversation();
787
+ await plugin.updateConversation(conv.id, {
788
+ messages: [
789
+ { id: 'msg-1', role: 'user', content: 'Hello Claude', timestamp: Date.now() },
790
+ ],
791
+ });
792
+
793
+ const list = plugin.getConversationList();
794
+ const meta = list.find(c => c.id === conv.id);
795
+
796
+ expect(meta?.preview).toContain('Hello Claude');
797
+ });
798
+ });
799
+
800
+ describe('loadSettings with conversations', () => {
801
+ it('should load saved conversations from metadata files', async () => {
802
+ const timestamp = Date.now();
803
+ const sessionMeta = JSON.stringify({
804
+ id: 'conv-saved-1',
805
+ title: 'Saved Chat',
806
+ createdAt: timestamp,
807
+ updatedAt: timestamp,
808
+ sessionId: 'saved-session',
809
+ });
810
+
811
+ // Mock files exist
812
+ mockApp.vault.adapter.exists.mockImplementation(async (path: string) => {
813
+ // Session files
814
+ if (path === '.claudian/sessions' || path === '.claudian/sessions/conv-saved-1.meta.json') {
815
+ return true;
816
+ }
817
+ // claudian-settings.json exists
818
+ if (path === '.claudian/claudian-settings.json') {
819
+ return true;
820
+ }
821
+ return false;
822
+ });
823
+ mockApp.vault.adapter.list.mockImplementation(async (path: string) => {
824
+ if (path === '.claudian/sessions') {
825
+ return { files: ['.claudian/sessions/conv-saved-1.meta.json'], folders: [] };
826
+ }
827
+ return { files: [], folders: [] };
828
+ });
829
+ mockApp.vault.adapter.read.mockImplementation(async (path: string) => {
830
+ if (path === '.claudian/sessions/conv-saved-1.meta.json') {
831
+ return sessionMeta;
832
+ }
833
+ if (path === '.claudian/claudian-settings.json') {
834
+ return JSON.stringify({});
835
+ }
836
+ return '';
837
+ });
838
+
839
+ // data.json is minimal (no state - already migrated)
840
+ (plugin.loadData as jest.Mock).mockResolvedValue({});
841
+
842
+ await plugin.loadSettings();
843
+
844
+ const loaded = await plugin.getConversationById('conv-saved-1');
845
+ expect(loaded?.id).toBe('conv-saved-1');
846
+ expect(loaded?.title).toBe('Saved Chat');
847
+ });
848
+
849
+ it('should clear session IDs when provider base URL changes', async () => {
850
+ const timestamp = Date.now();
851
+ const sessionMeta = JSON.stringify({
852
+ id: 'conv-saved-1',
853
+ title: 'Saved Chat',
854
+ createdAt: timestamp,
855
+ updatedAt: timestamp,
856
+ sessionId: 'saved-session',
857
+ });
858
+
859
+ mockApp.vault.adapter.exists.mockImplementation(async (path: string) => {
860
+ return path === '.claudian/claudian-settings.json' ||
861
+ path === '.claudian/sessions' ||
862
+ path === '.claudian/sessions/conv-saved-1.meta.json';
863
+ });
864
+ mockApp.vault.adapter.list.mockImplementation(async (path: string) => {
865
+ if (path === '.claudian/sessions') {
866
+ return { files: ['.claudian/sessions/conv-saved-1.meta.json'], folders: [] };
867
+ }
868
+ return { files: [], folders: [] };
869
+ });
870
+ mockApp.vault.adapter.read.mockImplementation(async (path: string) => {
871
+ if (path === '.claudian/claudian-settings.json') {
872
+ // All these fields are now in claudian-settings.json
873
+ return JSON.stringify({
874
+ lastEnvHash: 'old-hash',
875
+ environmentVariables: 'ANTHROPIC_BASE_URL=https://api.example.com',
876
+ });
877
+ }
878
+ if (path === '.claudian/sessions/conv-saved-1.meta.json') {
879
+ return sessionMeta;
880
+ }
881
+ return '';
882
+ });
883
+
884
+ // data.json is minimal (already migrated)
885
+ (plugin.loadData as jest.Mock).mockResolvedValue({});
886
+
887
+ await plugin.loadSettings();
888
+
889
+ const loaded = await plugin.getConversationById('conv-saved-1');
890
+ expect(loaded?.sessionId).toBeNull();
891
+
892
+ const sessionWrite = (mockApp.vault.adapter.write as jest.Mock).mock.calls.find(
893
+ ([path]) => path === '.claudian/sessions/conv-saved-1.meta.json'
894
+ );
895
+ expect(sessionWrite).toBeDefined();
896
+ const meta = JSON.parse(sessionWrite?.[1] as string);
897
+ expect(meta.sessionId).toBeNull();
898
+ });
899
+
900
+ it('should ignore legacy activeConversationId when no sessions exist', async () => {
901
+ // No sessions exist
902
+ mockApp.vault.adapter.exists.mockResolvedValue(false);
903
+ mockApp.vault.adapter.list.mockResolvedValue({ files: [], folders: [] });
904
+
905
+ (plugin.loadData as jest.Mock).mockResolvedValue({
906
+ activeConversationId: 'non-existent',
907
+ migrationVersion: 2,
908
+ });
909
+
910
+ await plugin.loadSettings();
911
+
912
+ expect(plugin.getConversationList()).toHaveLength(0);
913
+ });
914
+ });
915
+
916
+ describe('Multi-session message loading', () => {
917
+ it('should load messages from previousProviderSessionIds when present', async () => {
918
+ const timestamp = Date.now();
919
+
920
+ // Setup conversation with previousProviderSessionIds
921
+ const sessionMeta = JSON.stringify({
922
+ type: 'meta',
923
+ id: 'conv-multi-session',
924
+ title: 'Multi Session Chat',
925
+ createdAt: timestamp,
926
+ updatedAt: timestamp,
927
+ providerState: {
928
+ providerSessionId: 'session-B',
929
+ previousProviderSessionIds: ['session-A'],
930
+ },
931
+ });
932
+
933
+ mockApp.vault.adapter.exists.mockImplementation(async (path: string) => {
934
+ return path === '.claudian/claudian-settings.json' ||
935
+ path === '.claudian/sessions' ||
936
+ path === '.claudian/sessions/conv-multi-session.meta.json';
937
+ });
938
+ mockApp.vault.adapter.list.mockImplementation(async (path: string) => {
939
+ if (path === '.claudian/sessions') {
940
+ return { files: ['.claudian/sessions/conv-multi-session.meta.json'], folders: [] };
941
+ }
942
+ return { files: [], folders: [] };
943
+ });
944
+ mockApp.vault.adapter.read.mockImplementation(async (path: string) => {
945
+ if (path === '.claudian/sessions/conv-multi-session.meta.json') {
946
+ return sessionMeta;
947
+ }
948
+ if (path === '.claudian/claudian-settings.json') {
949
+ return JSON.stringify({});
950
+ }
951
+ return '';
952
+ });
953
+
954
+ (plugin.loadData as jest.Mock).mockResolvedValue({});
955
+
956
+ await plugin.loadSettings();
957
+
958
+ const loaded = await plugin.getConversationById('conv-multi-session');
959
+ expect((loaded?.providerState as any)?.previousProviderSessionIds).toEqual(['session-A']);
960
+ expect((loaded?.providerState as any)?.providerSessionId).toBe('session-B');
961
+ });
962
+
963
+ it('should preserve previousProviderSessionIds through conversation updates', async () => {
964
+ await plugin.onload();
965
+
966
+ const conv = await plugin.createConversation();
967
+ await plugin.updateConversation(conv.id, {
968
+ providerState: {
969
+ providerSessionId: 'session-B',
970
+ previousProviderSessionIds: ['session-A'],
971
+ },
972
+ });
973
+
974
+ const updated = await plugin.getConversationById(conv.id);
975
+ expect((updated?.providerState as any)?.previousProviderSessionIds).toEqual(['session-A']);
976
+ expect((updated?.providerState as any)?.providerSessionId).toBe('session-B');
977
+
978
+ // Further update should preserve previousProviderSessionIds
979
+ await plugin.updateConversation(conv.id, {
980
+ title: 'Updated Title',
981
+ });
982
+
983
+ const afterTitleUpdate = await plugin.getConversationById(conv.id);
984
+ expect((afterTitleUpdate?.providerState as any)?.previousProviderSessionIds).toEqual(['session-A']);
985
+ });
986
+
987
+ it('should handle empty previousProviderSessionIds array', async () => {
988
+ await plugin.onload();
989
+
990
+ const conv = await plugin.createConversation();
991
+ await plugin.updateConversation(conv.id, {
992
+ providerState: {
993
+ providerSessionId: 'session-A',
994
+ previousProviderSessionIds: [],
995
+ },
996
+ });
997
+
998
+ const updated = await plugin.getConversationById(conv.id);
999
+ expect((updated?.providerState as any)?.previousProviderSessionIds).toEqual([]);
1000
+ });
1001
+ });
1002
+
1003
+ describe('loadSdkMessagesForConversation - fork branch', () => {
1004
+ it('should load from forkSource.sessionId and truncate at forkSource.resumeAt for pending fork', async () => {
1005
+ await plugin.onload();
1006
+
1007
+ const conv = await plugin.createConversation();
1008
+ await plugin.updateConversation(conv.id, {
1009
+ providerState: {
1010
+ forkSource: { sessionId: 'source-session-abc', resumeAt: 'asst-uuid-cutoff' },
1011
+ // No providerSessionId → isPendingFork returns true
1012
+ providerSessionId: undefined,
1013
+ },
1014
+ sessionId: null,
1015
+ });
1016
+
1017
+ const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true);
1018
+ const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({
1019
+ messages: [
1020
+ { id: 'sdk-msg-1', role: 'user', content: 'Hello', timestamp: 1000 },
1021
+ { id: 'sdk-msg-2', role: 'assistant', content: 'Hi', timestamp: 1001 },
1022
+ ],
1023
+ skippedLines: 0,
1024
+ });
1025
+
1026
+ // Trigger loadSdkMessagesForConversation via public API
1027
+ const loaded = await plugin.getConversationById(conv.id);
1028
+
1029
+ // Should check existence of source session, not the conversation's own session
1030
+ expect(existsSpy).toHaveBeenCalledWith(
1031
+ expect.any(String),
1032
+ 'source-session-abc'
1033
+ );
1034
+
1035
+ // Should load from forkSource.sessionId with forkSource.resumeAt as truncation point
1036
+ expect(loadSpy).toHaveBeenCalledWith(
1037
+ expect.any(String),
1038
+ 'source-session-abc',
1039
+ 'asst-uuid-cutoff'
1040
+ );
1041
+
1042
+ // Messages should be loaded
1043
+ expect(loaded?.messages).toBeDefined();
1044
+
1045
+ existsSpy.mockRestore();
1046
+ loadSpy.mockRestore();
1047
+ });
1048
+
1049
+ it('should NOT use fork path when conversation has its own providerSessionId', async () => {
1050
+ await plugin.onload();
1051
+
1052
+ const conv = await plugin.createConversation();
1053
+ await plugin.updateConversation(conv.id, {
1054
+ providerState: {
1055
+ forkSource: { sessionId: 'source-session', resumeAt: 'asst-uuid' },
1056
+ providerSessionId: 'own-session-id',
1057
+ },
1058
+ });
1059
+
1060
+ const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true);
1061
+ const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({
1062
+ messages: [],
1063
+ skippedLines: 0,
1064
+ });
1065
+
1066
+ await plugin.getConversationById(conv.id);
1067
+
1068
+ // Should load from own session, not forkSource session
1069
+ expect(existsSpy).toHaveBeenCalledWith(
1070
+ expect.any(String),
1071
+ 'own-session-id'
1072
+ );
1073
+
1074
+ existsSpy.mockRestore();
1075
+ loadSpy.mockRestore();
1076
+ });
1077
+ });
1078
+
1079
+ describe('loadSdkMessagesForConversation - subagent recovery', () => {
1080
+ it('restores subagent data when Task tool exists but subagent content block is missing', async () => {
1081
+ await plugin.onload();
1082
+
1083
+ const conv = await plugin.createConversation();
1084
+ await plugin.updateConversation(conv.id, {
1085
+ providerState: {
1086
+ providerSessionId: 'session-subagent-recovery',
1087
+ subagentData: {
1088
+ 'task-1': {
1089
+ id: 'task-1',
1090
+ description: 'Recovered subagent',
1091
+ status: 'completed',
1092
+ result: 'Recovered result',
1093
+ toolCalls: [
1094
+ {
1095
+ id: 'sub-tool-1',
1096
+ name: 'Read',
1097
+ input: { file_path: 'README.md' },
1098
+ status: 'completed',
1099
+ result: 'content',
1100
+ } as any,
1101
+ ],
1102
+ isExpanded: false,
1103
+ } as any,
1104
+ },
1105
+ },
1106
+ messages: [
1107
+ {
1108
+ id: 'assistant-1',
1109
+ role: 'assistant',
1110
+ content: '',
1111
+ timestamp: 1000,
1112
+ toolCalls: [
1113
+ {
1114
+ id: 'task-1',
1115
+ name: 'Task',
1116
+ input: { description: 'Do sub task' },
1117
+ status: 'completed',
1118
+ result: 'Task completed',
1119
+ } as any,
1120
+ ],
1121
+ // Simulate partial persisted blocks that lost the task tool block.
1122
+ contentBlocks: [{ type: 'text', content: 'Done' }] as any,
1123
+ } as any,
1124
+ ],
1125
+ });
1126
+
1127
+ const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true);
1128
+ const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({
1129
+ messages: [],
1130
+ skippedLines: 0,
1131
+ });
1132
+
1133
+ const loaded = await plugin.getConversationById(conv.id);
1134
+ expect(loadSpy).toHaveBeenCalledWith(
1135
+ expect.any(String),
1136
+ 'session-subagent-recovery',
1137
+ undefined
1138
+ );
1139
+ expect(loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-1')).toEqual(
1140
+ expect.objectContaining({
1141
+ subagent: expect.objectContaining({
1142
+ id: 'task-1',
1143
+ description: 'Recovered subagent',
1144
+ result: 'Recovered result',
1145
+ }),
1146
+ })
1147
+ );
1148
+ expect(loaded?.messages[0].contentBlocks).toEqual(
1149
+ expect.arrayContaining([
1150
+ expect.objectContaining({ type: 'subagent', subagentId: 'task-1' }),
1151
+ ])
1152
+ );
1153
+
1154
+ existsSpy.mockRestore();
1155
+ loadSpy.mockRestore();
1156
+ });
1157
+
1158
+ it('prefers richer SDK task result over stale cached subagent result', async () => {
1159
+ await plugin.onload();
1160
+
1161
+ const conv = await plugin.createConversation();
1162
+ await plugin.updateConversation(conv.id, {
1163
+ providerState: {
1164
+ providerSessionId: 'session-subagent-merge',
1165
+ subagentData: {
1166
+ 'task-merge-1': {
1167
+ id: 'task-merge-1',
1168
+ description: 'Recovered subagent',
1169
+ mode: 'async',
1170
+ asyncStatus: 'completed',
1171
+ status: 'completed',
1172
+ result: 'Short stale result',
1173
+ toolCalls: [],
1174
+ isExpanded: false,
1175
+ } as any,
1176
+ },
1177
+ },
1178
+ messages: [
1179
+ {
1180
+ id: 'assistant-merge',
1181
+ role: 'assistant',
1182
+ content: '',
1183
+ timestamp: 1000,
1184
+ toolCalls: [
1185
+ {
1186
+ id: 'task-merge-1',
1187
+ name: 'Task',
1188
+ input: { description: 'Do sub task', run_in_background: true },
1189
+ status: 'completed',
1190
+ result: 'Full SDK result from queue-operation',
1191
+ } as any,
1192
+ ],
1193
+ contentBlocks: [{ type: 'subagent', subagentId: 'task-merge-1', mode: 'async' }] as any,
1194
+ } as any,
1195
+ ],
1196
+ });
1197
+
1198
+ const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true);
1199
+ const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({
1200
+ messages: [],
1201
+ skippedLines: 0,
1202
+ });
1203
+
1204
+ const loaded = await plugin.getConversationById(conv.id);
1205
+ const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-merge-1');
1206
+
1207
+ expect(loadSpy).toHaveBeenCalledWith(
1208
+ expect.any(String),
1209
+ 'session-subagent-merge',
1210
+ undefined
1211
+ );
1212
+ expect(taskTool?.result).toBe('Full SDK result from queue-operation');
1213
+ expect(taskTool?.subagent?.result).toBe('Full SDK result from queue-operation');
1214
+
1215
+ existsSpy.mockRestore();
1216
+ loadSpy.mockRestore();
1217
+ });
1218
+
1219
+ it('keeps the richer cached async result when both SDK and cache are terminal', async () => {
1220
+ await plugin.onload();
1221
+
1222
+ const conv = await plugin.createConversation();
1223
+ await plugin.updateConversation(conv.id, {
1224
+ providerState: {
1225
+ providerSessionId: 'session-subagent-cache-richer',
1226
+ subagentData: {
1227
+ 'task-merge-2': {
1228
+ id: 'task-merge-2',
1229
+ description: 'Recovered subagent',
1230
+ mode: 'async',
1231
+ asyncStatus: 'completed',
1232
+ status: 'completed',
1233
+ result: 'Recovered final result with full details',
1234
+ toolCalls: [],
1235
+ isExpanded: false,
1236
+ agentId: 'agent-cache-richer',
1237
+ } as any,
1238
+ },
1239
+ },
1240
+ messages: [],
1241
+ });
1242
+
1243
+ const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true);
1244
+ const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({
1245
+ messages: [
1246
+ {
1247
+ id: 'assistant-cache-richer',
1248
+ role: 'assistant',
1249
+ content: '',
1250
+ timestamp: 1000,
1251
+ toolCalls: [
1252
+ {
1253
+ id: 'task-merge-2',
1254
+ name: 'Task',
1255
+ input: { description: 'SDK async subagent', run_in_background: true },
1256
+ status: 'completed',
1257
+ result: 'Short SDK result',
1258
+ subagent: {
1259
+ id: 'task-merge-2',
1260
+ description: 'SDK async subagent',
1261
+ mode: 'async',
1262
+ asyncStatus: 'completed',
1263
+ status: 'completed',
1264
+ result: 'Short SDK result',
1265
+ toolCalls: [],
1266
+ isExpanded: false,
1267
+ agentId: 'agent-cache-richer',
1268
+ },
1269
+ } as any,
1270
+ ],
1271
+ contentBlocks: [{ type: 'subagent', subagentId: 'task-merge-2', mode: 'async' }] as any,
1272
+ } as any,
1273
+ ],
1274
+ skippedLines: 0,
1275
+ });
1276
+
1277
+ const loaded = await plugin.getConversationById(conv.id);
1278
+ const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-merge-2');
1279
+
1280
+ expect(taskTool?.status).toBe('completed');
1281
+ expect(taskTool?.result).toBe('Recovered final result with full details');
1282
+ expect(taskTool?.subagent?.result).toBe('Recovered final result with full details');
1283
+
1284
+ existsSpy.mockRestore();
1285
+ loadSpy.mockRestore();
1286
+ });
1287
+
1288
+ it('drops stale asyncStatus from cached sync subagents during recovery', async () => {
1289
+ await plugin.onload();
1290
+
1291
+ const conv = await plugin.createConversation();
1292
+ await plugin.updateConversation(conv.id, {
1293
+ providerState: {
1294
+ providerSessionId: 'session-sync-subagent-cleanup',
1295
+ subagentData: {
1296
+ 'task-sync-1': {
1297
+ id: 'task-sync-1',
1298
+ description: 'Recovered sync subagent',
1299
+ mode: 'sync',
1300
+ asyncStatus: 'completed',
1301
+ status: 'completed',
1302
+ result: 'Recovered sync result',
1303
+ toolCalls: [],
1304
+ isExpanded: false,
1305
+ } as any,
1306
+ },
1307
+ },
1308
+ messages: [
1309
+ {
1310
+ id: 'assistant-sync',
1311
+ role: 'assistant',
1312
+ content: '',
1313
+ timestamp: 1000,
1314
+ toolCalls: [
1315
+ {
1316
+ id: 'task-sync-1',
1317
+ name: 'Task',
1318
+ input: { description: 'Do sync task' },
1319
+ status: 'completed',
1320
+ result: 'Sync result',
1321
+ } as any,
1322
+ ],
1323
+ contentBlocks: [{ type: 'subagent', subagentId: 'task-sync-1', mode: 'sync' }] as any,
1324
+ } as any,
1325
+ ],
1326
+ });
1327
+
1328
+ const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true);
1329
+ const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({
1330
+ messages: [],
1331
+ skippedLines: 0,
1332
+ });
1333
+
1334
+ const loaded = await plugin.getConversationById(conv.id);
1335
+ const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-sync-1');
1336
+
1337
+ expect(taskTool?.subagent?.mode).toBe('sync');
1338
+ expect(taskTool?.subagent?.asyncStatus).toBeUndefined();
1339
+
1340
+ existsSpy.mockRestore();
1341
+ loadSpy.mockRestore();
1342
+ });
1343
+
1344
+ it('prefers terminal SDK async status over stale cached running state', async () => {
1345
+ await plugin.onload();
1346
+
1347
+ const conv = await plugin.createConversation();
1348
+ await plugin.updateConversation(conv.id, {
1349
+ providerState: {
1350
+ providerSessionId: 'session-async-sdk-terminal',
1351
+ subagentData: {
1352
+ 'task-async-sdk-terminal': {
1353
+ id: 'task-async-sdk-terminal',
1354
+ description: 'Cached async subagent',
1355
+ mode: 'async',
1356
+ asyncStatus: 'running',
1357
+ status: 'running',
1358
+ result: 'Still running',
1359
+ toolCalls: [],
1360
+ isExpanded: false,
1361
+ } as any,
1362
+ },
1363
+ },
1364
+ messages: [],
1365
+ });
1366
+
1367
+ const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true);
1368
+ const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({
1369
+ messages: [
1370
+ {
1371
+ id: 'assistant-sdk-terminal',
1372
+ role: 'assistant',
1373
+ content: '',
1374
+ timestamp: 1000,
1375
+ toolCalls: [
1376
+ {
1377
+ id: 'task-async-sdk-terminal',
1378
+ name: 'Task',
1379
+ input: { description: 'SDK async subagent', run_in_background: true },
1380
+ status: 'completed',
1381
+ result: 'Full SDK final result',
1382
+ subagent: {
1383
+ id: 'task-async-sdk-terminal',
1384
+ description: 'SDK async subagent',
1385
+ mode: 'async',
1386
+ asyncStatus: 'completed',
1387
+ status: 'completed',
1388
+ result: 'Full SDK final result',
1389
+ toolCalls: [],
1390
+ isExpanded: false,
1391
+ agentId: 'agent-sdk-terminal',
1392
+ },
1393
+ } as any,
1394
+ ],
1395
+ contentBlocks: [{ type: 'subagent', subagentId: 'task-async-sdk-terminal', mode: 'async' }] as any,
1396
+ } as any,
1397
+ ],
1398
+ skippedLines: 0,
1399
+ });
1400
+
1401
+ const loaded = await plugin.getConversationById(conv.id);
1402
+ const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-async-sdk-terminal');
1403
+
1404
+ expect(taskTool?.status).toBe('completed');
1405
+ expect(taskTool?.result).toBe('Full SDK final result');
1406
+ expect(taskTool?.subagent?.status).toBe('completed');
1407
+ expect(taskTool?.subagent?.asyncStatus).toBe('completed');
1408
+ expect(taskTool?.subagent?.result).toBe('Full SDK final result');
1409
+
1410
+ existsSpy.mockRestore();
1411
+ loadSpy.mockRestore();
1412
+ });
1413
+
1414
+ it('prefers cached terminal async status over SDK launch-only running state', async () => {
1415
+ await plugin.onload();
1416
+
1417
+ const conv = await plugin.createConversation();
1418
+ await plugin.updateConversation(conv.id, {
1419
+ providerState: {
1420
+ providerSessionId: 'session-async-cache-terminal',
1421
+ subagentData: {
1422
+ 'task-async-cache-terminal': {
1423
+ id: 'task-async-cache-terminal',
1424
+ description: 'Cached async subagent',
1425
+ mode: 'async',
1426
+ asyncStatus: 'completed',
1427
+ status: 'completed',
1428
+ result: 'Recovered final result',
1429
+ toolCalls: [],
1430
+ isExpanded: false,
1431
+ agentId: 'agent-cache-terminal',
1432
+ } as any,
1433
+ },
1434
+ },
1435
+ messages: [],
1436
+ });
1437
+
1438
+ const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true);
1439
+ const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({
1440
+ messages: [
1441
+ {
1442
+ id: 'assistant-sdk-running',
1443
+ role: 'assistant',
1444
+ content: '',
1445
+ timestamp: 1000,
1446
+ toolCalls: [
1447
+ {
1448
+ id: 'task-async-cache-terminal',
1449
+ name: 'Task',
1450
+ input: { description: 'SDK async subagent', run_in_background: true },
1451
+ status: 'running',
1452
+ result: 'Task launched in background.',
1453
+ subagent: {
1454
+ id: 'task-async-cache-terminal',
1455
+ description: 'SDK async subagent',
1456
+ mode: 'async',
1457
+ asyncStatus: 'running',
1458
+ status: 'running',
1459
+ result: 'Task launched in background.',
1460
+ toolCalls: [],
1461
+ isExpanded: false,
1462
+ agentId: 'agent-cache-terminal',
1463
+ },
1464
+ } as any,
1465
+ ],
1466
+ contentBlocks: [{ type: 'subagent', subagentId: 'task-async-cache-terminal', mode: 'async' }] as any,
1467
+ } as any,
1468
+ ],
1469
+ skippedLines: 0,
1470
+ });
1471
+
1472
+ const loaded = await plugin.getConversationById(conv.id);
1473
+ const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-async-cache-terminal');
1474
+
1475
+ expect(taskTool?.status).toBe('completed');
1476
+ expect(taskTool?.result).toBe('Recovered final result');
1477
+ expect(taskTool?.subagent?.status).toBe('completed');
1478
+ expect(taskTool?.subagent?.asyncStatus).toBe('completed');
1479
+ expect(taskTool?.subagent?.result).toBe('Recovered final result');
1480
+
1481
+ existsSpy.mockRestore();
1482
+ loadSpy.mockRestore();
1483
+ });
1484
+
1485
+ it('restores async subagent data and mode when Task tool exists but async block is missing', async () => {
1486
+ await plugin.onload();
1487
+
1488
+ const conv = await plugin.createConversation();
1489
+ await plugin.updateConversation(conv.id, {
1490
+ providerState: {
1491
+ providerSessionId: 'session-async-subagent-recovery',
1492
+ subagentData: {
1493
+ 'task-async-1': {
1494
+ id: 'task-async-1',
1495
+ description: 'Recovered async subagent',
1496
+ mode: 'async',
1497
+ asyncStatus: 'completed',
1498
+ status: 'completed',
1499
+ result: 'Recovered async result',
1500
+ toolCalls: [],
1501
+ isExpanded: false,
1502
+ } as any,
1503
+ },
1504
+ },
1505
+ messages: [
1506
+ {
1507
+ id: 'assistant-1',
1508
+ role: 'assistant',
1509
+ content: '',
1510
+ timestamp: 1000,
1511
+ toolCalls: [
1512
+ {
1513
+ id: 'task-async-1',
1514
+ name: 'Task',
1515
+ input: { description: 'Do background task', run_in_background: true },
1516
+ status: 'completed',
1517
+ result: 'Task started',
1518
+ } as any,
1519
+ ],
1520
+ contentBlocks: [{ type: 'text', content: 'Started' }] as any,
1521
+ } as any,
1522
+ ],
1523
+ });
1524
+
1525
+ const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true);
1526
+ const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({
1527
+ messages: [],
1528
+ skippedLines: 0,
1529
+ });
1530
+
1531
+ const loaded = await plugin.getConversationById(conv.id);
1532
+ const block = loaded?.messages[0].contentBlocks?.find(
1533
+ (b: any) => b.type === 'subagent' && b.subagentId === 'task-async-1'
1534
+ ) as any;
1535
+
1536
+ expect(loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-async-1')).toEqual(
1537
+ expect.objectContaining({
1538
+ id: 'task-async-1',
1539
+ subagent: expect.objectContaining({
1540
+ id: 'task-async-1',
1541
+ mode: 'async',
1542
+ asyncStatus: 'completed',
1543
+ }),
1544
+ })
1545
+ );
1546
+ expect(block).toEqual(
1547
+ expect.objectContaining({ type: 'subagent', subagentId: 'task-async-1', mode: 'async' })
1548
+ );
1549
+
1550
+ existsSpy.mockRestore();
1551
+ loadSpy.mockRestore();
1552
+ });
1553
+
1554
+ it('hydrates async subagent tool calls from SDK subagent files on reload', async () => {
1555
+ await plugin.onload();
1556
+
1557
+ const conv = await plugin.createConversation();
1558
+ await plugin.updateConversation(conv.id, {
1559
+ providerState: {
1560
+ providerSessionId: 'session-async-subagent-tools',
1561
+ subagentData: {
1562
+ 'task-async-tools': {
1563
+ id: 'task-async-tools',
1564
+ description: 'Recovered async subagent',
1565
+ mode: 'async',
1566
+ asyncStatus: 'completed',
1567
+ status: 'completed',
1568
+ result: 'Recovered async result',
1569
+ agentId: 'agent-a123',
1570
+ toolCalls: [],
1571
+ isExpanded: false,
1572
+ } as any,
1573
+ },
1574
+ },
1575
+ messages: [
1576
+ {
1577
+ id: 'assistant-1',
1578
+ role: 'assistant',
1579
+ content: '',
1580
+ timestamp: 1000,
1581
+ toolCalls: [
1582
+ {
1583
+ id: 'task-async-tools',
1584
+ name: 'Task',
1585
+ input: { description: 'Do background task', run_in_background: true },
1586
+ status: 'completed',
1587
+ result: 'Task started',
1588
+ } as any,
1589
+ ],
1590
+ contentBlocks: [{ type: 'subagent', subagentId: 'task-async-tools', mode: 'async' }] as any,
1591
+ } as any,
1592
+ ],
1593
+ });
1594
+
1595
+ const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true);
1596
+ const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({
1597
+ messages: [],
1598
+ skippedLines: 0,
1599
+ });
1600
+ const loadSubagentToolsSpy = jest.spyOn(sdkSession, 'loadSubagentToolCalls').mockResolvedValue([
1601
+ {
1602
+ id: 'sub-tool-1',
1603
+ name: 'Bash',
1604
+ input: { command: 'ls' },
1605
+ status: 'completed',
1606
+ result: 'ok',
1607
+ isExpanded: false,
1608
+ } as any,
1609
+ ]);
1610
+
1611
+ const loaded = await plugin.getConversationById(conv.id);
1612
+ const taskTool = loaded?.messages[0].toolCalls?.find(tc => tc.id === 'task-async-tools');
1613
+
1614
+ expect(loadSubagentToolsSpy).toHaveBeenCalledWith(
1615
+ expect.any(String),
1616
+ 'session-async-subagent-tools',
1617
+ 'agent-a123'
1618
+ );
1619
+ expect(taskTool?.subagent?.toolCalls).toEqual(
1620
+ expect.arrayContaining([
1621
+ expect.objectContaining({
1622
+ id: 'sub-tool-1',
1623
+ name: 'Bash',
1624
+ result: 'ok',
1625
+ }),
1626
+ ])
1627
+ );
1628
+
1629
+ existsSpy.mockRestore();
1630
+ loadSpy.mockRestore();
1631
+ loadSubagentToolsSpy.mockRestore();
1632
+ });
1633
+
1634
+ it('keeps async subagent renderer visible when task block and task tool call are both missing', async () => {
1635
+ await plugin.onload();
1636
+
1637
+ const conv = await plugin.createConversation();
1638
+ await plugin.updateConversation(conv.id, {
1639
+ providerState: {
1640
+ providerSessionId: 'session-async-subagent-fallback',
1641
+ subagentData: {
1642
+ 'task-async-orphan': {
1643
+ id: 'task-async-orphan',
1644
+ description: 'Recovered async orphan subagent',
1645
+ mode: 'async',
1646
+ asyncStatus: 'running',
1647
+ status: 'running',
1648
+ result: 'Running in background',
1649
+ toolCalls: [],
1650
+ isExpanded: false,
1651
+ } as any,
1652
+ },
1653
+ },
1654
+ messages: [
1655
+ {
1656
+ id: 'assistant-1',
1657
+ role: 'assistant',
1658
+ content: 'Background work started',
1659
+ timestamp: 1000,
1660
+ contentBlocks: [{ type: 'text', content: 'Background work started' }] as any,
1661
+ } as any,
1662
+ ],
1663
+ });
1664
+
1665
+ const existsSpy = jest.spyOn(sdkSession, 'sdkSessionExists').mockReturnValue(true);
1666
+ const loadSpy = jest.spyOn(sdkSession, 'loadSDKSessionMessages').mockResolvedValue({
1667
+ messages: [],
1668
+ skippedLines: 0,
1669
+ });
1670
+
1671
+ const loaded = await plugin.getConversationById(conv.id);
1672
+ const assistant = loaded?.messages.find(m => m.id === 'assistant-1');
1673
+ const block = assistant?.contentBlocks?.find(
1674
+ (b: any) => b.type === 'subagent' && b.subagentId === 'task-async-orphan'
1675
+ ) as any;
1676
+
1677
+ expect(assistant?.toolCalls?.find((tc: any) => tc.id === 'task-async-orphan')).toEqual(
1678
+ expect.objectContaining({
1679
+ id: 'task-async-orphan',
1680
+ name: TOOL_SUBAGENT,
1681
+ subagent: expect.objectContaining({
1682
+ id: 'task-async-orphan',
1683
+ mode: 'async',
1684
+ asyncStatus: 'running',
1685
+ }),
1686
+ })
1687
+ );
1688
+ expect(block).toEqual(
1689
+ expect.objectContaining({
1690
+ type: 'subagent',
1691
+ subagentId: 'task-async-orphan',
1692
+ mode: 'async',
1693
+ })
1694
+ );
1695
+
1696
+ existsSpy.mockRestore();
1697
+ loadSpy.mockRestore();
1698
+ });
1699
+ });
1700
+
1701
+ });