@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,1886 @@
1
+ import type { Component } from 'obsidian';
2
+ import { Notice, Platform } from 'obsidian';
3
+
4
+ import { getHiddenProviderCommandSet } from '../../../core/providers/commands/hiddenCommands';
5
+ import type { ProviderCommandDropdownConfig } from '../../../core/providers/commands/ProviderCommandCatalog';
6
+ import type { ProviderCommandEntry } from '../../../core/providers/commands/ProviderCommandEntry';
7
+ import { getEnabledProviderForModel, getProviderForModel } from '../../../core/providers/modelRouting';
8
+ import { ProviderRegistry } from '../../../core/providers/ProviderRegistry';
9
+ import { ProviderSettingsCoordinator } from '../../../core/providers/ProviderSettingsCoordinator';
10
+ import { ProviderWorkspaceRegistry } from '../../../core/providers/ProviderWorkspaceRegistry';
11
+ import type {
12
+ ProviderCapabilities,
13
+ ProviderChatUIConfig,
14
+ ProviderId,
15
+ ProviderUIOption,
16
+ } from '../../../core/providers/types';
17
+ import {
18
+ DEFAULT_CHAT_PROVIDER_ID,
19
+ } from '../../../core/providers/types';
20
+ import type { ChatRuntime } from '../../../core/runtime/ChatRuntime';
21
+ import type { AutoTurnResult } from '../../../core/runtime/types';
22
+ import { TOOL_AGENT_OUTPUT } from '../../../core/tools/toolNames';
23
+ import type { ChatMessage, ClaudianSettings, Conversation, StreamChunk } from '../../../core/types';
24
+ import { t } from '../../../i18n/i18n';
25
+ import type ClaudianPlugin from '../../../main';
26
+ import { SlashCommandDropdown } from '../../../shared/components/SlashCommandDropdown';
27
+ import { getEnhancedPath } from '../../../utils/env';
28
+ import { getVaultPath } from '../../../utils/path';
29
+ import { BrowserSelectionController } from '../controllers/BrowserSelectionController';
30
+ import { CanvasSelectionController } from '../controllers/CanvasSelectionController';
31
+ import { ConversationController } from '../controllers/ConversationController';
32
+ import { InputController } from '../controllers/InputController';
33
+ import { NavigationController } from '../controllers/NavigationController';
34
+ import { SelectionController } from '../controllers/SelectionController';
35
+ import { StreamController } from '../controllers/StreamController';
36
+ import { MessageRenderer } from '../rendering/MessageRenderer';
37
+ import { cleanupThinkingBlock } from '../rendering/ThinkingBlockRenderer';
38
+ import { findRewindContext } from '../rewind';
39
+ import { BangBashService } from '../services/BangBashService';
40
+ import { SubagentManager } from '../services/SubagentManager';
41
+ import { ChatState } from '../state/ChatState';
42
+ import { BangBashModeManager as BangBashModeManagerClass } from '../ui/BangBashModeManager';
43
+ import { FileContextManager } from '../ui/FileContext';
44
+ import { ImageContextManager } from '../ui/ImageContext';
45
+ import { createInputToolbar } from '../ui/InputToolbar';
46
+ import { InstructionModeManager as InstructionModeManagerClass } from '../ui/InstructionModeManager';
47
+ import { NavigationSidebar } from '../ui/NavigationSidebar';
48
+ import { StatusPanel } from '../ui/StatusPanel';
49
+ import { autoResizeTextarea } from '../ui/textareaResize';
50
+ import { recalculateUsageForModel } from '../utils/usageInfo';
51
+ import { getTabProviderId } from './providerResolution';
52
+ import type { TabData, TabDOMElements, TabId, TabManagerViewHost, TabProviderContext } from './types';
53
+ import { generateTabId } from './types';
54
+
55
+ type TabProviderSettings = Record<string, unknown> & {
56
+ model: string;
57
+ thinkingBudget: string;
58
+ effortLevel: string;
59
+ serviceTier: string;
60
+ permissionMode: string;
61
+ customContextLimits?: Record<string, number>;
62
+ };
63
+
64
+ function getSharedSelectionFocusScopeEls(component: Component): HTMLElement[] {
65
+ const host = component as Partial<TabManagerViewHost>;
66
+ return host.getSharedSelectionFocusScopeEls?.() ?? [];
67
+ }
68
+
69
+ /**
70
+ * Returns model options for a blank tab.
71
+ * Uses provider registration metadata to determine which providers are
72
+ * available and how they should appear in the mixed picker.
73
+ */
74
+ export function getBlankTabModelOptions(
75
+ settings: Record<string, unknown>,
76
+ ): ProviderUIOption[] {
77
+ return ProviderRegistry.getEnabledProviderIds(settings).flatMap((providerId) => {
78
+ const uiConfig = ProviderRegistry.getChatUIConfig(providerId);
79
+ const providerIcon = uiConfig.getProviderIcon?.() ?? undefined;
80
+ const group = ProviderRegistry.getProviderDisplayName(providerId);
81
+
82
+ return uiConfig.getModelOptions(settings)
83
+ .map(model => ({ ...model, group, providerIcon }));
84
+ });
85
+ }
86
+
87
+ /**
88
+ * Resolves the draft model for a new blank tab by projecting provider-specific
89
+ * saved settings. Without this, `plugin.settings.model` reflects only the
90
+ * settings-provider's model, which may belong to a different provider.
91
+ */
92
+ function resolveBlankTabModel(
93
+ plugin: ClaudianPlugin,
94
+ providerId?: ProviderId,
95
+ ): string {
96
+ const settings = plugin.settings as unknown as Record<string, unknown>;
97
+ if (!providerId) {
98
+ return settings.model as string;
99
+ }
100
+
101
+ const targetProviderId = ProviderRegistry.isEnabled(providerId, settings)
102
+ ? providerId
103
+ : ProviderRegistry.resolveSettingsProviderId(settings);
104
+ const snapshot = ProviderSettingsCoordinator.getProviderSettingsSnapshot(settings, targetProviderId);
105
+ return snapshot.model as string;
106
+ }
107
+
108
+ export interface TabCreateOptions {
109
+ plugin: ClaudianPlugin;
110
+
111
+ containerEl: HTMLElement;
112
+ conversation?: Conversation;
113
+ tabId?: TabId;
114
+ /** Restored draft model for blank tabs. */
115
+ draftModel?: string | null;
116
+ /** Provider to inherit for blank tabs (e.g. from the active tab). */
117
+ defaultProviderId?: ProviderId;
118
+ onStreamingChanged?: (isStreaming: boolean) => void;
119
+ onTitleChanged?: (title: string) => void;
120
+ onAttentionChanged?: (needsAttention: boolean) => void;
121
+ onConversationIdChanged?: (conversationId: string | null) => void;
122
+ }
123
+
124
+ export { getTabProviderId } from './providerResolution';
125
+
126
+ function getTabCapabilities(
127
+ tab: TabProviderContext,
128
+ plugin: ClaudianPlugin,
129
+ conversation?: Conversation | null,
130
+ ): ProviderCapabilities {
131
+ const providerId = getTabProviderId(tab, plugin, conversation);
132
+ if (tab.service?.providerId === providerId) {
133
+ return tab.service.getCapabilities();
134
+ }
135
+
136
+ return ProviderRegistry.getCapabilities(providerId);
137
+ }
138
+
139
+ function getTabChatUIConfig(
140
+ tab: TabProviderContext,
141
+ plugin: ClaudianPlugin,
142
+ conversation?: Conversation | null,
143
+ ): ProviderChatUIConfig {
144
+ return ProviderRegistry.getChatUIConfig(getTabProviderId(tab, plugin, conversation));
145
+ }
146
+
147
+ function getTabSettingsSnapshot(
148
+ tab: TabProviderContext,
149
+ plugin: ClaudianPlugin,
150
+ ): TabProviderSettings {
151
+ return ProviderSettingsCoordinator.getProviderSettingsSnapshot(
152
+ plugin.settings,
153
+ getTabProviderId(tab, plugin),
154
+ );
155
+ }
156
+
157
+ function getTabPermissionMode(
158
+ tab: TabProviderContext,
159
+ plugin: ClaudianPlugin,
160
+ ): string {
161
+ const permissionMode = getTabSettingsSnapshot(tab, plugin).permissionMode;
162
+ return typeof permissionMode === 'string' && permissionMode
163
+ ? permissionMode
164
+ : 'normal';
165
+ }
166
+
167
+ function getTabHiddenCommands(
168
+ tab: TabProviderContext,
169
+ plugin: ClaudianPlugin,
170
+ conversation?: Conversation | null,
171
+ ): Set<string> {
172
+ return getHiddenProviderCommandSet(
173
+ plugin.settings,
174
+ getTabProviderId(tab, plugin, conversation),
175
+ );
176
+ }
177
+
178
+ function isEnterWithoutShiftOrComposition(e: KeyboardEvent): boolean {
179
+ if (e.key !== 'Enter' || e.shiftKey || e.isComposing) {
180
+ return false;
181
+ }
182
+
183
+ return true;
184
+ }
185
+
186
+ function hasPlatformSendModifier(e: KeyboardEvent): boolean {
187
+ if (Platform.isMacOS) {
188
+ return e.metaKey === true && !e.ctrlKey && !e.altKey;
189
+ }
190
+
191
+ return e.ctrlKey === true && !e.metaKey && !e.altKey;
192
+ }
193
+
194
+ function shouldSendMessageFromExplicitEnterShortcut(e: KeyboardEvent): boolean {
195
+ return isEnterWithoutShiftOrComposition(e) && hasPlatformSendModifier(e);
196
+ }
197
+
198
+ function shouldSendMessageFromEnterKey(
199
+ e: KeyboardEvent,
200
+ settings: Pick<ClaudianSettings, 'requireCommandOrControlEnterToSend'>,
201
+ ): boolean {
202
+ if (!isEnterWithoutShiftOrComposition(e)) {
203
+ return false;
204
+ }
205
+
206
+ if (settings.requireCommandOrControlEnterToSend === true) {
207
+ return hasPlatformSendModifier(e);
208
+ }
209
+
210
+ return true;
211
+ }
212
+
213
+ function isTabInputFocused(tab: TabData): boolean {
214
+ return tab.dom.inputEl.ownerDocument.activeElement === tab.dom.inputEl;
215
+ }
216
+
217
+ function sendTabInputMessage(
218
+ tab: TabData,
219
+ e: KeyboardEvent,
220
+ options?: { requireInputFocus?: boolean },
221
+ ): boolean {
222
+ if (options?.requireInputFocus && !isTabInputFocused(tab)) {
223
+ return false;
224
+ }
225
+
226
+ const inputController = tab.controllers.inputController;
227
+ if (!inputController) {
228
+ return false;
229
+ }
230
+
231
+ e.preventDefault();
232
+ void inputController.sendMessage();
233
+ return true;
234
+ }
235
+
236
+ export function sendTabInputMessageFromExplicitEnterShortcut(
237
+ tab: TabData,
238
+ e: KeyboardEvent,
239
+ options?: { requireInputFocus?: boolean },
240
+ ): boolean {
241
+ if (!shouldSendMessageFromExplicitEnterShortcut(e)) {
242
+ return false;
243
+ }
244
+
245
+ return sendTabInputMessage(tab, e, options);
246
+ }
247
+
248
+ function sendTabInputMessageFromEnterKey(
249
+ tab: TabData,
250
+ settings: Pick<ClaudianSettings, 'requireCommandOrControlEnterToSend'>,
251
+ e: KeyboardEvent,
252
+ ): boolean {
253
+ if (!shouldSendMessageFromEnterKey(e, settings)) {
254
+ return false;
255
+ }
256
+
257
+ return sendTabInputMessage(tab, e);
258
+ }
259
+
260
+ type ProviderCatalogInfo = {
261
+ config: ProviderCommandDropdownConfig;
262
+ getEntries: () => Promise<ProviderCommandEntry[]>;
263
+ } | null;
264
+
265
+ function getRegistryProviderCatalogInfo(providerId: ProviderId): ProviderCatalogInfo {
266
+ const catalog = ProviderWorkspaceRegistry.getCommandCatalog(providerId);
267
+ if (!catalog) {
268
+ return null;
269
+ }
270
+
271
+ return {
272
+ config: catalog.getDropdownConfig(),
273
+ getEntries: () => catalog.listDropdownEntries({ includeBuiltIns: false }),
274
+ };
275
+ }
276
+
277
+ function getProviderMcpManager(providerId: ProviderId) {
278
+ return ProviderWorkspaceRegistry.getMcpServerManager(providerId);
279
+ }
280
+
281
+ function syncSlashCommandDropdownForProvider(
282
+ tab: TabData,
283
+ plugin: ClaudianPlugin,
284
+ getProviderCatalogConfig?: () => ProviderCatalogInfo,
285
+ conversation?: Conversation | null,
286
+ ): void {
287
+ const dropdown = tab.ui.slashCommandDropdown;
288
+ if (!dropdown) {
289
+ return;
290
+ }
291
+
292
+ const catalogInfo = getProviderCatalogConfig?.()
293
+ ?? getRegistryProviderCatalogInfo(getTabProviderId(tab, plugin, conversation));
294
+
295
+ if (catalogInfo) {
296
+ dropdown.setProviderCatalog?.(catalogInfo.config, catalogInfo.getEntries);
297
+ } else {
298
+ dropdown.resetSdkSkillsCache();
299
+ }
300
+
301
+ dropdown.setHiddenCommands(getTabHiddenCommands(tab, plugin, conversation));
302
+ }
303
+
304
+ async function updateTabProviderSettings(
305
+ tab: TabProviderContext,
306
+ plugin: ClaudianPlugin,
307
+ update: (settings: TabProviderSettings) => void,
308
+ ): Promise<TabProviderSettings> {
309
+ const providerId = getTabProviderId(tab, plugin);
310
+ const snapshot = getTabSettingsSnapshot(tab, plugin);
311
+ update(snapshot);
312
+ ProviderSettingsCoordinator.commitProviderSettingsSnapshot(
313
+ plugin.settings,
314
+ providerId,
315
+ snapshot,
316
+ );
317
+ await plugin.saveSettings();
318
+ return snapshot;
319
+ }
320
+
321
+ function refreshTabProviderUI(tab: TabData, plugin: ClaudianPlugin): void {
322
+ const capabilities = getTabCapabilities(tab, plugin);
323
+ const permissionMode = getTabPermissionMode(tab, plugin);
324
+ tab.ui.modelSelector?.updateDisplay();
325
+ tab.ui.modelSelector?.renderOptions();
326
+ tab.ui.modeSelector?.updateDisplay();
327
+ tab.ui.modeSelector?.renderOptions();
328
+ tab.ui.thinkingBudgetSelector?.updateDisplay();
329
+ tab.ui.permissionToggle?.updateDisplay();
330
+ tab.ui.serviceTierToggle?.updateDisplay();
331
+ tab.dom.inputWrapper.toggleClass(
332
+ 'claudian-input-plan-mode',
333
+ permissionMode === 'plan' && capabilities.supportsPlanMode,
334
+ );
335
+ }
336
+
337
+ /**
338
+ * Hides or disables UI elements that the active provider does not support.
339
+ * Called after toolbar initialization and on provider switches.
340
+ */
341
+ function applyProviderUIGating(tab: TabData, plugin: ClaudianPlugin): void {
342
+ const capabilities = getTabCapabilities(tab, plugin);
343
+ const uiConfig = getTabChatUIConfig(tab, plugin);
344
+ const mcpManager = capabilities.supportsMcpTools
345
+ ? getProviderMcpManager(capabilities.providerId)
346
+ : null;
347
+ const hasPermissionToggle = Boolean(uiConfig.getPermissionModeToggle?.());
348
+
349
+ if (!capabilities.supportsMcpTools) {
350
+ tab.ui.mcpServerSelector?.clearEnabled();
351
+ }
352
+ tab.ui.mcpServerSelector?.setVisible(capabilities.supportsMcpTools);
353
+ tab.ui.permissionToggle?.setVisible(hasPermissionToggle);
354
+ tab.ui.fileContextManager?.setMcpManager(mcpManager);
355
+
356
+ tab.ui.fileContextManager?.setAgentService(
357
+ ProviderWorkspaceRegistry.getAgentMentionProvider(capabilities.providerId),
358
+ );
359
+
360
+ tab.ui.imageContextManager?.setEnabled(capabilities.supportsImageAttachments);
361
+ tab.ui.contextUsageMeter?.update(tab.state.usage);
362
+ }
363
+
364
+ function syncTabProviderServices(
365
+ tab: TabData,
366
+ plugin: ClaudianPlugin,
367
+ ): void {
368
+ tab.services.instructionRefineService?.cancel();
369
+ tab.services.instructionRefineService?.resetConversation();
370
+ tab.services.instructionRefineService = ProviderRegistry.createInstructionRefineService(plugin, tab.providerId);
371
+ tab.services.subagentManager.setTaskResultInterpreter?.(
372
+ ProviderRegistry.getTaskResultInterpreter(tab.providerId)
373
+ );
374
+ }
375
+
376
+ function ensureTitleGenerationService(tab: TabData, plugin: ClaudianPlugin): void {
377
+ if (!tab.services.titleGenerationService) {
378
+ tab.services.titleGenerationService = ProviderRegistry.createTitleGenerationService(plugin);
379
+ }
380
+ }
381
+
382
+ function cleanupTabRuntime(tab: TabData): void {
383
+ if (tab.service && typeof tab.service.cleanup === 'function') {
384
+ tab.service.cleanup();
385
+ }
386
+ tab.service = null;
387
+ tab.serviceInitialized = false;
388
+ }
389
+
390
+ /**
391
+ * Called when provider availability changes. If a blank tab targets a provider
392
+ * that is now disabled, it falls back to the first enabled provider's default
393
+ * blank-tab model. Refreshes model selector options for all blank tabs.
394
+ */
395
+ export function onProviderAvailabilityChanged(tab: TabData, plugin: ClaudianPlugin): void {
396
+ if (tab.lifecycleState !== 'blank') return;
397
+
398
+ const settingsSnapshot = plugin.settings as unknown as Record<string, unknown>;
399
+ const enabledProviderIds = ProviderRegistry.getEnabledProviderIds(settingsSnapshot);
400
+ let nextProviderId = tab.providerId;
401
+
402
+ if (tab.draftModel) {
403
+ const draftProvider = getEnabledProviderForModel(tab.draftModel, settingsSnapshot);
404
+ const draftProviderOwnsModel = ProviderRegistry
405
+ .getChatUIConfig(draftProvider)
406
+ .ownsModel(tab.draftModel, settingsSnapshot);
407
+ if (!enabledProviderIds.includes(draftProvider) || !draftProviderOwnsModel) {
408
+ const fallbackProviderId = enabledProviderIds[0] ?? DEFAULT_CHAT_PROVIDER_ID;
409
+ const fallbackModels = ProviderRegistry.getChatUIConfig(fallbackProviderId)
410
+ .getModelOptions(settingsSnapshot);
411
+ tab.draftModel = fallbackModels[0]?.value ?? tab.draftModel;
412
+ nextProviderId = fallbackProviderId;
413
+ } else {
414
+ nextProviderId = draftProvider;
415
+ }
416
+ }
417
+
418
+ tab.providerId = nextProviderId;
419
+
420
+ // Clean up stale service if provider changed
421
+ if (
422
+ tab.service
423
+ && tab.service.providerId !== nextProviderId
424
+ ) {
425
+ tab.service.cleanup();
426
+ tab.service = null;
427
+ tab.serviceInitialized = false;
428
+ }
429
+
430
+ syncTabProviderServices(tab, plugin);
431
+ tab.ui.slashCommandDropdown?.setHiddenCommands(getTabHiddenCommands(tab, plugin));
432
+ tab.ui.slashCommandDropdown?.resetSdkSkillsCache();
433
+ refreshTabProviderUI(tab, plugin);
434
+ applyProviderUIGating(tab, plugin);
435
+ }
436
+
437
+ /**
438
+ * Creates a new Tab instance with all required state.
439
+ */
440
+ export function createTab(options: TabCreateOptions): TabData {
441
+ const {
442
+ plugin,
443
+ containerEl,
444
+ conversation,
445
+ tabId,
446
+ onStreamingChanged,
447
+ onAttentionChanged,
448
+ onConversationIdChanged,
449
+ } = options;
450
+
451
+ const id = tabId ?? generateTabId();
452
+
453
+ const contentEl = containerEl.createDiv({ cls: 'claudian-tab-content claudian-hidden' });
454
+
455
+ const state = new ChatState({
456
+ onStreamingStateChanged: onStreamingChanged,
457
+ onAttentionChanged: onAttentionChanged,
458
+ onConversationChanged: onConversationIdChanged,
459
+ });
460
+
461
+ // Create subagent manager with no-op callback.
462
+ // This placeholder is replaced in initializeTabControllers() with the actual
463
+ // callback that updates the StreamController. We defer the real callback
464
+ // because StreamController doesn't exist until controllers are initialized.
465
+ const subagentManager = new SubagentManager(() => {});
466
+
467
+ const dom = buildTabDOM(contentEl);
468
+ state.queueIndicatorEl = dom.queueIndicatorEl;
469
+
470
+ const isBound = !!conversation?.id;
471
+ const restoredDraftModel = typeof options.draftModel === 'string'
472
+ ? options.draftModel.trim()
473
+ : '';
474
+ const draftModel = isBound
475
+ ? null
476
+ : (restoredDraftModel || resolveBlankTabModel(plugin, options.defaultProviderId));
477
+ const initialProviderId = conversation?.providerId
478
+ ?? (draftModel
479
+ ? getEnabledProviderForModel(draftModel, plugin.settings)
480
+ : DEFAULT_CHAT_PROVIDER_ID);
481
+
482
+ const tab: TabData = {
483
+ id,
484
+ lifecycleState: isBound ? 'bound_cold' : 'blank',
485
+ draftModel,
486
+ providerId: initialProviderId,
487
+ conversationId: conversation?.id ?? null,
488
+ service: null,
489
+ serviceInitialized: false,
490
+ state,
491
+ controllers: {
492
+ selectionController: null,
493
+ browserSelectionController: null,
494
+ canvasSelectionController: null,
495
+ conversationController: null,
496
+ streamController: null,
497
+ inputController: null,
498
+ navigationController: null,
499
+ },
500
+ services: {
501
+ subagentManager,
502
+ instructionRefineService: null,
503
+ titleGenerationService: null,
504
+ },
505
+ ui: {
506
+ fileContextManager: null,
507
+ imageContextManager: null,
508
+ modelSelector: null,
509
+ modeSelector: null,
510
+ thinkingBudgetSelector: null,
511
+ externalContextSelector: null,
512
+ mcpServerSelector: null,
513
+ permissionToggle: null,
514
+ serviceTierToggle: null,
515
+ slashCommandDropdown: null,
516
+ instructionModeManager: null,
517
+ bangBashModeManager: null,
518
+ contextUsageMeter: null,
519
+ statusPanel: null,
520
+ navigationSidebar: null,
521
+ },
522
+ dom,
523
+ renderer: null,
524
+ };
525
+
526
+ return tab;
527
+ }
528
+
529
+ /**
530
+ * Builds the DOM structure for a tab.
531
+ */
532
+ function buildTabDOM(contentEl: HTMLElement): TabDOMElements {
533
+ const messagesWrapperEl = contentEl.createDiv({ cls: 'claudian-messages-wrapper' });
534
+ const messagesEl = messagesWrapperEl.createDiv({ cls: 'claudian-messages' });
535
+ const welcomeEl = messagesEl.createDiv({ cls: 'claudian-welcome' });
536
+ const statusPanelContainerEl = contentEl.createDiv({ cls: 'claudian-status-panel-container' });
537
+ const inputComposerEl = contentEl.createDiv({ cls: 'claudian-input-composer' });
538
+ const inputContainerEl = inputComposerEl.createDiv({ cls: 'claudian-input-container' });
539
+ const queueIndicatorEl = inputContainerEl.createDiv({ cls: 'claudian-input-queue-row' });
540
+ const navRowEl = inputContainerEl.createDiv({ cls: 'claudian-input-nav-row' });
541
+ const inputWrapper = inputContainerEl.createDiv({ cls: 'claudian-input-wrapper' });
542
+ const contextRowEl = inputWrapper.createDiv({ cls: 'claudian-context-row' });
543
+ const inputEl = inputWrapper.createEl('textarea', {
544
+ cls: 'claudian-input',
545
+ attr: {
546
+ placeholder: 'How can i help you today?',
547
+ rows: '3',
548
+ dir: 'auto',
549
+ },
550
+ });
551
+
552
+ return {
553
+ contentEl,
554
+ messagesEl,
555
+ welcomeEl,
556
+ statusPanelContainerEl,
557
+ inputComposerEl,
558
+ inputContainerEl,
559
+ queueIndicatorEl,
560
+ inputWrapper,
561
+ inputEl,
562
+ navRowEl,
563
+ contextRowEl,
564
+ selectionIndicatorEl: null,
565
+ browserIndicatorEl: null,
566
+ canvasIndicatorEl: null,
567
+ eventCleanups: [],
568
+ };
569
+ }
570
+
571
+ /**
572
+ * Initializes the tab's chat runtime for the send path.
573
+ *
574
+ * This is the ONLY place a runtime is created. Called from:
575
+ * - ensureServiceInitialized() in InputController.sendMessage()
576
+ *
577
+ * Session sync is passive (state update only). The runtime is started
578
+ * on demand by query() inside the send path.
579
+ */
580
+ export async function initializeTabService(
581
+ tab: TabData,
582
+ plugin: ClaudianPlugin,
583
+ conversationOverride?: Conversation | null,
584
+ ): Promise<void>;
585
+ export async function initializeTabService(
586
+ tab: TabData,
587
+ plugin: ClaudianPlugin,
588
+ _legacyArg: unknown,
589
+ conversationOverride?: Conversation | null,
590
+ ): Promise<void>;
591
+ export async function initializeTabService(
592
+ tab: TabData,
593
+ plugin: ClaudianPlugin,
594
+ argOrOverride?: unknown,
595
+ maybeOverride?: Conversation | null,
596
+ ): Promise<void> {
597
+ if (tab.lifecycleState === 'closing') {
598
+ return;
599
+ }
600
+
601
+ // Support legacy 4-arg call sites (3rd arg was previously an MCP manager)
602
+ const conversationOverride = isConversationLike(argOrOverride)
603
+ ? argOrOverride
604
+ : (argOrOverride === null ? null : maybeOverride);
605
+
606
+ const conversation = conversationOverride ?? (
607
+ tab.conversationId
608
+ ? await plugin.getConversationById(tab.conversationId)
609
+ : null
610
+ );
611
+ const providerId = getTabProviderId(tab, plugin, conversation);
612
+
613
+ if (tab.serviceInitialized && tab.service?.providerId === providerId) {
614
+ return;
615
+ }
616
+
617
+ let service: ChatRuntime | null = null;
618
+ let unsubscribeReadyState: (() => void) | null = null;
619
+ const previousService = tab.service;
620
+
621
+ try {
622
+ if (typeof previousService?.cleanup === 'function') {
623
+ previousService.cleanup();
624
+ }
625
+ tab.service = null;
626
+ tab.serviceInitialized = false;
627
+
628
+ const runtime = ProviderRegistry.createChatRuntime({ plugin, providerId });
629
+ service = runtime;
630
+ unsubscribeReadyState = runtime.onReadyStateChange(() => {});
631
+ tab.dom.eventCleanups.push(() => unsubscribeReadyState?.());
632
+
633
+ // Passive sync: set session state without starting the runtime process.
634
+ // The runtime starts on demand when query() is called.
635
+ if (conversation) {
636
+ const hasMessages = conversation.messages.length > 0;
637
+ const externalContextPaths = hasMessages
638
+ ? conversation.externalContextPaths || []
639
+ : (plugin.settings.persistentExternalContextPaths || []);
640
+
641
+ runtime.syncConversationState(conversation, externalContextPaths);
642
+ }
643
+
644
+ // Re-check after async operations — tab may have been closed during init
645
+ if (isClosingLifecycleState(tab.lifecycleState)) {
646
+ unsubscribeReadyState?.();
647
+ service?.cleanup();
648
+ return;
649
+ }
650
+
651
+
652
+ tab.providerId = providerId;
653
+ tab.service = service;
654
+ tab.serviceInitialized = true;
655
+
656
+ // Update lifecycle state
657
+ if (tab.lifecycleState === 'blank') {
658
+ tab.draftModel = null;
659
+ }
660
+ tab.lifecycleState = 'bound_active';
661
+ } catch (error) {
662
+ // Clean up partial state on failure
663
+ unsubscribeReadyState?.();
664
+ service?.cleanup();
665
+ tab.service = null;
666
+ tab.serviceInitialized = false;
667
+
668
+ // Re-throw to let caller handle (e.g., show error to user)
669
+ throw error;
670
+ }
671
+ }
672
+
673
+ function isConversationLike(value: unknown): value is Conversation {
674
+ return !!value
675
+ && typeof value === 'object'
676
+ && typeof (value as Conversation).id === 'string'
677
+ && Array.isArray((value as Conversation).messages);
678
+ }
679
+
680
+ function initializeContextManagers(tab: TabData, plugin: ClaudianPlugin): void {
681
+ const { dom } = tab;
682
+ const app = plugin.app;
683
+
684
+ // File context manager - chips in contextRowEl, dropdown in inputContainerEl
685
+ tab.ui.fileContextManager = new FileContextManager(
686
+ app,
687
+ dom.contextRowEl,
688
+ dom.inputEl,
689
+ {
690
+ getExcludedTags: () => plugin.settings.excludedTags,
691
+ onChipsChanged: () => {
692
+ tab.controllers.selectionController?.updateContextRowVisibility();
693
+ tab.controllers.browserSelectionController?.updateContextRowVisibility();
694
+ tab.controllers.canvasSelectionController?.updateContextRowVisibility();
695
+ autoResizeTextarea(dom.inputEl);
696
+ tab.renderer?.scrollToBottomIfNeeded();
697
+ },
698
+ getExternalContexts: () => tab.ui.externalContextSelector?.getExternalContexts() || [],
699
+ },
700
+ dom.inputContainerEl
701
+ );
702
+ tab.ui.fileContextManager.setMcpManager(getProviderMcpManager(getTabProviderId(tab, plugin)));
703
+
704
+ // Image context manager - drag/drop uses inputContainerEl, preview in contextRowEl
705
+ tab.ui.imageContextManager = new ImageContextManager(
706
+ dom.inputContainerEl,
707
+ dom.inputEl,
708
+ {
709
+ onImagesChanged: () => {
710
+ tab.controllers.selectionController?.updateContextRowVisibility();
711
+ tab.controllers.browserSelectionController?.updateContextRowVisibility();
712
+ tab.controllers.canvasSelectionController?.updateContextRowVisibility();
713
+ autoResizeTextarea(dom.inputEl);
714
+ tab.renderer?.scrollToBottomIfNeeded();
715
+ },
716
+ },
717
+ dom.contextRowEl
718
+ );
719
+ }
720
+
721
+ function initializeSlashCommands(
722
+ tab: TabData,
723
+ getHiddenCommands?: () => Set<string>,
724
+ catalogInfo?: { config: ProviderCommandDropdownConfig; getEntries: () => Promise<ProviderCommandEntry[]> } | null,
725
+ ): void {
726
+ const { dom } = tab;
727
+
728
+ tab.ui.slashCommandDropdown = new SlashCommandDropdown(
729
+ dom.inputContainerEl,
730
+ dom.inputEl,
731
+ {
732
+ onSelect: () => {},
733
+ onHide: () => {},
734
+ },
735
+ {
736
+ hiddenCommands: getHiddenCommands?.() ?? new Set(),
737
+ providerConfig: catalogInfo?.config,
738
+ getProviderEntries: catalogInfo?.getEntries,
739
+ }
740
+ );
741
+ }
742
+
743
+ /**
744
+ * Initializes instruction mode and todo panel for a tab.
745
+ */
746
+ function initializeInstructionAndTodo(tab: TabData, plugin: ClaudianPlugin): void {
747
+ const { dom } = tab;
748
+
749
+ syncTabProviderServices(tab, plugin);
750
+ ensureTitleGenerationService(tab, plugin);
751
+ tab.ui.instructionModeManager = new InstructionModeManagerClass(
752
+ dom.inputEl,
753
+ {
754
+ onSubmit: async (rawInstruction) => {
755
+ await tab.controllers.inputController?.handleInstructionSubmit(rawInstruction);
756
+ },
757
+ getInputWrapper: () => dom.inputWrapper,
758
+ }
759
+ );
760
+
761
+ // Bang bash mode (! command execution)
762
+ if (isBangBashEnabled(plugin.settings)) {
763
+ const vaultPath = getVaultPath(plugin.app);
764
+ if (vaultPath) {
765
+ const enhancedPath = getEnhancedPath();
766
+ const bashService = new BangBashService(vaultPath, enhancedPath);
767
+
768
+ tab.ui.bangBashModeManager = new BangBashModeManagerClass(
769
+ dom.inputEl,
770
+ {
771
+ onSubmit: async (command) => {
772
+ const statusPanel = tab.ui.statusPanel;
773
+ if (!statusPanel) return;
774
+
775
+ const id = `bash-${Date.now()}`;
776
+ statusPanel.addBashOutput({ id, command, status: 'running', output: '' });
777
+
778
+ const result = await bashService.execute(command);
779
+ const output = [result.stdout, result.stderr, result.error].filter(Boolean).join('\n').trim();
780
+ const status = result.exitCode === 0 ? 'completed' : 'error';
781
+ statusPanel.updateBashOutput(id, { status, output, exitCode: result.exitCode });
782
+ },
783
+ getInputWrapper: () => dom.inputWrapper,
784
+ }
785
+ );
786
+ }
787
+ }
788
+
789
+ tab.ui.statusPanel = new StatusPanel();
790
+ tab.ui.statusPanel.mount(dom.statusPanelContainerEl);
791
+ }
792
+
793
+ function isBangBashEnabled(settings: Record<string, unknown>): boolean {
794
+ return ProviderRegistry.getEnabledProviderIds(settings).some((providerId) => (
795
+ ProviderRegistry.getChatUIConfig(providerId).isBangBashEnabled?.(settings) ?? false
796
+ ));
797
+ }
798
+
799
+ /**
800
+ * Creates and wires the input toolbar for a tab.
801
+ */
802
+ function initializeInputToolbar(
803
+ tab: TabData,
804
+ plugin: ClaudianPlugin,
805
+ getProviderCatalogConfig?: () => ProviderCatalogInfo,
806
+ onProviderChanged?: (providerId: ProviderId) => void | Promise<void>,
807
+ ): void {
808
+ const { dom } = tab;
809
+
810
+ const inputToolbar = dom.inputWrapper.createDiv({ cls: 'claudian-input-toolbar' });
811
+
812
+ // Blank-tab UI config wrapper that returns mixed model options
813
+ const blankTabUIConfigProxy = (): ProviderChatUIConfig => {
814
+ const draftProvider = tab.draftModel
815
+ ? getEnabledProviderForModel(tab.draftModel, plugin.settings)
816
+ : DEFAULT_CHAT_PROVIDER_ID;
817
+ const baseConfig = ProviderRegistry.getChatUIConfig(draftProvider);
818
+ return {
819
+ ...baseConfig,
820
+ getModelOptions: (settings: Record<string, unknown>) =>
821
+ getBlankTabModelOptions(settings),
822
+ };
823
+ };
824
+
825
+ const toolbarComponents = createInputToolbar(inputToolbar, {
826
+ getUIConfig: () => {
827
+ if (tab.lifecycleState === 'blank') {
828
+ return blankTabUIConfigProxy();
829
+ }
830
+ return getTabChatUIConfig(tab, plugin);
831
+ },
832
+ getCapabilities: () => getTabCapabilities(tab, plugin),
833
+ getSettings: () => getTabSettingsSnapshot(tab, plugin),
834
+ getEnvironmentVariables: () => plugin.getActiveEnvironmentVariables(),
835
+ onModelChange: async (model: string) => {
836
+ // For blank tabs, update draft model and derive provider
837
+ if (tab.lifecycleState === 'blank') {
838
+ const previousProvider = tab.providerId;
839
+ tab.draftModel = model;
840
+ const newProvider = getEnabledProviderForModel(
841
+ model,
842
+ plugin.settings,
843
+ );
844
+ const didProviderChange = newProvider !== previousProvider;
845
+ if (tab.service) {
846
+ cleanupTabRuntime(tab);
847
+ }
848
+ tab.providerId = newProvider;
849
+ if (didProviderChange) {
850
+ syncTabProviderServices(tab, plugin);
851
+ }
852
+ syncSlashCommandDropdownForProvider(tab, plugin, getProviderCatalogConfig);
853
+
854
+ // Update settings for the new provider
855
+ const uiConfig = ProviderRegistry.getChatUIConfig(newProvider);
856
+ await updateTabProviderSettings(tab, plugin, (settings) => {
857
+ settings.model = model;
858
+ uiConfig.applyModelDefaults(model, settings);
859
+ });
860
+ if (didProviderChange) {
861
+ await onProviderChanged?.(newProvider);
862
+ }
863
+ await uiConfig.prepareModelMetadata?.(model, plugin.settings, { plugin });
864
+ tab.ui.thinkingBudgetSelector?.updateDisplay();
865
+ tab.ui.serviceTierToggle?.updateDisplay();
866
+ tab.ui.modelSelector?.updateDisplay();
867
+ tab.ui.modeSelector?.updateDisplay();
868
+ // Re-render options (provider may have changed reasoning controls)
869
+ tab.ui.modelSelector?.renderOptions();
870
+ tab.ui.modeSelector?.renderOptions();
871
+ applyProviderUIGating(tab, plugin);
872
+ return;
873
+ }
874
+
875
+ // For bound tabs, reject cross-provider model changes
876
+ const boundProvider = tab.providerId;
877
+ const modelProvider = getProviderForModel(model, plugin.settings);
878
+ if (modelProvider !== boundProvider) {
879
+ new Notice('Cannot switch provider on a bound session. Start a new tab instead.');
880
+ tab.ui.modelSelector?.updateDisplay();
881
+ return;
882
+ }
883
+
884
+ const uiConfig: ProviderChatUIConfig = getTabChatUIConfig(tab, plugin);
885
+ const providerSettings = await updateTabProviderSettings(tab, plugin, (settings) => {
886
+ settings.model = model;
887
+ uiConfig.applyModelDefaults(model, settings);
888
+ });
889
+ await uiConfig.prepareModelMetadata?.(model, plugin.settings, { plugin });
890
+ tab.ui.thinkingBudgetSelector?.updateDisplay();
891
+ tab.ui.serviceTierToggle?.updateDisplay();
892
+ tab.ui.modelSelector?.updateDisplay();
893
+ tab.ui.modelSelector?.renderOptions();
894
+
895
+ // Recalculate context usage percentage for the new model's context window
896
+ const currentUsage = tab.state.usage;
897
+ if (currentUsage) {
898
+ const newContextWindow = uiConfig.getContextWindowSize(
899
+ model,
900
+ providerSettings.customContextLimits,
901
+ providerSettings,
902
+ );
903
+ tab.state.usage = recalculateUsageForModel(currentUsage, model, newContextWindow);
904
+ }
905
+ },
906
+ onModeChange: async (mode: string) => {
907
+ await updateTabProviderSettings(tab, plugin, (settings) => {
908
+ getTabChatUIConfig(tab, plugin).applyModeSelection?.(mode, settings);
909
+ });
910
+ tab.ui.modeSelector?.updateDisplay();
911
+ tab.ui.modeSelector?.renderOptions();
912
+ },
913
+ onThinkingBudgetChange: async (budget: string) => {
914
+ await updateTabProviderSettings(tab, plugin, (settings) => {
915
+ settings.thinkingBudget = budget;
916
+ getTabChatUIConfig(tab, plugin).applyReasoningSelection?.(settings.model, budget, settings);
917
+ });
918
+ },
919
+ onEffortLevelChange: async (effort: string) => {
920
+ await updateTabProviderSettings(tab, plugin, (settings) => {
921
+ settings.effortLevel = effort;
922
+ getTabChatUIConfig(tab, plugin).applyReasoningSelection?.(settings.model, effort, settings);
923
+ });
924
+ },
925
+ onServiceTierChange: async (serviceTier: string) => {
926
+ await updateTabProviderSettings(tab, plugin, (settings) => {
927
+ settings.serviceTier = serviceTier;
928
+ });
929
+ tab.ui.serviceTierToggle?.updateDisplay();
930
+ },
931
+ onPermissionModeChange: async (mode: string) => {
932
+ await updateTabProviderSettings(tab, plugin, (settings) => {
933
+ const uiConfig = getTabChatUIConfig(tab, plugin);
934
+ if (uiConfig.applyPermissionMode) {
935
+ uiConfig.applyPermissionMode(mode, settings);
936
+ } else {
937
+ settings.permissionMode = mode;
938
+ }
939
+ });
940
+ tab.ui.permissionToggle?.updateDisplay();
941
+ dom.inputWrapper.toggleClass(
942
+ 'claudian-input-plan-mode',
943
+ mode === 'plan' && getTabCapabilities(tab, plugin).supportsPlanMode,
944
+ );
945
+ },
946
+ });
947
+
948
+ tab.ui.modelSelector = toolbarComponents.modelSelector;
949
+ tab.ui.modeSelector = toolbarComponents.modeSelector;
950
+ tab.ui.thinkingBudgetSelector = toolbarComponents.thinkingBudgetSelector;
951
+ tab.ui.contextUsageMeter = toolbarComponents.contextUsageMeter;
952
+ tab.ui.externalContextSelector = toolbarComponents.externalContextSelector;
953
+ tab.ui.mcpServerSelector = toolbarComponents.mcpServerSelector;
954
+ tab.ui.permissionToggle = toolbarComponents.permissionToggle;
955
+ tab.ui.serviceTierToggle = toolbarComponents.serviceTierToggle;
956
+
957
+ tab.ui.mcpServerSelector.setMcpManager(getProviderMcpManager(getTabProviderId(tab, plugin)));
958
+
959
+ // Sync @-mentions to UI selector
960
+ tab.ui.fileContextManager?.setOnMcpMentionChange((servers) => {
961
+ tab.ui.mcpServerSelector?.addMentionedServers(servers);
962
+ });
963
+
964
+ // Wire external context changes
965
+ tab.ui.externalContextSelector.setOnChange(() => {
966
+ tab.ui.fileContextManager?.preScanExternalContexts();
967
+ });
968
+
969
+ // Initialize persistent paths
970
+ tab.ui.externalContextSelector.setPersistentPaths(
971
+ plugin.settings.persistentExternalContextPaths || []
972
+ );
973
+
974
+ // Wire persistence changes
975
+ tab.ui.externalContextSelector.setOnPersistenceChange((paths) => {
976
+ plugin.settings.persistentExternalContextPaths = paths;
977
+ void plugin.saveSettings();
978
+ });
979
+
980
+ refreshTabProviderUI(tab, plugin);
981
+
982
+ // Gate provider-specific UI elements
983
+ applyProviderUIGating(tab, plugin);
984
+ }
985
+
986
+ export interface InitializeTabUIOptions {
987
+ getProviderCatalogConfig?: () => ProviderCatalogInfo;
988
+ onProviderChanged?: (providerId: ProviderId) => void | Promise<void>;
989
+ }
990
+
991
+ /**
992
+ * Initializes the tab's UI components.
993
+ * Call this after the tab is created and before it becomes active.
994
+ */
995
+ export function initializeTabUI(
996
+ tab: TabData,
997
+ plugin: ClaudianPlugin,
998
+ options: InitializeTabUIOptions = {}
999
+ ): void {
1000
+ const { dom, state } = tab;
1001
+
1002
+ // Initialize context managers (file/image)
1003
+ initializeContextManagers(tab, plugin);
1004
+
1005
+ // Selection indicator - add to contextRowEl
1006
+ dom.selectionIndicatorEl = dom.contextRowEl.createDiv({ cls: 'claudian-selection-indicator claudian-hidden' });
1007
+
1008
+ dom.browserIndicatorEl = dom.contextRowEl.createDiv({ cls: 'claudian-browser-selection-indicator claudian-hidden' });
1009
+
1010
+ dom.canvasIndicatorEl = dom.contextRowEl.createDiv({ cls: 'claudian-canvas-indicator claudian-hidden' });
1011
+
1012
+ const catalogInfo = options.getProviderCatalogConfig?.() ?? null;
1013
+ initializeSlashCommands(
1014
+ tab,
1015
+ () => getTabHiddenCommands(tab, plugin),
1016
+ catalogInfo,
1017
+ );
1018
+
1019
+ if (dom.messagesEl.parentElement) {
1020
+ tab.ui.navigationSidebar = new NavigationSidebar(
1021
+ dom.messagesEl.parentElement,
1022
+ dom.messagesEl
1023
+ );
1024
+ }
1025
+
1026
+ initializeInstructionAndTodo(tab, plugin);
1027
+ initializeInputToolbar(tab, plugin, options.getProviderCatalogConfig, options.onProviderChanged);
1028
+
1029
+ state.callbacks = {
1030
+ ...state.callbacks,
1031
+ onUsageChanged: (usage) => {
1032
+ tab.ui.contextUsageMeter?.update(usage);
1033
+ },
1034
+ onTodosChanged: (todos) => tab.ui.statusPanel?.updateTodos(todos),
1035
+ onAutoScrollChanged: () => tab.ui.navigationSidebar?.updateVisibility(),
1036
+ };
1037
+
1038
+ // ResizeObserver to detect overflow changes (e.g., content growth)
1039
+ const resizeObserver = new ResizeObserver(() => {
1040
+ tab.ui.navigationSidebar?.updateVisibility();
1041
+ });
1042
+ resizeObserver.observe(dom.messagesEl);
1043
+ dom.eventCleanups.push(() => resizeObserver.disconnect());
1044
+ }
1045
+
1046
+ export interface ForkContext {
1047
+ messages: ChatMessage[];
1048
+ providerId?: ProviderId;
1049
+ sourceSessionId: string;
1050
+ sourceProviderState?: Record<string, unknown>;
1051
+ resumeAt: string;
1052
+ sourceTitle?: string;
1053
+ /** 1-based index used for fork title suffix (counts only non-interrupt user messages). */
1054
+ forkAtUserMessage?: number;
1055
+ currentNote?: string;
1056
+ }
1057
+
1058
+ function deepCloneMessages(messages: ChatMessage[]): ChatMessage[] {
1059
+ if (typeof structuredClone === 'function') {
1060
+ return structuredClone(messages);
1061
+ }
1062
+ return JSON.parse(JSON.stringify(messages)) as ChatMessage[];
1063
+ }
1064
+
1065
+ function isClosingLifecycleState(state: TabData['lifecycleState']): boolean {
1066
+ return state === 'closing';
1067
+ }
1068
+
1069
+ function countUserMessagesForForkTitle(messages: ChatMessage[]): number {
1070
+ // Keep fork numbering stable by excluding non-semantic user messages.
1071
+ return messages.filter(m => m.role === 'user' && !m.isInterrupt && !m.isRebuiltContext).length;
1072
+ }
1073
+
1074
+ interface ForkSource {
1075
+ providerId?: ProviderId;
1076
+ sourceSessionId: string;
1077
+ sourceProviderState?: Record<string, unknown>;
1078
+ sourceTitle?: string;
1079
+ currentNote?: string;
1080
+ }
1081
+
1082
+ /**
1083
+ * Resolves session ID and conversation metadata needed for forking.
1084
+ * Prefers the live service session ID; falls back to persisted conversation metadata.
1085
+ * Shows a notice and returns null when no session can be resolved.
1086
+ */
1087
+ function resolveForkSource(tab: TabData, plugin: ClaudianPlugin): ForkSource | null {
1088
+ const conversation = tab.conversationId
1089
+ ? plugin.getConversationSync(tab.conversationId)
1090
+ : null;
1091
+
1092
+ // Delegate session ID resolution to the runtime when available;
1093
+ // fall back to persisted conversation metadata when no runtime is active.
1094
+ const sourceSessionId = tab.service
1095
+ ? tab.service.resolveSessionIdForFork(conversation ?? null)
1096
+ : ProviderRegistry
1097
+ .getConversationHistoryService(conversation?.providerId ?? tab.providerId)
1098
+ .resolveSessionIdForConversation(conversation);
1099
+
1100
+ if (!sourceSessionId) {
1101
+ new Notice(t('chat.fork.failed', { error: t('chat.fork.errorNoSession') }));
1102
+ return null;
1103
+ }
1104
+
1105
+ return {
1106
+ providerId: getTabProviderId(tab, plugin, conversation),
1107
+ sourceSessionId,
1108
+ sourceProviderState: conversation?.providerState,
1109
+ sourceTitle: conversation?.title,
1110
+ currentNote: conversation?.currentNote,
1111
+ };
1112
+ }
1113
+
1114
+ async function handleForkRequest(
1115
+ tab: TabData,
1116
+ plugin: ClaudianPlugin,
1117
+ userMessageId: string,
1118
+ forkRequestCallback: (forkContext: ForkContext) => Promise<void>,
1119
+ ): Promise<void> {
1120
+ const { state } = tab;
1121
+
1122
+ if (!getTabCapabilities(tab, plugin).supportsFork) {
1123
+ new Notice('Fork is not supported by this provider.');
1124
+ return;
1125
+ }
1126
+
1127
+ if (state.isStreaming) {
1128
+ new Notice(t('chat.fork.unavailableStreaming'));
1129
+ return;
1130
+ }
1131
+
1132
+ const msgs = state.messages;
1133
+ const userIdx = msgs.findIndex(m => m.id === userMessageId);
1134
+ if (userIdx === -1) {
1135
+ new Notice(t('chat.fork.failed', { error: t('chat.fork.errorMessageNotFound') }));
1136
+ return;
1137
+ }
1138
+
1139
+ if (!msgs[userIdx].userMessageId) {
1140
+ new Notice(t('chat.fork.unavailableNoUuid'));
1141
+ return;
1142
+ }
1143
+
1144
+ const rewindCtx = findRewindContext(msgs, userIdx);
1145
+ if (!rewindCtx.hasResponse || !rewindCtx.prevAssistantUuid) {
1146
+ new Notice(t('chat.fork.unavailableNoResponse'));
1147
+ return;
1148
+ }
1149
+
1150
+ const source = resolveForkSource(tab, plugin);
1151
+ if (!source) return;
1152
+
1153
+ await forkRequestCallback({
1154
+ messages: deepCloneMessages(msgs.slice(0, userIdx)),
1155
+ providerId: source.providerId,
1156
+ sourceSessionId: source.sourceSessionId,
1157
+ sourceProviderState: source.sourceProviderState,
1158
+ resumeAt: rewindCtx.prevAssistantUuid,
1159
+ sourceTitle: source.sourceTitle,
1160
+ forkAtUserMessage: countUserMessagesForForkTitle(msgs.slice(0, userIdx + 1)),
1161
+ currentNote: source.currentNote,
1162
+ });
1163
+ }
1164
+
1165
+ async function handleForkAll(
1166
+ tab: TabData,
1167
+ plugin: ClaudianPlugin,
1168
+ forkRequestCallback: (forkContext: ForkContext) => Promise<void>,
1169
+ ): Promise<void> {
1170
+ const { state } = tab;
1171
+
1172
+ if (!getTabCapabilities(tab, plugin).supportsFork) {
1173
+ new Notice('Fork is not supported by this provider.');
1174
+ return;
1175
+ }
1176
+
1177
+ if (state.isStreaming) {
1178
+ new Notice(t('chat.fork.unavailableStreaming'));
1179
+ return;
1180
+ }
1181
+
1182
+ const msgs = state.messages;
1183
+ if (msgs.length === 0) {
1184
+ new Notice(t('chat.fork.commandNoMessages'));
1185
+ return;
1186
+ }
1187
+
1188
+ let lastAssistantUuid: string | undefined;
1189
+ for (let i = msgs.length - 1; i >= 0; i--) {
1190
+ if (msgs[i].role === 'assistant' && msgs[i].assistantMessageId) {
1191
+ lastAssistantUuid = msgs[i].assistantMessageId;
1192
+ break;
1193
+ }
1194
+ }
1195
+
1196
+ if (!lastAssistantUuid) {
1197
+ new Notice(t('chat.fork.commandNoAssistantUuid'));
1198
+ return;
1199
+ }
1200
+
1201
+ const source = resolveForkSource(tab, plugin);
1202
+ if (!source) return;
1203
+
1204
+ await forkRequestCallback({
1205
+ messages: deepCloneMessages(msgs),
1206
+ providerId: source.providerId,
1207
+ sourceSessionId: source.sourceSessionId,
1208
+ sourceProviderState: source.sourceProviderState,
1209
+ resumeAt: lastAssistantUuid,
1210
+ sourceTitle: source.sourceTitle,
1211
+ forkAtUserMessage: countUserMessagesForForkTitle(msgs) + 1,
1212
+ currentNote: source.currentNote,
1213
+ });
1214
+ }
1215
+
1216
+ export function initializeTabControllers(
1217
+ tab: TabData,
1218
+ plugin: ClaudianPlugin,
1219
+ component: Component,
1220
+ forkRequestCallback?: (forkContext: ForkContext) => Promise<void>,
1221
+ openConversation?: (conversationId: string) => Promise<void>,
1222
+ getProviderCatalogConfig?: () => ProviderCatalogInfo,
1223
+ ): void;
1224
+ /** @deprecated Legacy 7-arg overload — 4th arg was previously an MCP manager. */
1225
+ export function initializeTabControllers(
1226
+ tab: TabData,
1227
+ plugin: ClaudianPlugin,
1228
+ component: Component,
1229
+ _legacyArg: unknown,
1230
+ forkRequestCallback?: (forkContext: ForkContext) => Promise<void>,
1231
+ openConversation?: (conversationId: string) => Promise<void>,
1232
+ getProviderCatalogConfig?: () => ProviderCatalogInfo,
1233
+ ): void;
1234
+ export function initializeTabControllers(
1235
+ tab: TabData,
1236
+ plugin: ClaudianPlugin,
1237
+ component: Component,
1238
+ arg4?: unknown,
1239
+ arg5?: unknown,
1240
+ arg6?: unknown,
1241
+ arg7?: unknown,
1242
+ ): void {
1243
+ // Support legacy 7-arg call sites (4th arg was previously an MCP manager)
1244
+ const isLegacy = arg4 !== undefined && typeof arg4 !== 'function';
1245
+ const forkRequestCallback = (isLegacy ? arg5 : arg4) as
1246
+ ((forkContext: ForkContext) => Promise<void>) | undefined;
1247
+ const openConversation = (isLegacy ? arg6 : arg5) as
1248
+ ((conversationId: string) => Promise<void>) | undefined;
1249
+ const getProviderCatalogConfig = (isLegacy ? arg7 : arg6) as
1250
+ (() => ProviderCatalogInfo) | undefined;
1251
+
1252
+ const { dom, state, services, ui } = tab;
1253
+
1254
+ // Create renderer
1255
+ tab.renderer = new MessageRenderer(
1256
+ plugin,
1257
+ component,
1258
+ dom.messagesEl,
1259
+ (id, mode) => tab.controllers.conversationController!.rewind(id, mode),
1260
+ forkRequestCallback
1261
+ ? (id) => handleForkRequest(tab, plugin, id, forkRequestCallback)
1262
+ : undefined,
1263
+ () => getTabCapabilities(tab, plugin),
1264
+ );
1265
+
1266
+ // Selection controller
1267
+ tab.controllers.selectionController = new SelectionController(
1268
+ plugin.app,
1269
+ dom.selectionIndicatorEl!,
1270
+ dom.inputEl,
1271
+ dom.contextRowEl,
1272
+ () => autoResizeTextarea(dom.inputEl),
1273
+ [dom.contentEl, dom.inputComposerEl, ...getSharedSelectionFocusScopeEls(component)],
1274
+ );
1275
+
1276
+ tab.controllers.browserSelectionController = new BrowserSelectionController(
1277
+ plugin.app,
1278
+ dom.browserIndicatorEl!,
1279
+ dom.inputEl,
1280
+ dom.contextRowEl,
1281
+ () => autoResizeTextarea(dom.inputEl)
1282
+ );
1283
+
1284
+ tab.controllers.canvasSelectionController = new CanvasSelectionController(
1285
+ plugin.app,
1286
+ dom.canvasIndicatorEl!,
1287
+ dom.inputEl,
1288
+ dom.contextRowEl,
1289
+ () => autoResizeTextarea(dom.inputEl)
1290
+ );
1291
+
1292
+ tab.controllers.streamController = new StreamController({
1293
+ plugin,
1294
+ state,
1295
+ renderer: tab.renderer,
1296
+ subagentManager: services.subagentManager,
1297
+ getMessagesEl: () => dom.messagesEl,
1298
+ getFileContextManager: () => ui.fileContextManager,
1299
+ updateQueueIndicator: () => tab.controllers.inputController?.updateQueueIndicator(),
1300
+ getAgentService: () => tab.service,
1301
+ });
1302
+
1303
+ // Wire subagent callback now that StreamController exists
1304
+ // DOM updates for async subagents are handled by SubagentManager directly;
1305
+ // this callback handles message persistence.
1306
+ services.subagentManager.setCallback(
1307
+ (subagent) => {
1308
+ tab.controllers.streamController?.onAsyncSubagentStateChange(subagent);
1309
+
1310
+ // During active stream, regular end-of-turn save captures latest state.
1311
+ if (!tab.state.isStreaming && tab.state.currentConversationId) {
1312
+ void tab.controllers.conversationController?.save(false).catch(() => {
1313
+ // Best-effort persistence; avoid surfacing background-save failures here.
1314
+ });
1315
+ }
1316
+ }
1317
+ );
1318
+
1319
+ tab.controllers.conversationController = new ConversationController(
1320
+ {
1321
+ plugin,
1322
+ state,
1323
+ renderer: tab.renderer,
1324
+ subagentManager: services.subagentManager,
1325
+ getHistoryDropdown: () => null, // Tab doesn't have its own history dropdown
1326
+ getWelcomeEl: () => dom.welcomeEl,
1327
+ setWelcomeEl: (el) => { dom.welcomeEl = el; },
1328
+ getMessagesEl: () => dom.messagesEl,
1329
+ getInputEl: () => dom.inputEl,
1330
+ getFileContextManager: () => ui.fileContextManager,
1331
+ getImageContextManager: () => ui.imageContextManager,
1332
+ getMcpServerSelector: () => ui.mcpServerSelector,
1333
+ getExternalContextSelector: () => ui.externalContextSelector,
1334
+ clearQueuedMessage: () => tab.controllers.inputController?.clearQueuedMessage(),
1335
+ getTitleGenerationService: () => services.titleGenerationService,
1336
+ getStatusPanel: () => ui.statusPanel,
1337
+ getAgentService: () => tab.service, // Use tab's service instead of plugin's
1338
+ dismissPendingInlinePrompts: () => tab.controllers.inputController?.dismissPendingApproval(),
1339
+ ensureServiceForConversation: async (conversation) => {
1340
+ const nextProviderId = getTabProviderId(tab, plugin, conversation);
1341
+ const providerChanged = tab.providerId !== nextProviderId;
1342
+ tab.providerId = nextProviderId;
1343
+
1344
+ if (providerChanged) {
1345
+ syncTabProviderServices(tab, plugin);
1346
+ }
1347
+
1348
+ // Bind session state only — runtime starts on send
1349
+ tab.conversationId = conversation?.id ?? null;
1350
+ tab.draftModel = null;
1351
+ tab.lifecycleState = conversation ? 'bound_cold' : 'blank';
1352
+ syncSlashCommandDropdownForProvider(tab, plugin, getProviderCatalogConfig, conversation);
1353
+
1354
+ // If the runtime already exists for the right provider, sync it passively
1355
+ if (tab.service && tab.service.providerId === nextProviderId && conversation) {
1356
+ const hasMessages = conversation.messages.length > 0;
1357
+ const externalContextPaths = hasMessages
1358
+ ? conversation.externalContextPaths || []
1359
+ : (plugin.settings.persistentExternalContextPaths || []);
1360
+ tab.service.syncConversationState(conversation, externalContextPaths);
1361
+ }
1362
+
1363
+ refreshTabProviderUI(tab, plugin);
1364
+ applyProviderUIGating(tab, plugin);
1365
+ },
1366
+ },
1367
+ {
1368
+ onNewConversation: () => {
1369
+ // Reset to blank state and drop the bound runtime so the next send
1370
+ // reinitializes against the currently selected blank-tab provider.
1371
+ const previousProviderId = tab.providerId;
1372
+ cleanupTabRuntime(tab);
1373
+ tab.lifecycleState = 'blank';
1374
+ tab.draftModel = resolveBlankTabModel(plugin, previousProviderId);
1375
+ tab.conversationId = null;
1376
+ tab.providerId = getTabProviderId(tab, plugin);
1377
+ if (tab.providerId !== previousProviderId) {
1378
+ syncTabProviderServices(tab, plugin);
1379
+ }
1380
+ refreshTabProviderUI(tab, plugin);
1381
+ applyProviderUIGating(tab, plugin);
1382
+ syncSlashCommandDropdownForProvider(tab, plugin, getProviderCatalogConfig);
1383
+ },
1384
+ onConversationLoaded: () => ui.slashCommandDropdown?.resetSdkSkillsCache(),
1385
+ onConversationSwitched: () => ui.slashCommandDropdown?.resetSdkSkillsCache(),
1386
+ }
1387
+ );
1388
+
1389
+ tab.controllers.inputController = new InputController({
1390
+ plugin,
1391
+ state,
1392
+ renderer: tab.renderer,
1393
+ streamController: tab.controllers.streamController,
1394
+ selectionController: tab.controllers.selectionController,
1395
+ browserSelectionController: tab.controllers.browserSelectionController,
1396
+ canvasSelectionController: tab.controllers.canvasSelectionController,
1397
+ conversationController: tab.controllers.conversationController,
1398
+ getInputEl: () => dom.inputEl,
1399
+ getInputContainerEl: () => dom.inputContainerEl,
1400
+ getWelcomeEl: () => dom.welcomeEl,
1401
+ getMessagesEl: () => dom.messagesEl,
1402
+ getFileContextManager: () => ui.fileContextManager,
1403
+ getImageContextManager: () => ui.imageContextManager,
1404
+ getMcpServerSelector: () => ui.mcpServerSelector,
1405
+ getExternalContextSelector: () => ui.externalContextSelector,
1406
+ getInstructionModeManager: () => ui.instructionModeManager,
1407
+ getInstructionRefineService: () => services.instructionRefineService,
1408
+ getTitleGenerationService: () => services.titleGenerationService,
1409
+ getStatusPanel: () => ui.statusPanel,
1410
+ generateId: generateMessageId,
1411
+ resetInputHeight: () => {
1412
+ // Per-tab input height is managed by CSS, no dynamic adjustment needed
1413
+ },
1414
+ getAuxiliaryModel: () => tab.service?.getAuxiliaryModel?.() ?? tab.draftModel ?? null,
1415
+ getAgentService: () => tab.service,
1416
+ getSubagentManager: () => services.subagentManager,
1417
+ getTabProviderId: () => getTabProviderId(tab, plugin),
1418
+ ensureServiceInitialized: async () => {
1419
+ if (tab.serviceInitialized && tab.lifecycleState === 'bound_active') {
1420
+ return true;
1421
+ }
1422
+
1423
+ try {
1424
+ // For blank tabs on first send: derive provider from draft model
1425
+ if (tab.lifecycleState === 'blank' && tab.draftModel) {
1426
+ const derivedProvider = getEnabledProviderForModel(
1427
+ tab.draftModel,
1428
+ plugin.settings,
1429
+ );
1430
+ tab.providerId = derivedProvider;
1431
+ }
1432
+
1433
+ await initializeTabService(tab, plugin);
1434
+ setupServiceCallbacks(tab, plugin);
1435
+
1436
+ // Transition: lock model selector to bound provider
1437
+ refreshTabProviderUI(tab, plugin);
1438
+ applyProviderUIGating(tab, plugin);
1439
+ return true;
1440
+ } catch (error) {
1441
+ new Notice(error instanceof Error ? error.message : 'Failed to initialize chat service');
1442
+ return false;
1443
+ }
1444
+ },
1445
+ openConversation,
1446
+ onForkAll: forkRequestCallback
1447
+ ? () => handleForkAll(tab, plugin, forkRequestCallback)
1448
+ : undefined,
1449
+ restorePrePlanPermissionModeIfNeeded: () => {
1450
+ if (getTabPermissionMode(tab, plugin) === 'plan') {
1451
+ const restoreMode = tab.state.prePlanPermissionMode ?? 'normal';
1452
+ tab.state.prePlanPermissionMode = null;
1453
+ updatePlanModeUI(tab, plugin, restoreMode);
1454
+ }
1455
+ },
1456
+ });
1457
+
1458
+ tab.controllers.navigationController = new NavigationController({
1459
+ getMessagesEl: () => dom.messagesEl,
1460
+ getInputEl: () => dom.inputEl,
1461
+ getSettings: () => plugin.settings.keyboardNavigation,
1462
+ isStreaming: () => state.isStreaming,
1463
+ shouldSkipEscapeHandling: () => {
1464
+ if (ui.instructionModeManager?.isActive()) return true;
1465
+ if (ui.bangBashModeManager?.isActive()) return true;
1466
+ if (tab.controllers.inputController?.isResumeDropdownVisible()) return true;
1467
+ if (ui.slashCommandDropdown?.isVisible()) return true;
1468
+ if (ui.fileContextManager?.isMentionDropdownVisible()) return true;
1469
+ return false;
1470
+ },
1471
+ });
1472
+ tab.controllers.navigationController.initialize();
1473
+ }
1474
+
1475
+ /**
1476
+ * Wires up input event handlers for a tab.
1477
+ * Call this after controllers are initialized.
1478
+ * Stores cleanup functions in dom.eventCleanups for proper memory management.
1479
+ */
1480
+ export function wireTabInputEvents(tab: TabData, plugin: ClaudianPlugin): void {
1481
+ const { dom, ui, state, controllers } = tab;
1482
+
1483
+ let wasBangBashActive = ui.bangBashModeManager?.isActive() ?? false;
1484
+ const syncBangBashSuppression = (): void => {
1485
+ const isActive = ui.bangBashModeManager?.isActive() ?? false;
1486
+ if (isActive === wasBangBashActive) return;
1487
+ wasBangBashActive = isActive;
1488
+
1489
+ ui.slashCommandDropdown?.setEnabled(!isActive);
1490
+ if (isActive) {
1491
+ ui.fileContextManager?.hideMentionDropdown();
1492
+ }
1493
+ };
1494
+
1495
+ const keydownHandler = (e: KeyboardEvent) => {
1496
+ if (ui.bangBashModeManager?.isActive()) {
1497
+ ui.bangBashModeManager.handleKeydown(e);
1498
+ syncBangBashSuppression();
1499
+ return;
1500
+ }
1501
+
1502
+ if (getTabCapabilities(tab, plugin).supportsInstructionMode && ui.instructionModeManager?.handleTriggerKey(e)) {
1503
+ return;
1504
+ }
1505
+
1506
+ if (ui.bangBashModeManager?.handleTriggerKey(e)) {
1507
+ syncBangBashSuppression();
1508
+ return;
1509
+ }
1510
+
1511
+ if (getTabCapabilities(tab, plugin).supportsInstructionMode && ui.instructionModeManager?.handleKeydown(e)) {
1512
+ return;
1513
+ }
1514
+
1515
+ if (sendTabInputMessageFromExplicitEnterShortcut(tab, e)) {
1516
+ return;
1517
+ }
1518
+
1519
+ if (controllers.inputController?.handleResumeKeydown(e)) {
1520
+ return;
1521
+ }
1522
+
1523
+ if (ui.slashCommandDropdown?.handleKeydown(e)) {
1524
+ return;
1525
+ }
1526
+
1527
+ if (ui.fileContextManager?.handleMentionKeydown(e)) {
1528
+ return;
1529
+ }
1530
+
1531
+ // Check !e.isComposing for IME support (Chinese, Japanese, Korean, etc.)
1532
+ if (e.key === 'Escape' && !e.isComposing && state.isStreaming) {
1533
+ e.preventDefault();
1534
+ controllers.inputController?.cancelStreaming();
1535
+ return;
1536
+ }
1537
+
1538
+ if (sendTabInputMessageFromEnterKey(tab, plugin.settings, e)) {
1539
+ return;
1540
+ }
1541
+ };
1542
+ dom.inputEl.addEventListener('keydown', keydownHandler);
1543
+ dom.eventCleanups.push(() => dom.inputEl.removeEventListener('keydown', keydownHandler));
1544
+
1545
+ const inputHandler = () => {
1546
+ if (!ui.bangBashModeManager?.isActive()) {
1547
+ ui.fileContextManager?.handleInputChange();
1548
+ }
1549
+ ui.instructionModeManager?.handleInputChange();
1550
+ ui.bangBashModeManager?.handleInputChange();
1551
+ syncBangBashSuppression();
1552
+ autoResizeTextarea(dom.inputEl);
1553
+ };
1554
+ dom.inputEl.addEventListener('input', inputHandler);
1555
+ dom.eventCleanups.push(() => dom.inputEl.removeEventListener('input', inputHandler));
1556
+
1557
+ // Scroll listener for auto-scroll control (tracks position always, not just during streaming)
1558
+ const SCROLL_THRESHOLD = 20; // pixels from bottom to consider "at bottom"
1559
+ const RE_ENABLE_DELAY = 150; // ms to wait before re-enabling auto-scroll
1560
+ let reEnableTimeout: number | null = null;
1561
+
1562
+ const isAutoScrollAllowed = (): boolean => plugin.settings.enableAutoScroll ?? true;
1563
+
1564
+ const scrollHandler = () => {
1565
+ if (!isAutoScrollAllowed()) {
1566
+ if (reEnableTimeout) {
1567
+ window.clearTimeout(reEnableTimeout);
1568
+ reEnableTimeout = null;
1569
+ }
1570
+ state.autoScrollEnabled = false;
1571
+ return;
1572
+ }
1573
+
1574
+ const { scrollTop, scrollHeight, clientHeight } = dom.messagesEl;
1575
+ const isAtBottom = scrollHeight - scrollTop - clientHeight <= SCROLL_THRESHOLD;
1576
+
1577
+ if (!isAtBottom) {
1578
+ // Immediately disable when user scrolls up
1579
+ if (reEnableTimeout) {
1580
+ window.clearTimeout(reEnableTimeout);
1581
+ reEnableTimeout = null;
1582
+ }
1583
+ state.autoScrollEnabled = false;
1584
+ } else if (!state.autoScrollEnabled) {
1585
+ // Debounce re-enabling to avoid bounce during scroll animation
1586
+ if (!reEnableTimeout) {
1587
+ reEnableTimeout = window.setTimeout(() => {
1588
+ reEnableTimeout = null;
1589
+ // Re-verify position before enabling (content may have changed)
1590
+ const { scrollTop, scrollHeight, clientHeight } = dom.messagesEl;
1591
+ if (scrollHeight - scrollTop - clientHeight <= SCROLL_THRESHOLD) {
1592
+ state.autoScrollEnabled = true;
1593
+ }
1594
+ }, RE_ENABLE_DELAY);
1595
+ }
1596
+ }
1597
+ };
1598
+ dom.messagesEl.addEventListener('scroll', scrollHandler, { passive: true });
1599
+ dom.eventCleanups.push(() => {
1600
+ dom.messagesEl.removeEventListener('scroll', scrollHandler);
1601
+ if (reEnableTimeout) window.clearTimeout(reEnableTimeout);
1602
+ });
1603
+ }
1604
+
1605
+ /**
1606
+ * Activates a tab (shows it and starts services).
1607
+ */
1608
+ export function activateTab(tab: TabData): void {
1609
+ tab.dom.contentEl.removeClass('claudian-hidden');
1610
+ tab.controllers.selectionController?.start();
1611
+ tab.controllers.browserSelectionController?.start();
1612
+ tab.controllers.canvasSelectionController?.start();
1613
+ // Refresh navigation sidebar visibility (dimensions now available after display)
1614
+ tab.ui.navigationSidebar?.updateVisibility();
1615
+ }
1616
+
1617
+ /**
1618
+ * Deactivates a tab (hides it and stops services).
1619
+ */
1620
+ export function deactivateTab(tab: TabData): void {
1621
+ tab.dom.contentEl.addClass('claudian-hidden');
1622
+ tab.controllers.selectionController?.stop();
1623
+ tab.controllers.browserSelectionController?.stop();
1624
+ tab.controllers.canvasSelectionController?.stop();
1625
+ }
1626
+
1627
+ /**
1628
+ * Cleans up a tab and releases all resources.
1629
+ * Made async to ensure proper cleanup ordering.
1630
+ */
1631
+ export async function destroyTab(tab: TabData): Promise<void> {
1632
+ tab.lifecycleState = 'closing';
1633
+
1634
+ tab.controllers.selectionController?.stop();
1635
+ tab.controllers.selectionController?.clear();
1636
+ tab.controllers.browserSelectionController?.stop();
1637
+ tab.controllers.browserSelectionController?.clear();
1638
+ tab.controllers.canvasSelectionController?.stop();
1639
+ tab.controllers.canvasSelectionController?.clear();
1640
+ tab.controllers.navigationController?.dispose();
1641
+
1642
+ cleanupThinkingBlock(tab.state.currentThinkingState);
1643
+ tab.state.currentThinkingState = null;
1644
+
1645
+ // Dismiss pending inline prompts before DOM teardown
1646
+ tab.controllers.inputController?.dismissPendingApproval();
1647
+
1648
+ tab.controllers.inputController?.destroyResumeDropdown();
1649
+ tab.ui.fileContextManager?.destroy();
1650
+ tab.ui.slashCommandDropdown?.destroy();
1651
+ tab.ui.slashCommandDropdown = null;
1652
+ tab.ui.instructionModeManager?.destroy();
1653
+ tab.ui.instructionModeManager = null;
1654
+ tab.ui.bangBashModeManager?.destroy();
1655
+ tab.ui.bangBashModeManager = null;
1656
+ tab.services.instructionRefineService?.cancel();
1657
+ tab.services.instructionRefineService?.resetConversation();
1658
+ tab.services.instructionRefineService = null;
1659
+ tab.services.titleGenerationService?.cancel();
1660
+ tab.services.titleGenerationService = null;
1661
+ tab.ui.statusPanel?.destroy();
1662
+ tab.ui.statusPanel = null;
1663
+ tab.ui.navigationSidebar?.destroy();
1664
+ tab.ui.navigationSidebar = null;
1665
+
1666
+ tab.services.subagentManager.orphanAllActive();
1667
+ tab.services.subagentManager.clear();
1668
+
1669
+ for (const cleanup of tab.dom.eventCleanups) {
1670
+ cleanup();
1671
+ }
1672
+ tab.dom.eventCleanups.length = 0;
1673
+
1674
+ // Clean up runtime before removing DOM
1675
+ tab.service?.cleanup();
1676
+ tab.service = null;
1677
+ tab.dom.contentEl.remove();
1678
+ }
1679
+
1680
+ /**
1681
+ * Gets the display title for a tab.
1682
+ * Uses synchronous access since we only need the title, not messages.
1683
+ */
1684
+ export function getTabTitle(tab: TabData, plugin: ClaudianPlugin): string {
1685
+ if (tab.conversationId) {
1686
+ const conversation = plugin.getConversationSync(tab.conversationId);
1687
+ if (conversation?.title) {
1688
+ return conversation.title;
1689
+ }
1690
+ }
1691
+ return 'New Chat';
1692
+ }
1693
+
1694
+ /** Shared between Tab.ts and TabManager.ts to avoid duplication. */
1695
+ export function setupServiceCallbacks(tab: TabData, plugin: ClaudianPlugin): void {
1696
+ if (tab.service && tab.controllers.inputController) {
1697
+ tab.service.setApprovalCallback(
1698
+ async (toolName, input, description, options) =>
1699
+ await tab.controllers.inputController?.handleApprovalRequest(toolName, input, description, options)
1700
+ ?? 'cancel'
1701
+ );
1702
+ tab.service.setApprovalDismisser(
1703
+ () => tab.controllers.inputController?.dismissPendingApprovalPrompt()
1704
+ );
1705
+ tab.service.setAskUserQuestionCallback(
1706
+ async (input, signal) =>
1707
+ await tab.controllers.inputController?.handleAskUserQuestion(input, signal)
1708
+ ?? null
1709
+ );
1710
+ tab.service.setExitPlanModeCallback(
1711
+ async (input, signal) => {
1712
+ const decision = await tab.controllers.inputController?.handleExitPlanMode(input, signal) ?? null;
1713
+ // Revert only on approve; feedback and cancel keep plan mode active.
1714
+ if (decision !== null && decision.type !== 'feedback') {
1715
+ // Only restore permission mode if still in plan mode — user may have toggled out via Shift+Tab
1716
+ if (getTabPermissionMode(tab, plugin) === 'plan') {
1717
+ const restoreMode = tab.state.prePlanPermissionMode ?? 'normal';
1718
+ tab.state.prePlanPermissionMode = null;
1719
+ updatePlanModeUI(tab, plugin, restoreMode);
1720
+ }
1721
+ if (decision.type === 'approve-new-session') {
1722
+ tab.state.pendingNewSessionPlan = decision.planContent;
1723
+ tab.state.cancelRequested = true;
1724
+ }
1725
+ }
1726
+ return decision;
1727
+ }
1728
+ );
1729
+ tab.service.setSubagentHookProvider(
1730
+ () => ({
1731
+ hasRunning: tab.services.subagentManager.hasRunningSubagents(),
1732
+ })
1733
+ );
1734
+ tab.service.setAutoTurnCallback((result: AutoTurnResult) => renderAutoTriggeredTurn(tab, result));
1735
+ tab.service.setPermissionModeSyncCallback((sdkMode) => {
1736
+ const mode = sdkMode === 'bypassPermissions' || sdkMode === 'yolo'
1737
+ ? 'yolo'
1738
+ : sdkMode === 'plan'
1739
+ ? 'plan'
1740
+ : 'normal';
1741
+ const currentMode = getTabPermissionMode(tab, plugin);
1742
+
1743
+ if (currentMode !== mode) {
1744
+ // Save pre-plan mode when entering plan (for Shift+Tab toggle restore)
1745
+ if (mode === 'plan' && tab.state.prePlanPermissionMode === null) {
1746
+ tab.state.prePlanPermissionMode = currentMode;
1747
+ }
1748
+ updatePlanModeUI(tab, plugin, mode);
1749
+ }
1750
+ });
1751
+ }
1752
+ }
1753
+
1754
+ function generateMessageId(): string {
1755
+ return `msg-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1756
+ }
1757
+
1758
+ /**
1759
+ * Renders an auto-triggered turn (e.g., agent response to task-notification)
1760
+ * that arrives after the main handler has completed.
1761
+ */
1762
+ function isVisibleAutoTurnChunk(chunk: StreamChunk, hiddenToolIds: Set<string>): boolean {
1763
+ switch (chunk.type) {
1764
+ case 'text':
1765
+ return chunk.content.trim().length > 0;
1766
+ case 'thinking':
1767
+ case 'notice':
1768
+ case 'error':
1769
+ case 'tool_output':
1770
+ case 'context_compacted':
1771
+ case 'subagent_tool_use':
1772
+ case 'subagent_tool_result':
1773
+ return true;
1774
+ case 'tool_use':
1775
+ return chunk.name !== TOOL_AGENT_OUTPUT;
1776
+ case 'tool_result':
1777
+ return !hiddenToolIds.has(chunk.id);
1778
+ default:
1779
+ return false;
1780
+ }
1781
+ }
1782
+
1783
+ function hasVisibleAutoTurnMessageContent(msg: ChatMessage): boolean {
1784
+ if (msg.content.trim().length > 0) return true;
1785
+ if (msg.toolCalls && msg.toolCalls.length > 0) return true;
1786
+ return msg.contentBlocks?.some(block =>
1787
+ block.type !== 'text' || block.content.trim().length > 0
1788
+ ) ?? false;
1789
+ }
1790
+
1791
+ async function renderAutoTriggeredTurn(tab: TabData, result: AutoTurnResult): Promise<void> {
1792
+ if (!tab.dom.contentEl.isConnected) {
1793
+ return;
1794
+ }
1795
+
1796
+ const { chunks, metadata } = result;
1797
+ if (chunks.length === 0) return;
1798
+
1799
+ const hiddenToolIds = new Set(
1800
+ chunks
1801
+ .filter((chunk): chunk is Extract<StreamChunk, { type: 'tool_use' }> =>
1802
+ chunk.type === 'tool_use' && chunk.name === TOOL_AGENT_OUTPUT
1803
+ )
1804
+ .map(chunk => chunk.id)
1805
+ );
1806
+ const hasVisibleContent = chunks.some(chunk => isVisibleAutoTurnChunk(chunk, hiddenToolIds));
1807
+
1808
+ const assistantMsg: ChatMessage = {
1809
+ id: metadata.assistantMessageId ?? generateMessageId(),
1810
+ role: 'assistant',
1811
+ content: '',
1812
+ timestamp: Date.now(),
1813
+ toolCalls: [],
1814
+ contentBlocks: [],
1815
+ ...(metadata.assistantMessageId && { assistantMessageId: metadata.assistantMessageId }),
1816
+ };
1817
+
1818
+ const previousContentEl = tab.state.currentContentEl;
1819
+ const previousTextEl = tab.state.currentTextEl;
1820
+ const previousTextContent = tab.state.currentTextContent;
1821
+ const previousThinkingState = tab.state.currentThinkingState;
1822
+
1823
+ if (hasVisibleContent) {
1824
+ tab.state.addMessage(assistantMsg);
1825
+ const msgEl = tab.renderer?.addMessage?.(assistantMsg);
1826
+ const contentEl = msgEl?.querySelector<HTMLElement>('.claudian-message-content');
1827
+ if (contentEl) {
1828
+ if (!previousContentEl) {
1829
+ tab.state.toolCallElements.clear();
1830
+ }
1831
+ tab.state.currentContentEl = contentEl;
1832
+ tab.state.currentTextEl = null;
1833
+ tab.state.currentTextContent = '';
1834
+ tab.state.currentThinkingState = null;
1835
+ }
1836
+ }
1837
+
1838
+ try {
1839
+ for (const chunk of chunks) {
1840
+ await tab.controllers.streamController?.handleStreamChunk(chunk, assistantMsg);
1841
+ }
1842
+
1843
+ if (hasVisibleContent && !hasVisibleAutoTurnMessageContent(assistantMsg)) {
1844
+ const placeholder = '(background task completed)';
1845
+ assistantMsg.content = placeholder;
1846
+ await tab.controllers.streamController?.appendText(placeholder);
1847
+ }
1848
+
1849
+ if (hasVisibleContent) {
1850
+ await tab.controllers.streamController?.finalizeCurrentThinkingBlock(assistantMsg);
1851
+ await tab.controllers.streamController?.finalizeCurrentTextBlock(assistantMsg);
1852
+ }
1853
+ } finally {
1854
+ if (hasVisibleContent) {
1855
+ tab.controllers.streamController?.hideThinkingIndicator();
1856
+ tab.services.subagentManager.resetStreamingState?.();
1857
+ tab.state.currentContentEl = previousContentEl;
1858
+ tab.state.currentTextEl = previousTextEl;
1859
+ tab.state.currentTextContent = previousTextContent;
1860
+ tab.state.currentThinkingState = previousThinkingState;
1861
+ tab.renderer?.scrollToBottom();
1862
+ }
1863
+ }
1864
+ }
1865
+
1866
+ export function updatePlanModeUI(tab: TabData, plugin: ClaudianPlugin, mode: string): void {
1867
+ const providerId = getTabProviderId(tab, plugin);
1868
+ const snapshot = getTabSettingsSnapshot(tab, plugin);
1869
+ const uiConfig = ProviderRegistry.getChatUIConfig(providerId);
1870
+ if (uiConfig.applyPermissionMode) {
1871
+ uiConfig.applyPermissionMode(mode, snapshot);
1872
+ } else {
1873
+ snapshot.permissionMode = mode;
1874
+ }
1875
+ ProviderSettingsCoordinator.commitProviderSettingsSnapshot(
1876
+ plugin.settings,
1877
+ providerId,
1878
+ snapshot,
1879
+ );
1880
+ void plugin.saveSettings();
1881
+ tab.ui.permissionToggle?.updateDisplay();
1882
+ tab.dom.inputWrapper.toggleClass(
1883
+ 'claudian-input-plan-mode',
1884
+ mode === 'plan' && getTabCapabilities(tab, plugin).supportsPlanMode,
1885
+ );
1886
+ }