@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,2004 @@
1
+ import '@/providers';
2
+
3
+ import { createMockEl } from '@test/helpers/mockElement';
4
+ import { Menu } from 'obsidian';
5
+
6
+ import {
7
+ TOOL_AGENT_OUTPUT,
8
+ TOOL_APPLY_PATCH,
9
+ TOOL_SPAWN_AGENT,
10
+ TOOL_TASK,
11
+ TOOL_WAIT_AGENT,
12
+ TOOL_WRITE_STDIN,
13
+ } from '@/core/tools/toolNames';
14
+ import type { ChatMessage, ImageAttachment } from '@/core/types';
15
+ import { MessageRenderer } from '@/features/chat/rendering/MessageRenderer';
16
+ import { renderStoredAsyncSubagent, renderStoredSubagent } from '@/features/chat/rendering/SubagentRenderer';
17
+ import { renderStoredThinkingBlock } from '@/features/chat/rendering/ThinkingBlockRenderer';
18
+ import { renderStoredToolCall } from '@/features/chat/rendering/ToolCallRenderer';
19
+ import { renderStoredWriteEdit } from '@/features/chat/rendering/WriteEditRenderer';
20
+
21
+ jest.mock('@/features/chat/rendering/SubagentRenderer', () => ({
22
+ renderStoredAsyncSubagent: jest.fn().mockReturnValue({ wrapperEl: {}, cleanup: jest.fn() }),
23
+ renderStoredSubagent: jest.fn(),
24
+ }));
25
+ jest.mock('@/features/chat/rendering/ThinkingBlockRenderer', () => ({
26
+ renderStoredThinkingBlock: jest.fn(),
27
+ }));
28
+ jest.mock('@/features/chat/rendering/ToolCallRenderer', () => ({
29
+ renderStoredToolCall: jest.fn(),
30
+ }));
31
+ jest.mock('@/features/chat/rendering/WriteEditRenderer', () => ({
32
+ renderStoredWriteEdit: jest.fn(),
33
+ }));
34
+ jest.mock('@/utils/imageEmbed', () => ({
35
+ replaceImageEmbedsWithHtml: jest.fn().mockImplementation((md: string) => md),
36
+ }));
37
+ jest.mock('@/utils/fileLink', () => ({
38
+ processFileLinks: jest.fn(),
39
+ registerFileLinkHandler: jest.fn(),
40
+ }));
41
+
42
+ function createMockComponent() {
43
+ return {
44
+ registerDomEvent: jest.fn(),
45
+ register: jest.fn(),
46
+ addChild: jest.fn(),
47
+ load: jest.fn(),
48
+ unload: jest.fn(),
49
+ };
50
+ }
51
+
52
+ function mockCapabilities(providerId: 'claude' | 'codex' = 'claude') {
53
+ return () => ({
54
+ providerId,
55
+ supportsPersistentRuntime: true,
56
+ supportsNativeHistory: providerId === 'claude',
57
+ supportsPlanMode: true,
58
+ supportsRewind: true,
59
+ supportsFork: true,
60
+ supportsProviderCommands: true,
61
+ supportsImageAttachments: true,
62
+ supportsInstructionMode: true,
63
+ supportsMcpTools: true,
64
+ reasoningControl: 'effort' as const,
65
+ });
66
+ }
67
+
68
+ function createRenderer(
69
+ messagesEl?: any,
70
+ providerId: 'claude' | 'codex' = 'claude',
71
+ settings: Record<string, unknown> = {},
72
+ ) {
73
+ const el = messagesEl ?? createMockEl();
74
+ const comp = createMockComponent();
75
+ const plugin = {
76
+ app: {},
77
+ settings: { mediaFolder: '', ...settings },
78
+ };
79
+ return {
80
+ renderer: new MessageRenderer(
81
+ plugin as any,
82
+ comp as any,
83
+ el,
84
+ undefined,
85
+ undefined,
86
+ mockCapabilities(providerId),
87
+ ),
88
+ messagesEl: el,
89
+ };
90
+ }
91
+
92
+ describe('MessageRenderer', () => {
93
+ beforeEach(() => {
94
+ jest.clearAllMocks();
95
+ (Menu as typeof Menu & { instances: unknown[] }).instances.length = 0;
96
+ });
97
+
98
+ // ============================================
99
+ // renderMessages
100
+ // ============================================
101
+
102
+ it('renders welcome element and calls renderStoredMessage for each message', () => {
103
+ const messagesEl = createMockEl();
104
+ const emptySpy = jest.spyOn(messagesEl, 'empty');
105
+ const mockComponent = createMockComponent();
106
+ const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl);
107
+ const renderStoredSpy = jest.spyOn(renderer, 'renderStoredMessage').mockImplementation(() => {});
108
+
109
+ const messages: ChatMessage[] = [
110
+ { id: 'm1', role: 'assistant', content: '', timestamp: Date.now(), toolCalls: [], contentBlocks: [] },
111
+ ];
112
+
113
+ const welcomeEl = renderer.renderMessages(messages, () => 'Hello');
114
+
115
+ expect(emptySpy).toHaveBeenCalled();
116
+ expect(renderStoredSpy).toHaveBeenCalledTimes(1);
117
+ expect(welcomeEl.hasClass('claudian-welcome')).toBe(true);
118
+ expect(welcomeEl.children[0].textContent).toBe('Hello');
119
+ });
120
+
121
+ it('renders empty messages list with just welcome element', () => {
122
+ const { renderer } = createRenderer();
123
+ const renderStoredSpy = jest.spyOn(renderer, 'renderStoredMessage').mockImplementation(() => {});
124
+
125
+ const welcomeEl = renderer.renderMessages([], () => 'Welcome!');
126
+
127
+ expect(renderStoredSpy).not.toHaveBeenCalled();
128
+ expect(welcomeEl.hasClass('claudian-welcome')).toBe(true);
129
+ });
130
+
131
+ // ============================================
132
+ // renderStoredMessage
133
+ // ============================================
134
+
135
+ it('renders interrupt messages with interrupt styling instead of user bubble', () => {
136
+ const messagesEl = createMockEl();
137
+ const mockComponent = createMockComponent();
138
+ const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl);
139
+
140
+ const interruptMsg: ChatMessage = {
141
+ id: 'interrupt-1',
142
+ role: 'user',
143
+ content: '[Request interrupted by user]',
144
+ timestamp: Date.now(),
145
+ isInterrupt: true,
146
+ };
147
+
148
+ renderer.renderStoredMessage(interruptMsg);
149
+
150
+ // Should create assistant-style message with interrupt content
151
+ expect(messagesEl.children.length).toBe(1);
152
+ const msgEl = messagesEl.children[0];
153
+ expect(msgEl.hasClass('claudian-message-assistant')).toBe(true);
154
+ // Check the content contains interrupt styling
155
+ const contentEl = msgEl.children[0];
156
+ const textEl = contentEl.children[0];
157
+ const interruptedEl = textEl.children[0];
158
+ expect(interruptedEl.hasClass('claudian-interrupted')).toBe(true);
159
+ expect(interruptedEl.textContent).toBe('Interrupted');
160
+ });
161
+
162
+ it('renders interrupted assistant message with content + interrupt indicator', () => {
163
+ const messagesEl = createMockEl();
164
+ const { renderer } = createRenderer(messagesEl);
165
+
166
+ const interruptMsg: ChatMessage = {
167
+ id: 'interrupt-codex-1',
168
+ role: 'assistant',
169
+ content: 'Starting to work on the feature...',
170
+ timestamp: Date.now(),
171
+ isInterrupt: true,
172
+ contentBlocks: [{ type: 'text', content: 'Starting to work on the feature...' }],
173
+ };
174
+
175
+ renderer.renderStoredMessage(interruptMsg);
176
+
177
+ // Should create an assistant message (not a bare interrupt marker)
178
+ expect(messagesEl.children.length).toBe(1);
179
+ const msgEl = messagesEl.children[0];
180
+ expect(msgEl.hasClass('claudian-message-assistant')).toBe(true);
181
+
182
+ // The content div should have both content rendering and an interrupt indicator
183
+ const contentEl = msgEl.children[0];
184
+ const lastChild = contentEl.children[contentEl.children.length - 1];
185
+ const interruptedEl = lastChild.children[0];
186
+ expect(interruptedEl.hasClass('claudian-interrupted')).toBe(true);
187
+ expect(interruptedEl.textContent).toBe('Interrupted');
188
+ });
189
+
190
+ it('renders bare interrupt marker for empty interrupted assistant message', () => {
191
+ const messagesEl = createMockEl();
192
+ const mockComponent = createMockComponent();
193
+ const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl);
194
+
195
+ const interruptMsg: ChatMessage = {
196
+ id: 'interrupt-codex-2',
197
+ role: 'assistant',
198
+ content: '',
199
+ timestamp: Date.now(),
200
+ isInterrupt: true,
201
+ };
202
+
203
+ renderer.renderStoredMessage(interruptMsg);
204
+
205
+ // Should create a bare interrupt marker (same as Claude-style)
206
+ expect(messagesEl.children.length).toBe(1);
207
+ const msgEl = messagesEl.children[0];
208
+ expect(msgEl.hasClass('claudian-message-assistant')).toBe(true);
209
+ const contentEl = msgEl.children[0];
210
+ const textEl = contentEl.children[0];
211
+ expect(textEl.children[0].hasClass('claudian-interrupted')).toBe(true);
212
+ });
213
+
214
+ it('skips rebuilt context messages', () => {
215
+ const messagesEl = createMockEl();
216
+ const { renderer } = createRenderer(messagesEl);
217
+
218
+ const msg: ChatMessage = {
219
+ id: 'rebuilt-1',
220
+ role: 'user',
221
+ content: 'rebuilt context',
222
+ timestamp: Date.now(),
223
+ isRebuiltContext: true,
224
+ };
225
+
226
+ renderer.renderStoredMessage(msg);
227
+
228
+ expect(messagesEl.children.length).toBe(0);
229
+ });
230
+
231
+ it('renders user message with text content', () => {
232
+ const messagesEl = createMockEl();
233
+ const { renderer } = createRenderer(messagesEl);
234
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
235
+
236
+ const msg: ChatMessage = {
237
+ id: 'u1',
238
+ role: 'user',
239
+ content: 'Hello world',
240
+ timestamp: Date.now(),
241
+ };
242
+
243
+ renderer.renderStoredMessage(msg);
244
+
245
+ expect(messagesEl.children.length).toBe(1);
246
+ const msgEl = messagesEl.children[0];
247
+ expect(msgEl.hasClass('claudian-message-user')).toBe(true);
248
+ });
249
+
250
+ it('renders user message with displayContent instead of content', () => {
251
+ const messagesEl = createMockEl();
252
+ const { renderer } = createRenderer(messagesEl);
253
+ const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
254
+
255
+ const msg: ChatMessage = {
256
+ id: 'u1',
257
+ role: 'user',
258
+ content: 'full prompt with context',
259
+ displayContent: 'user input only',
260
+ timestamp: Date.now(),
261
+ };
262
+
263
+ renderer.renderStoredMessage(msg);
264
+
265
+ expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'user input only');
266
+ });
267
+
268
+ it('renders extracted user display content when stored message has hidden XML context', () => {
269
+ const messagesEl = createMockEl();
270
+ const { renderer } = createRenderer(messagesEl);
271
+ const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
272
+
273
+ const msg: ChatMessage = {
274
+ id: 'u1',
275
+ role: 'user',
276
+ content: 'Explain this\n\n<current_note>\nnotes/test.md\n</current_note>',
277
+ timestamp: Date.now(),
278
+ };
279
+
280
+ renderer.renderStoredMessage(msg);
281
+
282
+ expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Explain this');
283
+ });
284
+
285
+ it('skips empty user message bubble (image-only)', () => {
286
+ const messagesEl = createMockEl();
287
+ const { renderer } = createRenderer(messagesEl);
288
+ jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {});
289
+
290
+ const msg: ChatMessage = {
291
+ id: 'u1',
292
+ role: 'user',
293
+ content: '',
294
+ timestamp: Date.now(),
295
+ images: [{ id: 'img-1', name: 'img.png', mediaType: 'image/png', data: 'abc', size: 100, source: 'paste' as const }],
296
+ };
297
+
298
+ renderer.renderStoredMessage(msg);
299
+
300
+ // Images should still be rendered, but no message bubble
301
+ expect(renderer.renderMessageImages).toHaveBeenCalled();
302
+ // Only the images container, no message bubble
303
+ const bubbles = messagesEl.children.filter(
304
+ (c: any) => c.hasClass('claudian-message')
305
+ );
306
+ expect(bubbles.length).toBe(0);
307
+ });
308
+
309
+ it('renders user message with images above bubble', () => {
310
+ const messagesEl = createMockEl();
311
+ const { renderer } = createRenderer(messagesEl);
312
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
313
+ const renderImagesSpy = jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {});
314
+
315
+ const images: ImageAttachment[] = [
316
+ { id: 'img-1', name: 'photo.png', mediaType: 'image/png', data: 'base64data', size: 200, source: 'file' },
317
+ ];
318
+
319
+ const msg: ChatMessage = {
320
+ id: 'u1',
321
+ role: 'user',
322
+ content: 'Check this image',
323
+ timestamp: Date.now(),
324
+ images,
325
+ };
326
+
327
+ renderer.renderStoredMessage(msg);
328
+
329
+ expect(renderImagesSpy).toHaveBeenCalledWith(messagesEl, images);
330
+ });
331
+
332
+ it('adds a rewind button for eligible stored user messages', () => {
333
+ const messagesEl = createMockEl();
334
+ const rewindCallback = jest.fn().mockResolvedValue(undefined);
335
+ const renderer = new MessageRenderer({ app: {}, settings: { mediaFolder: '' } } as any, createMockComponent() as any, messagesEl, rewindCallback, undefined, mockCapabilities());
336
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
337
+
338
+ const allMessages: ChatMessage[] = [
339
+ { id: 'a1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'prev-a' },
340
+ { id: 'u1', role: 'user', content: 'hello', timestamp: 2, userMessageId: 'user-u' },
341
+ { id: 'a2', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'resp-a' },
342
+ ];
343
+
344
+ renderer.renderStoredMessage(allMessages[1], allMessages, 1);
345
+
346
+ expect(messagesEl.querySelector('.claudian-message-rewind-btn')).not.toBeNull();
347
+ });
348
+
349
+ it('does not add a rewind button when stored render is called without context', () => {
350
+ const messagesEl = createMockEl();
351
+ const rewindCallback = jest.fn().mockResolvedValue(undefined);
352
+ const renderer = new MessageRenderer({ app: {}, settings: { mediaFolder: '' } } as any, createMockComponent() as any, messagesEl, rewindCallback, undefined, mockCapabilities());
353
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
354
+
355
+ const msg: ChatMessage = {
356
+ id: 'u1',
357
+ role: 'user',
358
+ content: 'hello',
359
+ timestamp: 1,
360
+ userMessageId: 'user-u',
361
+ };
362
+
363
+ renderer.renderStoredMessage(msg);
364
+
365
+ expect(messagesEl.querySelector('.claudian-message-rewind-btn')).toBeNull();
366
+ });
367
+
368
+ it('shows rewind mode menu for eligible streamed user messages', async () => {
369
+ const messagesEl = createMockEl();
370
+ const rewindCallback = jest.fn().mockResolvedValue(undefined);
371
+ const renderer = new MessageRenderer({ app: {}, settings: { mediaFolder: '' } } as any, createMockComponent() as any, messagesEl, rewindCallback, undefined, mockCapabilities());
372
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
373
+
374
+ const userMsg: ChatMessage = {
375
+ id: 'u1',
376
+ role: 'user',
377
+ content: 'hello',
378
+ timestamp: 2,
379
+ userMessageId: 'user-u',
380
+ };
381
+ renderer.addMessage(userMsg);
382
+
383
+ const allMessages: ChatMessage[] = [
384
+ { id: 'a1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'prev-a' },
385
+ userMsg,
386
+ { id: 'a2', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'resp-a' },
387
+ ];
388
+
389
+ renderer.refreshActionButtons(userMsg, allMessages, 1);
390
+
391
+ const btn = messagesEl.querySelector('.claudian-message-rewind-btn');
392
+ expect(btn).not.toBeNull();
393
+
394
+ btn!.click();
395
+ const menu = (Menu as typeof Menu & { instances: any[] }).instances[0];
396
+ expect(menu.items.map((item: any) => item.title)).toEqual([
397
+ 'Rewind conversation only',
398
+ 'Rewind code + conversation',
399
+ ]);
400
+
401
+ menu.items[0].clickHandler?.();
402
+ await Promise.resolve();
403
+
404
+ expect(rewindCallback).toHaveBeenCalledWith('u1', 'conversation');
405
+ });
406
+
407
+ // ============================================
408
+ // renderAssistantContent
409
+ // ============================================
410
+
411
+ it('renders assistant content blocks using specialized renderers', () => {
412
+ const messagesEl = createMockEl();
413
+ const mockComponent = createMockComponent();
414
+ const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl);
415
+ const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
416
+
417
+ const msg: ChatMessage = {
418
+ id: 'm1',
419
+ role: 'assistant',
420
+ content: '',
421
+ timestamp: Date.now(),
422
+ toolCalls: [
423
+ { id: 'todo', name: 'TodoWrite', input: { items: [] } } as any,
424
+ { id: 'edit', name: 'Edit', input: { file_path: 'notes/test.md' } } as any,
425
+ { id: 'read', name: 'Read', input: { file_path: 'notes/test.md' } } as any,
426
+ {
427
+ id: 'sub-1',
428
+ name: TOOL_TASK,
429
+ input: { description: 'Async subagent' },
430
+ status: 'running',
431
+ subagent: { id: 'sub-1', mode: 'async', status: 'running', toolCalls: [], isExpanded: false },
432
+ } as any,
433
+ {
434
+ id: 'sub-2',
435
+ name: TOOL_TASK,
436
+ input: { description: 'Sync subagent' },
437
+ status: 'running',
438
+ subagent: { id: 'sub-2', mode: 'sync', status: 'running', toolCalls: [], isExpanded: false },
439
+ } as any,
440
+ ],
441
+ contentBlocks: [
442
+ { type: 'thinking', content: 'thinking', durationSeconds: 2 } as any,
443
+ { type: 'text', content: 'Text block' } as any,
444
+ { type: 'tool_use', toolId: 'todo' } as any,
445
+ { type: 'tool_use', toolId: 'edit' } as any,
446
+ { type: 'tool_use', toolId: 'read' } as any,
447
+ { type: 'subagent', subagentId: 'sub-1', mode: 'async' } as any,
448
+ { type: 'subagent', subagentId: 'sub-2' } as any,
449
+ ],
450
+ };
451
+
452
+ renderer.renderStoredMessage(msg);
453
+
454
+ expect(renderStoredThinkingBlock).toHaveBeenCalled();
455
+ expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Text block');
456
+ // TodoWrite is not rendered inline - only in bottom panel
457
+ expect(renderStoredWriteEdit).toHaveBeenCalled();
458
+ expect(renderStoredToolCall).toHaveBeenCalled();
459
+ expect(renderStoredAsyncSubagent).toHaveBeenCalled();
460
+ expect(renderStoredSubagent).toHaveBeenCalled();
461
+ });
462
+
463
+ it('passes collapsed file-edit default to stored Write/Edit renderer', () => {
464
+ const messagesEl = createMockEl();
465
+ const { renderer } = createRenderer(messagesEl);
466
+
467
+ const msg: ChatMessage = {
468
+ id: 'm-write-default',
469
+ role: 'assistant',
470
+ content: '',
471
+ timestamp: Date.now(),
472
+ toolCalls: [
473
+ { id: 'edit-1', name: 'Edit', input: { file_path: 'notes/test.md' }, status: 'completed' } as any,
474
+ ],
475
+ contentBlocks: [
476
+ { type: 'tool_use', toolId: 'edit-1' } as any,
477
+ ],
478
+ };
479
+
480
+ renderer.renderStoredMessage(msg);
481
+
482
+ expect(renderStoredWriteEdit).toHaveBeenCalledWith(
483
+ expect.anything(),
484
+ expect.objectContaining({ id: 'edit-1', name: 'Edit' }),
485
+ { initiallyExpanded: false },
486
+ );
487
+ });
488
+
489
+ it('passes expanded file-edit default to stored Write/Edit renderer', () => {
490
+ const messagesEl = createMockEl();
491
+ const { renderer } = createRenderer(messagesEl, 'claude', { expandFileEditsByDefault: true });
492
+
493
+ const msg: ChatMessage = {
494
+ id: 'm-write-expanded',
495
+ role: 'assistant',
496
+ content: '',
497
+ timestamp: Date.now(),
498
+ toolCalls: [
499
+ { id: 'write-1', name: 'Write', input: { file_path: 'notes/test.md' }, status: 'completed' } as any,
500
+ ],
501
+ contentBlocks: [
502
+ { type: 'tool_use', toolId: 'write-1' } as any,
503
+ ],
504
+ };
505
+
506
+ renderer.renderStoredMessage(msg);
507
+
508
+ expect(renderStoredWriteEdit).toHaveBeenCalledWith(
509
+ expect.anything(),
510
+ expect.objectContaining({ id: 'write-1', name: 'Write' }),
511
+ { initiallyExpanded: true },
512
+ );
513
+ });
514
+
515
+ it('skips empty or whitespace-only text blocks', () => {
516
+ const messagesEl = createMockEl();
517
+ const { renderer } = createRenderer(messagesEl);
518
+ const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
519
+
520
+ const msg: ChatMessage = {
521
+ id: 'm1',
522
+ role: 'assistant',
523
+ content: '',
524
+ timestamp: Date.now(),
525
+ contentBlocks: [
526
+ { type: 'text', content: '' } as any,
527
+ { type: 'text', content: ' ' } as any,
528
+ { type: 'text', content: 'Real content' } as any,
529
+ ],
530
+ };
531
+
532
+ renderer.renderStoredMessage(msg);
533
+
534
+ // Only the non-empty text block should trigger renderContent
535
+ expect(renderContentSpy).toHaveBeenCalledTimes(1);
536
+ expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Real content');
537
+ });
538
+
539
+ it('does not render stored Codex write_stdin transport tools', () => {
540
+ const messagesEl = createMockEl();
541
+ const { renderer } = createRenderer(messagesEl, 'codex');
542
+
543
+ const msg: ChatMessage = {
544
+ id: 'm1',
545
+ role: 'assistant',
546
+ content: '',
547
+ timestamp: Date.now(),
548
+ toolCalls: [
549
+ {
550
+ id: 'stdin-1',
551
+ name: TOOL_WRITE_STDIN,
552
+ input: { session_id: '2404', chars: '' },
553
+ status: 'completed',
554
+ result: 'poll output',
555
+ } as any,
556
+ ],
557
+ contentBlocks: [
558
+ { type: 'tool_use', toolId: 'stdin-1' } as any,
559
+ ],
560
+ };
561
+
562
+ renderer.renderStoredMessage(msg);
563
+
564
+ expect(renderStoredToolCall).not.toHaveBeenCalled();
565
+ expect(messagesEl.children).toHaveLength(0);
566
+ });
567
+
568
+ it('renders stored Codex write_stdin tools when they send real input', () => {
569
+ const messagesEl = createMockEl();
570
+ const { renderer } = createRenderer(messagesEl, 'codex');
571
+
572
+ const msg: ChatMessage = {
573
+ id: 'm1',
574
+ role: 'assistant',
575
+ content: '',
576
+ timestamp: Date.now(),
577
+ toolCalls: [
578
+ {
579
+ id: 'stdin-1',
580
+ name: TOOL_WRITE_STDIN,
581
+ input: { session_id: '2404', chars: 'y\n' },
582
+ status: 'completed',
583
+ result: 'Input sent.',
584
+ } as any,
585
+ ],
586
+ contentBlocks: [
587
+ { type: 'tool_use', toolId: 'stdin-1' } as any,
588
+ ],
589
+ };
590
+
591
+ renderer.renderStoredMessage(msg);
592
+
593
+ expect(renderStoredToolCall).toHaveBeenCalledWith(
594
+ expect.anything(),
595
+ expect.objectContaining({
596
+ id: 'stdin-1',
597
+ name: TOOL_WRITE_STDIN,
598
+ input: { session_id: '2404', chars: 'y\n' },
599
+ }),
600
+ { initiallyExpanded: false },
601
+ );
602
+ expect(messagesEl.children).toHaveLength(1);
603
+ });
604
+
605
+ it('passes expanded file-edit default to stored apply_patch renderer', () => {
606
+ const messagesEl = createMockEl();
607
+ const { renderer } = createRenderer(messagesEl, 'codex', { expandFileEditsByDefault: true });
608
+
609
+ const msg: ChatMessage = {
610
+ id: 'm-apply-patch-expanded',
611
+ role: 'assistant',
612
+ content: '',
613
+ timestamp: Date.now(),
614
+ toolCalls: [
615
+ {
616
+ id: 'patch-1',
617
+ name: TOOL_APPLY_PATCH,
618
+ input: { changes: [{ path: 'src/main.ts', kind: 'update' }] },
619
+ status: 'completed',
620
+ result: 'Applied patch',
621
+ } as any,
622
+ ],
623
+ contentBlocks: [
624
+ { type: 'tool_use', toolId: 'patch-1' } as any,
625
+ ],
626
+ };
627
+
628
+ renderer.renderStoredMessage(msg);
629
+
630
+ expect(renderStoredToolCall).toHaveBeenCalledWith(
631
+ expect.anything(),
632
+ expect.objectContaining({ id: 'patch-1', name: TOOL_APPLY_PATCH }),
633
+ { initiallyExpanded: true },
634
+ );
635
+ });
636
+
637
+ it('renders response duration footer when durationSeconds is present', () => {
638
+ const messagesEl = createMockEl();
639
+ const { renderer } = createRenderer(messagesEl);
640
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
641
+
642
+ const msg: ChatMessage = {
643
+ id: 'm1',
644
+ role: 'assistant',
645
+ content: '',
646
+ timestamp: Date.now(),
647
+ contentBlocks: [
648
+ { type: 'text', content: 'Response text' } as any,
649
+ ],
650
+ durationSeconds: 65,
651
+ durationFlavorWord: 'Baked',
652
+ };
653
+
654
+ renderer.renderStoredMessage(msg);
655
+
656
+ // Find the footer element
657
+ const msgEl = messagesEl.children[0];
658
+ const contentEl = msgEl.children[0]; // claudian-message-content
659
+ const footerEl = contentEl.children.find((c: any) => c.hasClass('claudian-response-footer'));
660
+ expect(footerEl).toBeDefined();
661
+ const durationSpan = footerEl!.children[0];
662
+ expect(durationSpan.textContent).toContain('Baked');
663
+ expect(durationSpan.textContent).toContain('1m 5s');
664
+ });
665
+
666
+ it('does not render footer when durationSeconds is 0', () => {
667
+ const messagesEl = createMockEl();
668
+ const { renderer } = createRenderer(messagesEl);
669
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
670
+
671
+ const msg: ChatMessage = {
672
+ id: 'm1',
673
+ role: 'assistant',
674
+ content: '',
675
+ timestamp: Date.now(),
676
+ contentBlocks: [
677
+ { type: 'text', content: 'Response' } as any,
678
+ ],
679
+ durationSeconds: 0,
680
+ };
681
+
682
+ renderer.renderStoredMessage(msg);
683
+
684
+ const msgEl = messagesEl.children[0];
685
+ const contentEl = msgEl.children[0];
686
+ const footerEl = contentEl.children.find((c: any) => c.hasClass('claudian-response-footer'));
687
+ expect(footerEl).toBeUndefined();
688
+ });
689
+
690
+ it('uses default flavor word "Baked" when durationFlavorWord is not set', () => {
691
+ const messagesEl = createMockEl();
692
+ const { renderer } = createRenderer(messagesEl);
693
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
694
+
695
+ const msg: ChatMessage = {
696
+ id: 'm1',
697
+ role: 'assistant',
698
+ content: '',
699
+ timestamp: Date.now(),
700
+ contentBlocks: [
701
+ { type: 'text', content: 'Response' } as any,
702
+ ],
703
+ durationSeconds: 30,
704
+ };
705
+
706
+ renderer.renderStoredMessage(msg);
707
+
708
+ const msgEl = messagesEl.children[0];
709
+ const contentEl = msgEl.children[0];
710
+ const footerEl = contentEl.children.find((c: any) => c.hasClass('claudian-response-footer'));
711
+ expect(footerEl).toBeDefined();
712
+ expect(footerEl!.children[0].textContent).toContain('Baked');
713
+ });
714
+
715
+ it('renders fallback content for old conversations without contentBlocks', () => {
716
+ const messagesEl = createMockEl();
717
+ const { renderer } = createRenderer(messagesEl);
718
+ const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
719
+ const addCopySpy = jest.spyOn(renderer, 'addTextCopyButton').mockImplementation(() => {});
720
+
721
+ const msg: ChatMessage = {
722
+ id: 'm1',
723
+ role: 'assistant',
724
+ content: 'Legacy response text',
725
+ timestamp: Date.now(),
726
+ toolCalls: [
727
+ { id: 'read-1', name: 'Read', input: { file_path: 'test.md' }, status: 'completed' } as any,
728
+ ],
729
+ };
730
+
731
+ renderer.renderStoredMessage(msg);
732
+
733
+ // Should render content text
734
+ expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Legacy response text');
735
+ // Should add copy button for fallback text
736
+ expect(addCopySpy).toHaveBeenCalledWith(expect.anything(), 'Legacy response text');
737
+ // Should render tool call
738
+ expect(renderStoredToolCall).toHaveBeenCalled();
739
+ });
740
+
741
+ it('renders unreferenced tool calls when contentBlocks miss tool_use blocks', () => {
742
+ const messagesEl = createMockEl();
743
+ const { renderer } = createRenderer(messagesEl);
744
+ const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
745
+
746
+ (renderStoredToolCall as jest.Mock).mockClear();
747
+
748
+ const msg: ChatMessage = {
749
+ id: 'm-unreferenced-tool',
750
+ role: 'assistant',
751
+ content: '',
752
+ timestamp: Date.now(),
753
+ toolCalls: [
754
+ { id: 'read-1', name: 'Read', input: { file_path: 'a.md' }, status: 'completed' } as any,
755
+ ],
756
+ contentBlocks: [
757
+ { type: 'text', content: 'Only text block persisted' } as any,
758
+ ],
759
+ };
760
+
761
+ renderer.renderStoredMessage(msg);
762
+
763
+ expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'Only text block persisted');
764
+ expect(renderStoredToolCall).toHaveBeenCalledWith(
765
+ expect.anything(),
766
+ expect.objectContaining({ id: 'read-1', name: 'Read' }),
767
+ { initiallyExpanded: false },
768
+ );
769
+ });
770
+
771
+ it('renders Task tool calls as subagents for backward compatibility', () => {
772
+ const messagesEl = createMockEl();
773
+ const { renderer } = createRenderer(messagesEl);
774
+
775
+ (renderStoredSubagent as jest.Mock).mockClear();
776
+
777
+ const msg: ChatMessage = {
778
+ id: 'm1',
779
+ role: 'assistant',
780
+ content: '',
781
+ timestamp: Date.now(),
782
+ toolCalls: [
783
+ {
784
+ id: 'task-1',
785
+ name: TOOL_TASK,
786
+ input: { description: 'Run tests' },
787
+ status: 'completed',
788
+ result: 'All passed',
789
+ } as any,
790
+ ],
791
+ contentBlocks: [
792
+ { type: 'tool_use', toolId: 'task-1' } as any,
793
+ ],
794
+ };
795
+
796
+ renderer.renderStoredMessage(msg);
797
+
798
+ expect(renderStoredSubagent).toHaveBeenCalledWith(
799
+ expect.anything(),
800
+ expect.objectContaining({
801
+ id: 'task-1',
802
+ description: 'Run tests',
803
+ status: 'completed',
804
+ result: 'All passed',
805
+ })
806
+ );
807
+ });
808
+
809
+ it('renders Task tool as async subagent when linked subagent mode is async', () => {
810
+ const messagesEl = createMockEl();
811
+ const { renderer } = createRenderer(messagesEl);
812
+
813
+ (renderStoredAsyncSubagent as jest.Mock).mockClear();
814
+ (renderStoredSubagent as jest.Mock).mockClear();
815
+
816
+ const msg: ChatMessage = {
817
+ id: 'm-task-async',
818
+ role: 'assistant',
819
+ content: '',
820
+ timestamp: Date.now(),
821
+ toolCalls: [
822
+ {
823
+ id: 'task-async-1',
824
+ name: TOOL_TASK,
825
+ input: { description: 'Background task', run_in_background: true },
826
+ status: 'completed',
827
+ result: 'Task running',
828
+ subagent: {
829
+ id: 'task-async-1',
830
+ description: 'Background task',
831
+ mode: 'async',
832
+ asyncStatus: 'running',
833
+ status: 'running',
834
+ toolCalls: [],
835
+ isExpanded: false,
836
+ },
837
+ } as any,
838
+ ],
839
+ contentBlocks: [
840
+ { type: 'tool_use', toolId: 'task-async-1' } as any,
841
+ ],
842
+ };
843
+
844
+ renderer.renderStoredMessage(msg);
845
+
846
+ expect(renderStoredAsyncSubagent).toHaveBeenCalledWith(
847
+ expect.anything(),
848
+ expect.objectContaining({
849
+ id: 'task-async-1',
850
+ mode: 'async',
851
+ asyncStatus: 'running',
852
+ })
853
+ );
854
+ expect(renderStoredSubagent).not.toHaveBeenCalled();
855
+ });
856
+
857
+ it('infers async running state from structured Task result content', () => {
858
+ const messagesEl = createMockEl();
859
+ const { renderer } = createRenderer(messagesEl);
860
+
861
+ (renderStoredAsyncSubagent as jest.Mock).mockClear();
862
+
863
+ const msg: ChatMessage = {
864
+ id: 'm-task-async-structured',
865
+ role: 'assistant',
866
+ content: '',
867
+ timestamp: Date.now(),
868
+ toolCalls: [
869
+ {
870
+ id: 'task-async-structured-1',
871
+ name: TOOL_TASK,
872
+ input: { description: 'Background task', run_in_background: true },
873
+ status: 'completed',
874
+ result: [{ type: 'text', text: '{"status":"running"}' }] as any,
875
+ } as any,
876
+ ],
877
+ contentBlocks: [
878
+ { type: 'tool_use', toolId: 'task-async-structured-1' } as any,
879
+ ],
880
+ };
881
+
882
+ renderer.renderStoredMessage(msg);
883
+
884
+ expect(renderStoredAsyncSubagent).toHaveBeenCalledWith(
885
+ expect.anything(),
886
+ expect.objectContaining({
887
+ id: 'task-async-structured-1',
888
+ asyncStatus: 'running',
889
+ })
890
+ );
891
+ });
892
+
893
+ it('uses subagent block mode hint when linked subagent mode is missing', () => {
894
+ const messagesEl = createMockEl();
895
+ const { renderer } = createRenderer(messagesEl);
896
+
897
+ (renderStoredAsyncSubagent as jest.Mock).mockClear();
898
+ (renderStoredSubagent as jest.Mock).mockClear();
899
+
900
+ const msg: ChatMessage = {
901
+ id: 'm-task-mode-hint',
902
+ role: 'assistant',
903
+ content: '',
904
+ timestamp: Date.now(),
905
+ toolCalls: [
906
+ {
907
+ id: 'task-hint-1',
908
+ name: TOOL_TASK,
909
+ input: { description: 'Background task from block hint' },
910
+ status: 'running',
911
+ subagent: {
912
+ id: 'task-hint-1',
913
+ description: 'Background task from block hint',
914
+ status: 'running',
915
+ toolCalls: [],
916
+ isExpanded: false,
917
+ },
918
+ } as any,
919
+ ],
920
+ contentBlocks: [
921
+ { type: 'subagent', subagentId: 'task-hint-1', mode: 'async' } as any,
922
+ ],
923
+ };
924
+
925
+ renderer.renderStoredMessage(msg);
926
+
927
+ expect(renderStoredAsyncSubagent).toHaveBeenCalledWith(
928
+ expect.anything(),
929
+ expect.objectContaining({
930
+ id: 'task-hint-1',
931
+ mode: 'async',
932
+ })
933
+ );
934
+ expect(renderStoredSubagent).not.toHaveBeenCalled();
935
+ });
936
+
937
+ // ============================================
938
+ // TaskOutput skipping
939
+ // ============================================
940
+
941
+ it('should skip TaskOutput tool calls (internal async subagent communication)', () => {
942
+ const messagesEl = createMockEl();
943
+ const mockComponent = createMockComponent();
944
+ const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl);
945
+
946
+ (renderStoredToolCall as jest.Mock).mockClear();
947
+
948
+ const msg: ChatMessage = {
949
+ id: 'm1',
950
+ role: 'assistant',
951
+ content: '',
952
+ timestamp: Date.now(),
953
+ toolCalls: [
954
+ { id: 'agent-output-1', name: TOOL_AGENT_OUTPUT, input: { task_id: 'abc', block: true } } as any,
955
+ ],
956
+ contentBlocks: [
957
+ { type: 'tool_use', toolId: 'agent-output-1' } as any,
958
+ ],
959
+ };
960
+
961
+ renderer.renderStoredMessage(msg);
962
+
963
+ expect(renderStoredToolCall).not.toHaveBeenCalled();
964
+ });
965
+
966
+ it('should render other tool calls but skip TaskOutput when mixed', () => {
967
+ const messagesEl = createMockEl();
968
+ const mockComponent = createMockComponent();
969
+ const renderer = new MessageRenderer({} as any, mockComponent as any, messagesEl);
970
+
971
+ (renderStoredToolCall as jest.Mock).mockClear();
972
+
973
+ const msg: ChatMessage = {
974
+ id: 'm1',
975
+ role: 'assistant',
976
+ content: '',
977
+ timestamp: Date.now(),
978
+ toolCalls: [
979
+ { id: 'read-1', name: 'Read', input: { file_path: 'test.md' }, status: 'completed' } as any,
980
+ { id: 'agent-output-1', name: TOOL_AGENT_OUTPUT, input: { task_id: 'abc' } } as any,
981
+ { id: 'grep-1', name: 'Grep', input: { pattern: 'test' }, status: 'completed' } as any,
982
+ ],
983
+ contentBlocks: [
984
+ { type: 'tool_use', toolId: 'read-1' } as any,
985
+ { type: 'tool_use', toolId: 'agent-output-1' } as any,
986
+ { type: 'tool_use', toolId: 'grep-1' } as any,
987
+ ],
988
+ };
989
+
990
+ renderer.renderStoredMessage(msg);
991
+
992
+ expect(renderStoredToolCall).toHaveBeenCalledTimes(2);
993
+ expect(renderStoredToolCall).toHaveBeenCalledWith(
994
+ expect.anything(),
995
+ expect.objectContaining({ id: 'read-1', name: 'Read' }),
996
+ { initiallyExpanded: false },
997
+ );
998
+ expect(renderStoredToolCall).toHaveBeenCalledWith(
999
+ expect.anything(),
1000
+ expect.objectContaining({ id: 'grep-1', name: 'Grep' }),
1001
+ { initiallyExpanded: false },
1002
+ );
1003
+ });
1004
+
1005
+ // ============================================
1006
+ // addMessage (streaming)
1007
+ // ============================================
1008
+
1009
+ it('addMessage creates user message bubble with text', () => {
1010
+ const messagesEl = createMockEl();
1011
+ const { renderer } = createRenderer(messagesEl);
1012
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
1013
+
1014
+ const msg: ChatMessage = {
1015
+ id: 'u1',
1016
+ role: 'user',
1017
+ content: 'Hello',
1018
+ timestamp: Date.now(),
1019
+ };
1020
+
1021
+ const msgEl = renderer.addMessage(msg);
1022
+
1023
+ expect(msgEl.hasClass('claudian-message-user')).toBe(true);
1024
+ });
1025
+
1026
+ it('addMessage renders images for user messages', () => {
1027
+ const messagesEl = createMockEl();
1028
+ const { renderer } = createRenderer(messagesEl);
1029
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
1030
+ const renderImagesSpy = jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {});
1031
+
1032
+ const images: ImageAttachment[] = [
1033
+ { id: 'img-1', name: 'photo.png', mediaType: 'image/png', data: 'base64data', size: 200, source: 'file' },
1034
+ ];
1035
+
1036
+ const msg: ChatMessage = {
1037
+ id: 'u1',
1038
+ role: 'user',
1039
+ content: 'Look at this',
1040
+ timestamp: Date.now(),
1041
+ images,
1042
+ };
1043
+
1044
+ renderer.addMessage(msg);
1045
+
1046
+ expect(renderImagesSpy).toHaveBeenCalledWith(messagesEl, images);
1047
+ });
1048
+
1049
+ it('addMessage skips empty bubble for image-only user messages', () => {
1050
+ const messagesEl = createMockEl();
1051
+ const { renderer } = createRenderer(messagesEl);
1052
+ jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {});
1053
+ const scrollSpy = jest.spyOn(renderer, 'scrollToBottom').mockImplementation(() => {});
1054
+
1055
+ const msg: ChatMessage = {
1056
+ id: 'u1',
1057
+ role: 'user',
1058
+ content: '',
1059
+ timestamp: Date.now(),
1060
+ images: [{ id: 'img-1', name: 'img.png', mediaType: 'image/png', data: 'abc', size: 100, source: 'paste' as const }],
1061
+ };
1062
+
1063
+ const result = renderer.addMessage(msg);
1064
+
1065
+ // Should still return an element (last child or messagesEl)
1066
+ expect(result).toBeDefined();
1067
+ expect(scrollSpy).toHaveBeenCalled();
1068
+ });
1069
+
1070
+ it('addMessage creates assistant message element without user-specific rendering', () => {
1071
+ const messagesEl = createMockEl();
1072
+ const { renderer } = createRenderer(messagesEl);
1073
+
1074
+ const msg: ChatMessage = {
1075
+ id: 'a1',
1076
+ role: 'assistant',
1077
+ content: '',
1078
+ timestamp: Date.now(),
1079
+ };
1080
+
1081
+ const msgEl = renderer.addMessage(msg);
1082
+
1083
+ expect(msgEl.hasClass('claudian-message-assistant')).toBe(true);
1084
+ });
1085
+
1086
+ // ============================================
1087
+ // setMessagesEl
1088
+ // ============================================
1089
+
1090
+ it('setMessagesEl updates the container element', () => {
1091
+ const messagesEl = createMockEl();
1092
+ const { renderer } = createRenderer(messagesEl);
1093
+ const newEl = createMockEl();
1094
+
1095
+ renderer.setMessagesEl(newEl);
1096
+
1097
+ // Verify by using scrollToBottom which references messagesEl
1098
+ renderer.scrollToBottom();
1099
+ // The new element should have been used (scrollTop set)
1100
+ expect(newEl.scrollTop).toBe(newEl.scrollHeight);
1101
+ });
1102
+
1103
+ // ============================================
1104
+ // Image rendering
1105
+ // ============================================
1106
+
1107
+ it('renderMessageImages creates image elements', () => {
1108
+ const containerEl = createMockEl();
1109
+ const { renderer } = createRenderer();
1110
+ jest.spyOn(renderer, 'setImageSrc').mockImplementation(() => {});
1111
+
1112
+ const images: ImageAttachment[] = [
1113
+ { id: 'img-1', name: 'photo.png', mediaType: 'image/png', data: 'base64data1', size: 200, source: 'file' },
1114
+ { id: 'img-2', name: 'avatar.jpg', mediaType: 'image/jpeg', data: 'base64data2', size: 300, source: 'file' },
1115
+ ];
1116
+
1117
+ renderer.renderMessageImages(containerEl, images);
1118
+
1119
+ // Should create images container with 2 image wrappers
1120
+ expect(containerEl.children.length).toBe(1);
1121
+ const imagesContainer = containerEl.children[0];
1122
+ expect(imagesContainer.hasClass('claudian-message-images')).toBe(true);
1123
+ expect(imagesContainer.children.length).toBe(2);
1124
+ });
1125
+
1126
+ it('setImageSrc sets data URI on image element', () => {
1127
+ const { renderer } = createRenderer();
1128
+ const imgEl = createMockEl('img');
1129
+
1130
+ const image: ImageAttachment = {
1131
+ id: 'img-1',
1132
+ name: 'test.png',
1133
+ mediaType: 'image/png',
1134
+ data: 'abc123',
1135
+ size: 100,
1136
+ source: 'file',
1137
+ };
1138
+
1139
+ renderer.setImageSrc(imgEl as any, image);
1140
+
1141
+ expect(imgEl.getAttribute('src')).toBe('data:image/png;base64,abc123');
1142
+ });
1143
+
1144
+ it('showFullImage creates overlay with image', () => {
1145
+ const { renderer } = createRenderer();
1146
+ const image: ImageAttachment = {
1147
+ id: 'img-1',
1148
+ name: 'test.png',
1149
+ mediaType: 'image/png',
1150
+ data: 'abc123',
1151
+ size: 100,
1152
+ source: 'file',
1153
+ };
1154
+
1155
+ // Mock document.body.createDiv (document may not exist in node env)
1156
+ const overlayEl = createMockEl();
1157
+ const mockBody = { createDiv: jest.fn().mockReturnValue(overlayEl) };
1158
+ const origDocument = globalThis.document;
1159
+ (globalThis as any).document = { body: mockBody, addEventListener: jest.fn(), removeEventListener: jest.fn() };
1160
+
1161
+ try {
1162
+ renderer.showFullImage(image);
1163
+ expect(mockBody.createDiv).toHaveBeenCalledWith({ cls: 'claudian-image-modal-overlay' });
1164
+ } finally {
1165
+ (globalThis as any).document = origDocument;
1166
+ }
1167
+ });
1168
+
1169
+ // ============================================
1170
+ // Copy button
1171
+ // ============================================
1172
+
1173
+ it('addTextCopyButton adds a copy button element', () => {
1174
+ const textEl = createMockEl();
1175
+ const { renderer } = createRenderer();
1176
+
1177
+ renderer.addTextCopyButton(textEl, 'some markdown');
1178
+
1179
+ expect(textEl.children.length).toBe(1);
1180
+ const copyBtn = textEl.children[0];
1181
+ expect(copyBtn.hasClass('claudian-text-copy-btn')).toBe(true);
1182
+ });
1183
+
1184
+ // ============================================
1185
+ // Scroll utilities
1186
+ // ============================================
1187
+
1188
+ it('scrollToBottom sets scrollTop to scrollHeight', () => {
1189
+ const messagesEl = createMockEl();
1190
+ messagesEl.scrollHeight = 1000;
1191
+ const { renderer } = createRenderer(messagesEl);
1192
+
1193
+ renderer.scrollToBottom();
1194
+
1195
+ expect(messagesEl.scrollTop).toBe(1000);
1196
+ });
1197
+
1198
+ it('scrollToBottomIfNeeded scrolls when near bottom', () => {
1199
+ const messagesEl = createMockEl();
1200
+ messagesEl.scrollHeight = 1000;
1201
+ messagesEl.scrollTop = 950;
1202
+ Object.defineProperty(messagesEl, 'clientHeight', { value: 0, configurable: true });
1203
+ const { renderer } = createRenderer(messagesEl);
1204
+
1205
+ // Mock requestAnimationFrame
1206
+ const origRAF = globalThis.requestAnimationFrame;
1207
+ (globalThis as any).requestAnimationFrame = (cb: () => void) => { cb(); return 0; };
1208
+
1209
+ try {
1210
+ renderer.scrollToBottomIfNeeded();
1211
+ // Near bottom (1000 - 950 - 0 = 50, < 100 threshold) → scrolls
1212
+ expect(messagesEl.scrollTop).toBe(1000);
1213
+ } finally {
1214
+ (globalThis as any).requestAnimationFrame = origRAF;
1215
+ }
1216
+ });
1217
+
1218
+ it('scrollToBottomIfNeeded does not scroll when far from bottom', () => {
1219
+ const messagesEl = createMockEl();
1220
+ messagesEl.scrollHeight = 1000;
1221
+ messagesEl.scrollTop = 100;
1222
+ Object.defineProperty(messagesEl, 'clientHeight', { value: 0, configurable: true });
1223
+ const { renderer } = createRenderer(messagesEl);
1224
+
1225
+ const originalScrollTop = messagesEl.scrollTop;
1226
+ renderer.scrollToBottomIfNeeded();
1227
+
1228
+ // scrollTop should not change (900 > 100 threshold)
1229
+ expect(messagesEl.scrollTop).toBe(originalScrollTop);
1230
+ });
1231
+
1232
+ // ============================================
1233
+ // renderContent
1234
+ // ============================================
1235
+
1236
+ it('renderContent should not throw on valid markdown', async () => {
1237
+ const { renderer } = createRenderer();
1238
+ const el = createMockEl();
1239
+
1240
+ // Should not throw even if internal rendering fails (graceful error handling)
1241
+ await expect(renderer.renderContent(el, '**Hello** world')).resolves.not.toThrow();
1242
+ });
1243
+
1244
+ it('renderContent should empty the element before rendering', async () => {
1245
+ const { renderer } = createRenderer();
1246
+ const el = createMockEl();
1247
+ el.createDiv({ text: 'old content' });
1248
+ expect(el.children.length).toBe(1);
1249
+
1250
+ await renderer.renderContent(el, 'new content');
1251
+
1252
+ // After render, old content should be gone (empty() was called before rendering)
1253
+ expect(el.children.length).toBe(0);
1254
+ });
1255
+
1256
+ it('renderContent should skip file-link post-processing when markdown has no wikilinks', async () => {
1257
+ const { processFileLinks } = await import('@/utils/fileLink');
1258
+ const { renderer } = createRenderer();
1259
+ const el = createMockEl();
1260
+
1261
+ await renderer.renderContent(el, 'plain markdown without links');
1262
+
1263
+ expect(processFileLinks).not.toHaveBeenCalled();
1264
+ });
1265
+
1266
+ it('renderContent escapes math delimiters only when requested for streaming', async () => {
1267
+ const { MarkdownRenderer } = await import('obsidian');
1268
+ const { renderer } = createRenderer();
1269
+ const el = createMockEl();
1270
+
1271
+ await renderer.renderContent(
1272
+ el,
1273
+ 'Live $x + y$ and `echo $PATH`',
1274
+ { deferMath: true }
1275
+ );
1276
+
1277
+ expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalledWith(
1278
+ 'Live \\$x + y\\$ and `echo $PATH`',
1279
+ el,
1280
+ '',
1281
+ expect.anything()
1282
+ );
1283
+ });
1284
+
1285
+ // ============================================
1286
+ // addTextCopyButton - click behavior
1287
+ // ============================================
1288
+
1289
+ describe('addTextCopyButton - click behavior', () => {
1290
+ let originalNavigator: Navigator;
1291
+
1292
+ beforeEach(() => {
1293
+ originalNavigator = globalThis.navigator;
1294
+ jest.useFakeTimers();
1295
+ });
1296
+
1297
+ afterEach(() => {
1298
+ jest.useRealTimers();
1299
+ Object.defineProperty(globalThis, 'navigator', {
1300
+ value: originalNavigator,
1301
+ writable: true,
1302
+ configurable: true,
1303
+ });
1304
+ });
1305
+
1306
+ it('click should copy and show feedback', async () => {
1307
+ const { renderer } = createRenderer();
1308
+ const textEl = createMockEl();
1309
+
1310
+ const writeTextMock = jest.fn().mockResolvedValue(undefined);
1311
+ Object.defineProperty(globalThis, 'navigator', {
1312
+ value: { clipboard: { writeText: writeTextMock } },
1313
+ writable: true,
1314
+ configurable: true,
1315
+ });
1316
+
1317
+ renderer.addTextCopyButton(textEl, 'markdown content');
1318
+
1319
+ const copyBtn = textEl.children[0];
1320
+ expect(copyBtn.hasClass('claudian-text-copy-btn')).toBe(true);
1321
+
1322
+ // Simulate click
1323
+ const clickHandlers = copyBtn._eventListeners.get('click');
1324
+ expect(clickHandlers).toBeDefined();
1325
+
1326
+ await clickHandlers![0]({ stopPropagation: jest.fn() });
1327
+
1328
+ expect(writeTextMock).toHaveBeenCalledWith('markdown content');
1329
+ expect(copyBtn.textContent).toBe('Copied!');
1330
+ expect(copyBtn.classList.contains('copied')).toBe(true);
1331
+ });
1332
+
1333
+ it('should handle clipboard API failure gracefully', async () => {
1334
+ const { renderer } = createRenderer();
1335
+ const textEl = createMockEl();
1336
+
1337
+ const writeTextMock = jest.fn().mockRejectedValue(new Error('not allowed'));
1338
+ Object.defineProperty(globalThis, 'navigator', {
1339
+ value: { clipboard: { writeText: writeTextMock } },
1340
+ writable: true,
1341
+ configurable: true,
1342
+ });
1343
+
1344
+ renderer.addTextCopyButton(textEl, 'content');
1345
+
1346
+ const copyBtn = textEl.children[0];
1347
+ const clickHandlers = copyBtn._eventListeners.get('click');
1348
+
1349
+ // Should not throw
1350
+ await clickHandlers![0]({ stopPropagation: jest.fn() });
1351
+
1352
+ // Should not show feedback on error
1353
+ expect(copyBtn.textContent).not.toBe('copied!');
1354
+ });
1355
+ });
1356
+
1357
+ // ============================================
1358
+ // renderMessages (entry point)
1359
+ // ============================================
1360
+
1361
+ it('renderMessages should render stored messages and return welcome element', () => {
1362
+ const messagesEl = createMockEl();
1363
+ const { renderer } = createRenderer(messagesEl);
1364
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
1365
+ jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {});
1366
+
1367
+ const messages: ChatMessage[] = [
1368
+ { id: 'u1', role: 'user', content: 'Hello', timestamp: Date.now() },
1369
+ { id: 'a1', role: 'assistant', content: 'Hi there', timestamp: Date.now(), contentBlocks: [{ type: 'text', content: 'Hi there' }] as any },
1370
+ ];
1371
+
1372
+ const welcomeEl = renderer.renderMessages(messages, () => 'Good morning!');
1373
+
1374
+ expect(welcomeEl).toBeDefined();
1375
+ expect(welcomeEl!.hasClass('claudian-welcome')).toBe(true);
1376
+ });
1377
+
1378
+ it('renderMessages should hide welcome when messages exist', () => {
1379
+ const messagesEl = createMockEl();
1380
+ const { renderer } = createRenderer(messagesEl);
1381
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
1382
+ jest.spyOn(renderer, 'renderMessageImages').mockImplementation(() => {});
1383
+
1384
+ const messages: ChatMessage[] = [
1385
+ { id: 'u1', role: 'user', content: 'Hello', timestamp: Date.now() },
1386
+ ];
1387
+
1388
+ const welcomeEl = renderer.renderMessages(messages, () => 'Hello');
1389
+
1390
+ // When messages exist, welcome should be hidden
1391
+ expect(welcomeEl).toBeDefined();
1392
+ });
1393
+
1394
+ it('renderMessages should return welcome element when no messages', () => {
1395
+ const messagesEl = createMockEl();
1396
+ const { renderer } = createRenderer(messagesEl);
1397
+
1398
+ const welcomeEl = renderer.renderMessages([], () => 'Welcome');
1399
+
1400
+ expect(welcomeEl).toBeDefined();
1401
+ expect(welcomeEl!.hasClass('claudian-welcome')).toBe(true);
1402
+ });
1403
+
1404
+ // ============================================
1405
+ // Task tool rendering - error and running status
1406
+ // ============================================
1407
+
1408
+ describe('Task tool rendering - error and running status', () => {
1409
+ it('renders Task tool with error status as subagent with status error', () => {
1410
+ const messagesEl = createMockEl();
1411
+ const { renderer } = createRenderer(messagesEl, 'codex');
1412
+
1413
+ (renderStoredSubagent as jest.Mock).mockClear();
1414
+
1415
+ const msg: ChatMessage = {
1416
+ id: 'm1',
1417
+ role: 'assistant',
1418
+ content: '',
1419
+ timestamp: Date.now(),
1420
+ toolCalls: [
1421
+ {
1422
+ id: 'task-err',
1423
+ name: TOOL_TASK,
1424
+ input: { description: 'Failing task' },
1425
+ status: 'error',
1426
+ result: 'Something went wrong',
1427
+ } as any,
1428
+ ],
1429
+ contentBlocks: [
1430
+ { type: 'tool_use', toolId: 'task-err' } as any,
1431
+ ],
1432
+ };
1433
+
1434
+ renderer.renderStoredMessage(msg);
1435
+
1436
+ expect(renderStoredSubagent).toHaveBeenCalledWith(
1437
+ expect.anything(),
1438
+ expect.objectContaining({
1439
+ id: 'task-err',
1440
+ description: 'Failing task',
1441
+ status: 'error',
1442
+ result: 'Something went wrong',
1443
+ })
1444
+ );
1445
+ });
1446
+
1447
+ it('renders Task tool with running status (default case in switch)', () => {
1448
+ const messagesEl = createMockEl();
1449
+ const { renderer } = createRenderer(messagesEl, 'codex');
1450
+
1451
+ (renderStoredSubagent as jest.Mock).mockClear();
1452
+
1453
+ const msg: ChatMessage = {
1454
+ id: 'm1',
1455
+ role: 'assistant',
1456
+ content: '',
1457
+ timestamp: Date.now(),
1458
+ toolCalls: [
1459
+ {
1460
+ id: 'task-run',
1461
+ name: TOOL_TASK,
1462
+ input: { description: 'Running task' },
1463
+ status: 'pending',
1464
+ } as any,
1465
+ ],
1466
+ contentBlocks: [
1467
+ { type: 'tool_use', toolId: 'task-run' } as any,
1468
+ ],
1469
+ };
1470
+
1471
+ renderer.renderStoredMessage(msg);
1472
+
1473
+ expect(renderStoredSubagent).toHaveBeenCalledWith(
1474
+ expect.anything(),
1475
+ expect.objectContaining({
1476
+ id: 'task-run',
1477
+ description: 'Running task',
1478
+ status: 'running',
1479
+ })
1480
+ );
1481
+ });
1482
+
1483
+ it('renders Task tool with no description uses fallback Subagent task', () => {
1484
+ const messagesEl = createMockEl();
1485
+ const { renderer } = createRenderer(messagesEl);
1486
+
1487
+ (renderStoredSubagent as jest.Mock).mockClear();
1488
+
1489
+ const msg: ChatMessage = {
1490
+ id: 'm1',
1491
+ role: 'assistant',
1492
+ content: '',
1493
+ timestamp: Date.now(),
1494
+ toolCalls: [
1495
+ {
1496
+ id: 'task-no-desc',
1497
+ name: TOOL_TASK,
1498
+ input: {},
1499
+ status: 'completed',
1500
+ result: 'Done',
1501
+ } as any,
1502
+ ],
1503
+ contentBlocks: [
1504
+ { type: 'tool_use', toolId: 'task-no-desc' } as any,
1505
+ ],
1506
+ };
1507
+
1508
+ renderer.renderStoredMessage(msg);
1509
+
1510
+ expect(renderStoredSubagent).toHaveBeenCalledWith(
1511
+ expect.anything(),
1512
+ expect.objectContaining({
1513
+ id: 'task-no-desc',
1514
+ description: 'Subagent task',
1515
+ status: 'completed',
1516
+ })
1517
+ );
1518
+ });
1519
+
1520
+ it('renders Codex spawn_agent with the same prompt and result recovered on reload', () => {
1521
+ const messagesEl = createMockEl();
1522
+ const { renderer } = createRenderer(messagesEl, 'codex');
1523
+
1524
+ (renderStoredSubagent as jest.Mock).mockClear();
1525
+
1526
+ const msg: ChatMessage = {
1527
+ id: 'm-codex-subagent',
1528
+ role: 'assistant',
1529
+ content: '',
1530
+ timestamp: Date.now(),
1531
+ toolCalls: [
1532
+ {
1533
+ id: 'spawn-1',
1534
+ name: TOOL_SPAWN_AGENT,
1535
+ input: {
1536
+ message: 'Inspect utils.ts and return the final patch summary.',
1537
+ model: 'gpt-5.4-mini',
1538
+ },
1539
+ status: 'completed',
1540
+ result: '{"agent_id":"agent-1","nickname":"Zeno"}',
1541
+ } as any,
1542
+ {
1543
+ id: 'wait-1',
1544
+ name: TOOL_WAIT_AGENT,
1545
+ input: { targets: ['agent-1'], timeout_ms: 30000 },
1546
+ status: 'completed',
1547
+ result: '{"status":{"agent-1":{"completed":"Patched utils.ts and verified imports."}},"timed_out":false}',
1548
+ } as any,
1549
+ ],
1550
+ contentBlocks: [
1551
+ { type: 'tool_use', toolId: 'spawn-1' } as any,
1552
+ ],
1553
+ };
1554
+
1555
+ renderer.renderStoredMessage(msg);
1556
+
1557
+ expect(renderStoredSubagent).toHaveBeenCalledWith(
1558
+ expect.anything(),
1559
+ expect.objectContaining({
1560
+ id: 'spawn-1',
1561
+ description: 'Zeno (gpt-5.4-mini)',
1562
+ prompt: 'Inspect utils.ts and return the final patch summary.',
1563
+ status: 'completed',
1564
+ result: 'Patched utils.ts and verified imports.',
1565
+ })
1566
+ );
1567
+ });
1568
+ });
1569
+
1570
+ // ============================================
1571
+ // showFullImage - close behaviors
1572
+ // ============================================
1573
+
1574
+ describe('showFullImage - close behaviors', () => {
1575
+ const image: ImageAttachment = {
1576
+ id: 'img-1',
1577
+ name: 'test.png',
1578
+ mediaType: 'image/png',
1579
+ data: 'abc123',
1580
+ size: 100,
1581
+ source: 'file',
1582
+ };
1583
+
1584
+ function setupDocumentMock() {
1585
+ const overlayEl = createMockEl();
1586
+ const mockBody = { createDiv: jest.fn().mockReturnValue(overlayEl) };
1587
+ const docListeners = new Map<string, ((...args: any[]) => void)[]>();
1588
+ const origDocument = globalThis.document;
1589
+
1590
+ (globalThis as any).document = {
1591
+ body: mockBody,
1592
+ addEventListener: jest.fn((event: string, handler: (...args: any[]) => void) => {
1593
+ if (!docListeners.has(event)) docListeners.set(event, []);
1594
+ docListeners.get(event)!.push(handler);
1595
+ }),
1596
+ removeEventListener: jest.fn((event: string, handler: (...args: any[]) => void) => {
1597
+ const handlers = docListeners.get(event);
1598
+ if (handlers) {
1599
+ const idx = handlers.indexOf(handler);
1600
+ if (idx !== -1) handlers.splice(idx, 1);
1601
+ }
1602
+ }),
1603
+ };
1604
+
1605
+ return { overlayEl, docListeners, origDocument };
1606
+ }
1607
+
1608
+ it('closeBtn click removes overlay', () => {
1609
+ const { renderer } = createRenderer();
1610
+ const { overlayEl, origDocument } = setupDocumentMock();
1611
+
1612
+ try {
1613
+ renderer.showFullImage(image);
1614
+
1615
+ // The overlay has a modal child, which has a close button child
1616
+ const modalEl = overlayEl.children[0]; // claudian-image-modal
1617
+ // Children: img (index 0), closeBtn (index 1)
1618
+ const closeBtn = modalEl.children[1];
1619
+ expect(closeBtn.hasClass('claudian-image-modal-close')).toBe(true);
1620
+
1621
+ const removeSpy = jest.spyOn(overlayEl, 'remove');
1622
+ closeBtn.click();
1623
+
1624
+ expect(removeSpy).toHaveBeenCalled();
1625
+ } finally {
1626
+ (globalThis as any).document = origDocument;
1627
+ }
1628
+ });
1629
+
1630
+ it('clicking overlay background removes overlay', () => {
1631
+ const { renderer } = createRenderer();
1632
+ const { overlayEl, origDocument } = setupDocumentMock();
1633
+
1634
+ try {
1635
+ renderer.showFullImage(image);
1636
+
1637
+ const removeSpy = jest.spyOn(overlayEl, 'remove');
1638
+
1639
+ // Simulate click on the overlay itself (e.target === overlay)
1640
+ const clickHandlers = overlayEl._eventListeners.get('click');
1641
+ expect(clickHandlers).toBeDefined();
1642
+ clickHandlers![0]({ target: overlayEl });
1643
+
1644
+ expect(removeSpy).toHaveBeenCalled();
1645
+ } finally {
1646
+ (globalThis as any).document = origDocument;
1647
+ }
1648
+ });
1649
+
1650
+ it('ESC key removes overlay', () => {
1651
+ const { renderer } = createRenderer();
1652
+ const { overlayEl, docListeners, origDocument } = setupDocumentMock();
1653
+
1654
+ try {
1655
+ renderer.showFullImage(image);
1656
+
1657
+ const removeSpy = jest.spyOn(overlayEl, 'remove');
1658
+
1659
+ // Simulate ESC key press via the document keydown listener
1660
+ const keydownHandlers = docListeners.get('keydown');
1661
+ expect(keydownHandlers).toBeDefined();
1662
+ expect(keydownHandlers!.length).toBeGreaterThan(0);
1663
+ keydownHandlers![0]({ key: 'Escape' });
1664
+
1665
+ expect(removeSpy).toHaveBeenCalled();
1666
+ // After close, the keydown handler should be removed
1667
+ expect(document.removeEventListener).toHaveBeenCalledWith('keydown', expect.any(Function));
1668
+ } finally {
1669
+ (globalThis as any).document = origDocument;
1670
+ }
1671
+ });
1672
+ });
1673
+
1674
+ // ============================================
1675
+ // renderContent - code block wrapping (error path)
1676
+ // ============================================
1677
+
1678
+ describe('renderContent - error handling', () => {
1679
+ it('renderContent shows error div when MarkdownRenderer throws', async () => {
1680
+ const { MarkdownRenderer } = await import('obsidian');
1681
+ (MarkdownRenderer.renderMarkdown as jest.Mock).mockRejectedValueOnce(
1682
+ new Error('Render failed')
1683
+ );
1684
+
1685
+ const { renderer } = createRenderer();
1686
+ const el = createMockEl();
1687
+
1688
+ await renderer.renderContent(el, '**broken markdown**');
1689
+
1690
+ const errorDiv = el.children.find(
1691
+ (c: any) => c.hasClass('claudian-render-error')
1692
+ );
1693
+ expect(errorDiv).toBeDefined();
1694
+ expect(errorDiv!.textContent).toBe('Failed to render message content.');
1695
+ });
1696
+ });
1697
+
1698
+ // ============================================
1699
+ // addTextCopyButton - rapid click handling
1700
+ // ============================================
1701
+
1702
+ describe('addTextCopyButton - rapid click handling', () => {
1703
+ let originalNavigator: Navigator;
1704
+
1705
+ beforeEach(() => {
1706
+ originalNavigator = globalThis.navigator;
1707
+ jest.useFakeTimers();
1708
+ Object.defineProperty(globalThis, 'navigator', {
1709
+ value: { clipboard: { writeText: jest.fn().mockResolvedValue(undefined) } },
1710
+ writable: true,
1711
+ configurable: true,
1712
+ });
1713
+ });
1714
+
1715
+ afterEach(() => {
1716
+ jest.useRealTimers();
1717
+ Object.defineProperty(globalThis, 'navigator', {
1718
+ value: originalNavigator,
1719
+ writable: true,
1720
+ configurable: true,
1721
+ });
1722
+ });
1723
+
1724
+ it('rapid clicks clear previous timeout', async () => {
1725
+ const { renderer } = createRenderer();
1726
+ const textEl = createMockEl();
1727
+ const clearTimeoutSpy = jest.spyOn(globalThis, 'clearTimeout');
1728
+
1729
+ renderer.addTextCopyButton(textEl, 'content to copy');
1730
+
1731
+ const copyBtn = textEl.children[0];
1732
+ const clickHandlers = copyBtn._eventListeners.get('click');
1733
+ expect(clickHandlers).toBeDefined();
1734
+
1735
+ // First click
1736
+ await clickHandlers![0]({ stopPropagation: jest.fn() });
1737
+ expect(copyBtn.textContent).toBe('Copied!');
1738
+
1739
+ // Second rapid click before timeout expires
1740
+ await clickHandlers![0]({ stopPropagation: jest.fn() });
1741
+
1742
+ // clearTimeout should have been called for the first pending timeout
1743
+ expect(clearTimeoutSpy).toHaveBeenCalled();
1744
+ expect(copyBtn.textContent).toBe('Copied!');
1745
+
1746
+ clearTimeoutSpy.mockRestore();
1747
+ });
1748
+
1749
+ it('feedback timeout restores icon after delay', async () => {
1750
+ const { renderer } = createRenderer();
1751
+ const textEl = createMockEl();
1752
+
1753
+ renderer.addTextCopyButton(textEl, 'content to copy');
1754
+
1755
+ const copyBtn = textEl.children[0];
1756
+ const originalInnerHTML = copyBtn.innerHTML;
1757
+ const clickHandlers = copyBtn._eventListeners.get('click');
1758
+
1759
+ // Click to copy
1760
+ await clickHandlers![0]({ stopPropagation: jest.fn() });
1761
+ expect(copyBtn.textContent).toBe('Copied!');
1762
+ expect(copyBtn.classList.contains('copied')).toBe(true);
1763
+
1764
+ // Advance timers by 1500ms (the feedback duration)
1765
+ jest.advanceTimersByTime(1500);
1766
+
1767
+ // Icon should be restored and copied class removed
1768
+ expect(copyBtn.innerHTML).toBe(originalInnerHTML);
1769
+ expect(copyBtn.classList.contains('copied')).toBe(false);
1770
+ });
1771
+ });
1772
+
1773
+ // ============================================
1774
+ // renderContent - code block wrapping
1775
+ // ============================================
1776
+
1777
+ describe('renderContent - code block wrapping', () => {
1778
+ it('passes image-processed markdown directly to MarkdownRenderer', async () => {
1779
+ const { MarkdownRenderer } = await import('obsidian');
1780
+ const { replaceImageEmbedsWithHtml } = await import('@/utils/imageEmbed');
1781
+ const { processFileLinks } = await import('@/utils/fileLink');
1782
+ const { renderer } = createRenderer();
1783
+ const el = createMockEl();
1784
+
1785
+ (replaceImageEmbedsWithHtml as jest.Mock).mockReturnValueOnce(
1786
+ '<span title="[[note.md]]">raw html</span>\n [[note.md]]'
1787
+ );
1788
+
1789
+ await renderer.renderContent(el, 'before-images ![[image.png]] [[note.md]]');
1790
+
1791
+ expect(replaceImageEmbedsWithHtml).toHaveBeenCalledWith(
1792
+ 'before-images ![[image.png]] [[note.md]]',
1793
+ expect.anything(),
1794
+ { mediaFolder: '' }
1795
+ );
1796
+ expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalledWith(
1797
+ '<span title="[[note.md]]">raw html</span>\n [[note.md]]',
1798
+ el,
1799
+ '',
1800
+ expect.anything()
1801
+ );
1802
+ expect(processFileLinks).toHaveBeenCalledWith(expect.anything(), el);
1803
+ });
1804
+
1805
+ it('should wrap pre elements in code wrapper divs', async () => {
1806
+ const { MarkdownRenderer } = await import('obsidian');
1807
+ const { renderer } = createRenderer();
1808
+ const el = createMockEl();
1809
+
1810
+ // Mock renderMarkdown to create a pre element in the container
1811
+ (MarkdownRenderer.renderMarkdown as jest.Mock).mockImplementationOnce(
1812
+ async (_md: string, container: any) => {
1813
+ const pre = container.createEl('pre');
1814
+ pre.createEl('code', { text: 'console.log("hello")' });
1815
+ }
1816
+ );
1817
+
1818
+ await renderer.renderContent(el, '```js\nconsole.log("hello")\n```');
1819
+
1820
+ // The pre should be wrapped in a claudian-code-wrapper
1821
+ // Due to mock limitations, check that querySelectorAll was called on el
1822
+ // The actual wrapping logic runs on real DOM, but the mock captures calls
1823
+ expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalled();
1824
+ });
1825
+
1826
+ it('should skip wrapping already-wrapped pre elements', async () => {
1827
+ const { MarkdownRenderer } = await import('obsidian');
1828
+ const { renderer } = createRenderer();
1829
+ const el = createMockEl();
1830
+
1831
+ // Mock renderMarkdown to create an already-wrapped pre element
1832
+ (MarkdownRenderer.renderMarkdown as jest.Mock).mockImplementationOnce(
1833
+ async (_md: string, container: any) => {
1834
+ const wrapper = container.createDiv({ cls: 'claudian-code-wrapper' });
1835
+ wrapper.createEl('pre');
1836
+ }
1837
+ );
1838
+
1839
+ await renderer.renderContent(el, '```\nalready wrapped\n```');
1840
+
1841
+ // Should not throw and should complete normally
1842
+ expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalled();
1843
+ });
1844
+ });
1845
+
1846
+ // ============================================
1847
+ // renderMessageImages - click handler
1848
+ // ============================================
1849
+
1850
+ describe('renderMessageImages - click handler', () => {
1851
+ it('should add click handler on image elements', () => {
1852
+ const containerEl = createMockEl();
1853
+ const { renderer } = createRenderer();
1854
+ const showFullImageSpy = jest.spyOn(renderer, 'showFullImage').mockImplementation(() => {});
1855
+ jest.spyOn(renderer, 'setImageSrc').mockImplementation(() => {});
1856
+
1857
+ const images: ImageAttachment[] = [
1858
+ { id: 'img-1', name: 'photo.png', mediaType: 'image/png', data: 'base64data', size: 200, source: 'file' },
1859
+ ];
1860
+
1861
+ renderer.renderMessageImages(containerEl, images);
1862
+
1863
+ // Find the img element and check for click handler
1864
+ const imagesContainer = containerEl.children[0];
1865
+ const wrapper = imagesContainer.children[0];
1866
+ const imgEl = wrapper.children[0]; // The img element
1867
+
1868
+ // Check click handler is registered
1869
+ const clickHandlers = imgEl._eventListeners?.get('click');
1870
+ expect(clickHandlers).toBeDefined();
1871
+ expect(clickHandlers!.length).toBe(1);
1872
+
1873
+ // Trigger click and verify showFullImage is called
1874
+ clickHandlers![0]();
1875
+ expect(showFullImageSpy).toHaveBeenCalledWith(images[0]);
1876
+ });
1877
+ });
1878
+
1879
+ // ============================================
1880
+ // renderContent - code block wrapping with language labels
1881
+ // ============================================
1882
+
1883
+ describe('renderContent - language label and copy', () => {
1884
+ it('should add language label when code block has language class', async () => {
1885
+ const { MarkdownRenderer } = await import('obsidian');
1886
+ const { renderer } = createRenderer();
1887
+ const el = createMockEl();
1888
+
1889
+ (MarkdownRenderer.renderMarkdown as jest.Mock).mockImplementationOnce(
1890
+ async (_md: string, container: any) => {
1891
+ const pre = container.createEl('pre');
1892
+ const code = pre.createEl('code');
1893
+ code.className = 'language-typescript';
1894
+ code.textContent = 'const x = 1;';
1895
+ }
1896
+ );
1897
+
1898
+ await renderer.renderContent(el, '```typescript\nconst x = 1;\n```');
1899
+
1900
+ expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalled();
1901
+ });
1902
+
1903
+ it('should move copy-code-button outside pre into wrapper', async () => {
1904
+ const { MarkdownRenderer } = await import('obsidian');
1905
+ const { renderer } = createRenderer();
1906
+ const el = createMockEl();
1907
+
1908
+ (MarkdownRenderer.renderMarkdown as jest.Mock).mockImplementationOnce(
1909
+ async (_md: string, container: any) => {
1910
+ const pre = container.createEl('pre');
1911
+ pre.createEl('code', { text: 'some code' });
1912
+ const copyBtn = pre.createEl('button');
1913
+ copyBtn.className = 'copy-code-button';
1914
+ }
1915
+ );
1916
+
1917
+ await renderer.renderContent(el, '```\nsome code\n```');
1918
+
1919
+ expect(MarkdownRenderer.renderMarkdown).toHaveBeenCalled();
1920
+ });
1921
+ });
1922
+
1923
+ // ============================================
1924
+ // addMessage - displayContent for user messages
1925
+ // ============================================
1926
+
1927
+ it('addMessage renders displayContent instead of content when available', () => {
1928
+ const messagesEl = createMockEl();
1929
+ const { renderer } = createRenderer(messagesEl);
1930
+ const renderContentSpy = jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
1931
+
1932
+ const msg: ChatMessage = {
1933
+ id: 'u1',
1934
+ role: 'user',
1935
+ content: 'full prompt with context',
1936
+ displayContent: 'user input only',
1937
+ timestamp: Date.now(),
1938
+ };
1939
+
1940
+ renderer.addMessage(msg);
1941
+
1942
+ expect(renderContentSpy).toHaveBeenCalledWith(expect.anything(), 'user input only');
1943
+ });
1944
+
1945
+ // ============================================
1946
+ // renderStoredThinkingBlock - durationSeconds parameter
1947
+ // ============================================
1948
+
1949
+ describe('renderStoredThinkingBlock - durationSeconds parameter', () => {
1950
+ it('should pass durationSeconds to renderStoredThinkingBlock', () => {
1951
+ const messagesEl = createMockEl();
1952
+ const { renderer } = createRenderer(messagesEl);
1953
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
1954
+
1955
+ (renderStoredThinkingBlock as jest.Mock).mockClear();
1956
+
1957
+ const msg: ChatMessage = {
1958
+ id: 'm1',
1959
+ role: 'assistant',
1960
+ content: '',
1961
+ timestamp: Date.now(),
1962
+ contentBlocks: [
1963
+ { type: 'thinking', content: 'deep thought', durationSeconds: 42 } as any,
1964
+ ],
1965
+ };
1966
+
1967
+ renderer.renderStoredMessage(msg);
1968
+
1969
+ expect(renderStoredThinkingBlock).toHaveBeenCalledWith(
1970
+ expect.anything(),
1971
+ 'deep thought',
1972
+ 42,
1973
+ expect.any(Function)
1974
+ );
1975
+ });
1976
+
1977
+ it('should pass undefined durationSeconds when not set', () => {
1978
+ const messagesEl = createMockEl();
1979
+ const { renderer } = createRenderer(messagesEl);
1980
+ jest.spyOn(renderer, 'renderContent').mockResolvedValue(undefined);
1981
+
1982
+ (renderStoredThinkingBlock as jest.Mock).mockClear();
1983
+
1984
+ const msg: ChatMessage = {
1985
+ id: 'm1',
1986
+ role: 'assistant',
1987
+ content: '',
1988
+ timestamp: Date.now(),
1989
+ contentBlocks: [
1990
+ { type: 'thinking', content: 'thought without duration' } as any,
1991
+ ],
1992
+ };
1993
+
1994
+ renderer.renderStoredMessage(msg);
1995
+
1996
+ expect(renderStoredThinkingBlock).toHaveBeenCalledWith(
1997
+ expect.anything(),
1998
+ 'thought without duration',
1999
+ undefined,
2000
+ expect.any(Function)
2001
+ );
2002
+ });
2003
+ });
2004
+ });