@chatcode/cco-better-sidebar 0.18.0-alpha.0

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 (329) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +554 -0
  3. package/README_EN.md +563 -0
  4. package/assets/logo.svg +1 -0
  5. package/cordis.patch.yml +79 -0
  6. package/lib/client-editor.js +55744 -0
  7. package/lib/client-mermaid.js +208256 -0
  8. package/lib/client-registry.js +30378 -0
  9. package/lib/client-terminal.js +16894 -0
  10. package/lib/client.js +30378 -0
  11. package/lib/index.js +6897 -0
  12. package/lib/invariant.js +22 -0
  13. package/lib/types/agent-opens.d.ts +65 -0
  14. package/lib/types/agent-pty.d.ts +226 -0
  15. package/lib/types/browser-probe.d.ts +15 -0
  16. package/lib/types/bundle-route.d.ts +12 -0
  17. package/lib/types/chatcode-update.d.ts +47 -0
  18. package/lib/types/client/AccountMenu.d.ts +1 -0
  19. package/lib/types/client/AiRatioSection.d.ts +3 -0
  20. package/lib/types/client/BrowserView.d.ts +39 -0
  21. package/lib/types/client/ChatcodeGate.d.ts +6 -0
  22. package/lib/types/client/CodingPlanModelsSettings.d.ts +9 -0
  23. package/lib/types/client/DiffTab.d.ts +6 -0
  24. package/lib/types/client/DiffView.d.ts +51 -0
  25. package/lib/types/client/EditorHost.d.ts +13 -0
  26. package/lib/types/client/FenceErrorNotice.d.ts +5 -0
  27. package/lib/types/client/FileTree.d.ts +41 -0
  28. package/lib/types/client/FreeWindow.d.ts +29 -0
  29. package/lib/types/client/GitView.d.ts +12 -0
  30. package/lib/types/client/MarkdownHtml.d.ts +32 -0
  31. package/lib/types/client/OrphanedTab.d.ts +11 -0
  32. package/lib/types/client/PdfView.d.ts +6 -0
  33. package/lib/types/client/ReleaseNotesDialog.d.ts +11 -0
  34. package/lib/types/client/RenderBoundary.d.ts +32 -0
  35. package/lib/types/client/SandboxStatusBar.d.ts +10 -0
  36. package/lib/types/client/SideCardSection.d.ts +74 -0
  37. package/lib/types/client/Sidebar.d.ts +8 -0
  38. package/lib/types/client/TabBar.d.ts +54 -0
  39. package/lib/types/client/TerminalView.d.ts +24 -0
  40. package/lib/types/client/TextEditor.d.ts +10 -0
  41. package/lib/types/client/TreePanel.d.ts +27 -0
  42. package/lib/types/client/UpdateDialog.d.ts +10 -0
  43. package/lib/types/client/UpdateSettingsPanel.d.ts +1 -0
  44. package/lib/types/client/UpdateSettingsSection.d.ts +10 -0
  45. package/lib/types/client/UploadOverlay.d.ts +20 -0
  46. package/lib/types/client/UsageHeatmapSection.d.ts +4 -0
  47. package/lib/types/client/add-plugin-modal.d.ts +15 -0
  48. package/lib/types/client/api.d.ts +345 -0
  49. package/lib/types/client/binary-download.d.ts +12 -0
  50. package/lib/types/client/breakpoints.d.ts +17 -0
  51. package/lib/types/client/browser.d.ts +60 -0
  52. package/lib/types/client/builtins/index.d.ts +18 -0
  53. package/lib/types/client/builtins/tabs.d.ts +11 -0
  54. package/lib/types/client/builtins/viewers.d.ts +3 -0
  55. package/lib/types/client/chat-presentation.d.ts +13 -0
  56. package/lib/types/client/chunk-loader.d.ts +124 -0
  57. package/lib/types/client/chunks/editor.d.ts +9 -0
  58. package/lib/types/client/chunks/mermaid.d.ts +10 -0
  59. package/lib/types/client/chunks/terminal.d.ts +9 -0
  60. package/lib/types/client/cm-themes.d.ts +24 -0
  61. package/lib/types/client/conversation-draft.d.ts +14 -0
  62. package/lib/types/client/desktop-env.d.ts +35 -0
  63. package/lib/types/client/editor-load.d.ts +66 -0
  64. package/lib/types/client/frame-batcher.d.ts +24 -0
  65. package/lib/types/client/icons.d.ts +66 -0
  66. package/lib/types/client/image-types.d.ts +3 -0
  67. package/lib/types/client/ime-guard.d.ts +36 -0
  68. package/lib/types/client/index.d.ts +19 -0
  69. package/lib/types/client/intercept.d.ts +44 -0
  70. package/lib/types/client/lang.d.ts +14 -0
  71. package/lib/types/client/layout-push.d.ts +15 -0
  72. package/lib/types/client/lazy-chunk.d.ts +25 -0
  73. package/lib/types/client/link-intercept.d.ts +42 -0
  74. package/lib/types/client/locales-ar.d.ts +18 -0
  75. package/lib/types/client/locales-de.d.ts +4 -0
  76. package/lib/types/client/locales-fr.d.ts +10 -0
  77. package/lib/types/client/locales-hi.d.ts +17 -0
  78. package/lib/types/client/locales-id.d.ts +15 -0
  79. package/lib/types/client/locales-it.d.ts +8 -0
  80. package/lib/types/client/locales-ja.d.ts +17 -0
  81. package/lib/types/client/locales-ko.d.ts +9 -0
  82. package/lib/types/client/locales-nl.d.ts +16 -0
  83. package/lib/types/client/locales-pl.d.ts +19 -0
  84. package/lib/types/client/locales-pt.d.ts +1 -0
  85. package/lib/types/client/locales-ru.d.ts +13 -0
  86. package/lib/types/client/locales-sv.d.ts +1 -0
  87. package/lib/types/client/locales-th.d.ts +17 -0
  88. package/lib/types/client/locales-tr.d.ts +17 -0
  89. package/lib/types/client/locales-vi.d.ts +17 -0
  90. package/lib/types/client/locales-zh-HK.d.ts +32 -0
  91. package/lib/types/client/locales-zh-MO.d.ts +32 -0
  92. package/lib/types/client/locales-zh-TW.d.ts +32 -0
  93. package/lib/types/client/locales.d.ts +571 -0
  94. package/lib/types/client/markdown-html.d.ts +96 -0
  95. package/lib/types/client/markdown-images.d.ts +45 -0
  96. package/lib/types/client/markdown-labels.d.ts +21 -0
  97. package/lib/types/client/md-toc.d.ts +4 -0
  98. package/lib/types/client/mermaid-blocks.d.ts +47 -0
  99. package/lib/types/client/mermaid-sanitize.d.ts +2 -0
  100. package/lib/types/client/mermaid.d.ts +12 -0
  101. package/lib/types/client/open-when-sized.d.ts +20 -0
  102. package/lib/types/client/open-with-settings.d.ts +4 -0
  103. package/lib/types/client/open-with.d.ts +87 -0
  104. package/lib/types/client/openpath-intercept.d.ts +52 -0
  105. package/lib/types/client/paths.d.ts +43 -0
  106. package/lib/types/client/pdf-types.d.ts +2 -0
  107. package/lib/types/client/pinned.d.ts +86 -0
  108. package/lib/types/client/plugin-settings.d.ts +8 -0
  109. package/lib/types/client/plugins-shared.d.ts +27 -0
  110. package/lib/types/client/plugins-tabs.d.ts +3 -0
  111. package/lib/types/client/plugins-viewers.d.ts +3 -0
  112. package/lib/types/client/prefs.d.ts +41 -0
  113. package/lib/types/client/produced-files.d.ts +29 -0
  114. package/lib/types/client/selection-payload.d.ts +27 -0
  115. package/lib/types/client/service.d.ts +445 -0
  116. package/lib/types/client/session-pins.d.ts +14 -0
  117. package/lib/types/client/settings-nav-icon.d.ts +27 -0
  118. package/lib/types/client/shell-presets.d.ts +48 -0
  119. package/lib/types/client/split-pane.d.ts +38 -0
  120. package/lib/types/client/state.d.ts +480 -0
  121. package/lib/types/client/tab-content-memo.d.ts +40 -0
  122. package/lib/types/client/terminal-font.d.ts +78 -0
  123. package/lib/types/client/terminal-links.d.ts +119 -0
  124. package/lib/types/client/theme.d.ts +45 -0
  125. package/lib/types/client/titlebar-strip.d.ts +23 -0
  126. package/lib/types/client/ui-tweaks/appicons.d.ts +16 -0
  127. package/lib/types/client/ui-tweaks/archive.d.ts +42 -0
  128. package/lib/types/client/ui-tweaks/branding.d.ts +45 -0
  129. package/lib/types/client/ui-tweaks/cachehit.d.ts +41 -0
  130. package/lib/types/client/ui-tweaks/gitbar.d.ts +103 -0
  131. package/lib/types/client/ui-tweaks/index.d.ts +279 -0
  132. package/lib/types/client/ui-tweaks/mcp.d.ts +28 -0
  133. package/lib/types/client/ui-tweaks/notifier.d.ts +125 -0
  134. package/lib/types/client/ui-tweaks/whale.d.ts +55 -0
  135. package/lib/types/client/upload.d.ts +69 -0
  136. package/lib/types/client/useChatcodeAccount.d.ts +10 -0
  137. package/lib/types/client/wco.d.ts +47 -0
  138. package/lib/types/codingplan.d.ts +37 -0
  139. package/lib/types/config.d.ts +95 -0
  140. package/lib/types/context-types.d.ts +540 -0
  141. package/lib/types/fs-operations.d.ts +30 -0
  142. package/lib/types/fs-search.d.ts +24 -0
  143. package/lib/types/fs-tree.d.ts +51 -0
  144. package/lib/types/git.d.ts +124 -0
  145. package/lib/types/html-route.d.ts +59 -0
  146. package/lib/types/index.d.ts +47 -0
  147. package/lib/types/invariant.d.ts +15 -0
  148. package/lib/types/login-route.d.ts +2 -0
  149. package/lib/types/open-external.d.ts +24 -0
  150. package/lib/types/path-security.d.ts +26 -0
  151. package/lib/types/prefs-shared.d.ts +257 -0
  152. package/lib/types/pty-deps.d.ts +79 -0
  153. package/lib/types/pty-manager.d.ts +168 -0
  154. package/lib/types/tools.d.ts +34 -0
  155. package/lib/types/trust-fence.d.ts +24 -0
  156. package/lib/types/ui-tweaks/archive.d.ts +71 -0
  157. package/lib/types/ui-tweaks/config.d.ts +186 -0
  158. package/lib/types/ui-tweaks/git-web.d.ts +43 -0
  159. package/lib/types/ui-tweaks/git.d.ts +311 -0
  160. package/lib/types/ui-tweaks/index.d.ts +17 -0
  161. package/lib/types/ui-tweaks/mcp.d.ts +103 -0
  162. package/lib/types/ui-tweaks/turn-outcome.d.ts +57 -0
  163. package/lib/types/ui-tweaks/web.d.ts +55 -0
  164. package/lib/types/wire.d.ts +38 -0
  165. package/package.json +213 -0
  166. package/scripts/install.ps1 +310 -0
  167. package/scripts/install.sh +287 -0
  168. package/src/agent-opens.ts +265 -0
  169. package/src/agent-pty.ts +527 -0
  170. package/src/browser-probe.ts +26 -0
  171. package/src/bundle-route.ts +129 -0
  172. package/src/chatcode-update.ts +193 -0
  173. package/src/client/AccountMenu.tsx +177 -0
  174. package/src/client/AiRatioSection.module.css +329 -0
  175. package/src/client/AiRatioSection.tsx +292 -0
  176. package/src/client/BrowserView.tsx +289 -0
  177. package/src/client/ChatcodeGate.tsx +35 -0
  178. package/src/client/CodingPlanModelsSettings.module.css +118 -0
  179. package/src/client/CodingPlanModelsSettings.tsx +101 -0
  180. package/src/client/DiffTab.tsx +114 -0
  181. package/src/client/DiffView.tsx +305 -0
  182. package/src/client/EditorHost.tsx +563 -0
  183. package/src/client/FenceErrorNotice.tsx +40 -0
  184. package/src/client/FileTree.tsx +701 -0
  185. package/src/client/FreeWindow.tsx +337 -0
  186. package/src/client/GitView.tsx +722 -0
  187. package/src/client/MarkdownHtml.tsx +296 -0
  188. package/src/client/OrphanedTab.tsx +27 -0
  189. package/src/client/PdfView.tsx +110 -0
  190. package/src/client/ReleaseNotesDialog.tsx +91 -0
  191. package/src/client/RenderBoundary.tsx +49 -0
  192. package/src/client/SandboxStatusBar.tsx +60 -0
  193. package/src/client/SideCardSection.module.css +932 -0
  194. package/src/client/SideCardSection.tsx +1169 -0
  195. package/src/client/Sidebar.tsx +1857 -0
  196. package/src/client/TabBar.tsx +365 -0
  197. package/src/client/TerminalView.tsx +424 -0
  198. package/src/client/TextEditor.tsx +468 -0
  199. package/src/client/TreePanel.tsx +279 -0
  200. package/src/client/UpdateDialog.tsx +186 -0
  201. package/src/client/UpdateSettingsPanel.tsx +29 -0
  202. package/src/client/UpdateSettingsSection.tsx +22 -0
  203. package/src/client/UploadOverlay.tsx +62 -0
  204. package/src/client/UsageHeatmapSection.module.css +440 -0
  205. package/src/client/UsageHeatmapSection.tsx +301 -0
  206. package/src/client/add-plugin-modal.tsx +199 -0
  207. package/src/client/api.ts +433 -0
  208. package/src/client/binary-download.tsx +23 -0
  209. package/src/client/breakpoints.ts +61 -0
  210. package/src/client/browser.ts +167 -0
  211. package/src/client/builtins/index.ts +37 -0
  212. package/src/client/builtins/tabs.tsx +275 -0
  213. package/src/client/builtins/viewers.tsx +125 -0
  214. package/src/client/chat-presentation.ts +95 -0
  215. package/src/client/chunk-loader.ts +350 -0
  216. package/src/client/chunks/editor.tsx +9 -0
  217. package/src/client/chunks/mermaid.tsx +10 -0
  218. package/src/client/chunks/terminal.tsx +9 -0
  219. package/src/client/cm-themes.ts +129 -0
  220. package/src/client/conversation-draft.ts +29 -0
  221. package/src/client/css-modules.d.ts +5 -0
  222. package/src/client/desktop-env.ts +72 -0
  223. package/src/client/editor-load.ts +92 -0
  224. package/src/client/frame-batcher.ts +56 -0
  225. package/src/client/icons.tsx +170 -0
  226. package/src/client/image-types.ts +8 -0
  227. package/src/client/ime-guard.ts +47 -0
  228. package/src/client/index.tsx +501 -0
  229. package/src/client/intercept.tsx +172 -0
  230. package/src/client/lang.ts +225 -0
  231. package/src/client/layout-push.ts +37 -0
  232. package/src/client/layout.css +156 -0
  233. package/src/client/lazy-chunk.tsx +89 -0
  234. package/src/client/link-intercept.ts +73 -0
  235. package/src/client/locales-ar.ts +387 -0
  236. package/src/client/locales-de.ts +372 -0
  237. package/src/client/locales-fr.ts +379 -0
  238. package/src/client/locales-hi.ts +386 -0
  239. package/src/client/locales-id.ts +384 -0
  240. package/src/client/locales-it.ts +377 -0
  241. package/src/client/locales-ja.ts +486 -0
  242. package/src/client/locales-ko.ts +378 -0
  243. package/src/client/locales-nl.ts +384 -0
  244. package/src/client/locales-pl.ts +388 -0
  245. package/src/client/locales-pt.ts +369 -0
  246. package/src/client/locales-ru.ts +384 -0
  247. package/src/client/locales-sv.ts +369 -0
  248. package/src/client/locales-th.ts +386 -0
  249. package/src/client/locales-tr.ts +386 -0
  250. package/src/client/locales-vi.ts +386 -0
  251. package/src/client/locales-zh-HK.ts +401 -0
  252. package/src/client/locales-zh-MO.ts +401 -0
  253. package/src/client/locales-zh-TW.ts +401 -0
  254. package/src/client/locales.ts +1229 -0
  255. package/src/client/markdown-html.ts +331 -0
  256. package/src/client/markdown-images.ts +137 -0
  257. package/src/client/markdown-labels.tsx +31 -0
  258. package/src/client/md-toc.tsx +127 -0
  259. package/src/client/mermaid-blocks.ts +110 -0
  260. package/src/client/mermaid-sanitize.ts +93 -0
  261. package/src/client/mermaid.tsx +406 -0
  262. package/src/client/open-when-sized.ts +43 -0
  263. package/src/client/open-with-settings.tsx +135 -0
  264. package/src/client/open-with.ts +221 -0
  265. package/src/client/openpath-intercept.ts +79 -0
  266. package/src/client/paths.ts +63 -0
  267. package/src/client/pdf-types.ts +4 -0
  268. package/src/client/pinned.ts +184 -0
  269. package/src/client/plugin-settings.ts +43 -0
  270. package/src/client/plugins-shared.ts +29 -0
  271. package/src/client/plugins-tabs.ts +153 -0
  272. package/src/client/plugins-viewers.ts +44 -0
  273. package/src/client/prefs.ts +216 -0
  274. package/src/client/produced-files.ts +120 -0
  275. package/src/client/selection-payload.ts +86 -0
  276. package/src/client/service.ts +873 -0
  277. package/src/client/session-pins.tsx +334 -0
  278. package/src/client/settings-nav-icon.ts +55 -0
  279. package/src/client/shell-presets.ts +83 -0
  280. package/src/client/sidebar.module.css +3607 -0
  281. package/src/client/split-pane.tsx +333 -0
  282. package/src/client/state.ts +1646 -0
  283. package/src/client/tab-content-memo.ts +55 -0
  284. package/src/client/terminal-font.ts +224 -0
  285. package/src/client/terminal-links.ts +194 -0
  286. package/src/client/theme.ts +110 -0
  287. package/src/client/titlebar-strip.ts +37 -0
  288. package/src/client/ui-tweaks/appicons.ts +24 -0
  289. package/src/client/ui-tweaks/archive.tsx +281 -0
  290. package/src/client/ui-tweaks/branding.tsx +232 -0
  291. package/src/client/ui-tweaks/cachehit.tsx +122 -0
  292. package/src/client/ui-tweaks/gitbar.tsx +2559 -0
  293. package/src/client/ui-tweaks/index.tsx +1696 -0
  294. package/src/client/ui-tweaks/mcp.tsx +582 -0
  295. package/src/client/ui-tweaks/notifier.ts +514 -0
  296. package/src/client/ui-tweaks/whale.tsx +171 -0
  297. package/src/client/upload.ts +187 -0
  298. package/src/client/useChatcodeAccount.ts +103 -0
  299. package/src/client/wco.ts +120 -0
  300. package/src/codingplan.ts +146 -0
  301. package/src/config.ts +207 -0
  302. package/src/context-types.ts +559 -0
  303. package/src/fs-operations.ts +97 -0
  304. package/src/fs-search.ts +113 -0
  305. package/src/fs-tree.ts +158 -0
  306. package/src/git.ts +446 -0
  307. package/src/html-route.ts +106 -0
  308. package/src/index.ts +1524 -0
  309. package/src/invariant.ts +32 -0
  310. package/src/login-route.ts +26 -0
  311. package/src/open-external.ts +90 -0
  312. package/src/path-security.ts +81 -0
  313. package/src/prefs-shared.ts +303 -0
  314. package/src/pty-deps.ts +241 -0
  315. package/src/pty-manager.ts +376 -0
  316. package/src/tools.ts +482 -0
  317. package/src/trust-fence.ts +84 -0
  318. package/src/ui-tweaks/archive.ts +293 -0
  319. package/src/ui-tweaks/config.ts +257 -0
  320. package/src/ui-tweaks/git-web.ts +421 -0
  321. package/src/ui-tweaks/git.ts +1524 -0
  322. package/src/ui-tweaks/index.ts +61 -0
  323. package/src/ui-tweaks/mcp.ts +535 -0
  324. package/src/ui-tweaks/turn-outcome.ts +105 -0
  325. package/src/ui-tweaks/web.ts +225 -0
  326. package/src/wire.ts +110 -0
  327. package/vendor/addon-fit.js +2 -0
  328. package/vendor/xterm.css +218 -0
  329. package/vendor/xterm.js +5 -0
@@ -0,0 +1,582 @@
1
+ /**
2
+ * dsh-ui-tweaks — MCP manager (browser half).
3
+ *
4
+ * Renders the "MCP 管理" settings section (usable when the mcpManagerEnabled
5
+ * toggle in 界面调整 is on — otherwise it shows an invite card). The section
6
+ * lists every configured MCP server with its status (active / failed / loading
7
+ * / stopped / disabled), command / url, env names, registered tools, and
8
+ * offers per-server actions: **重启** (runtime reload), **启用/停用**,
9
+ * **编辑**, and **删除** (two-click confirm). An **添加服务器** button opens
10
+ * the editor, which supports BOTH a structured **表单** and a raw **YAML**
11
+ * mode (the config is validated on the server before the profile's
12
+ * `cordis.patch.yml` is rewritten; DSH's patch watcher then hot-reloads the
13
+ * loader so the server starts/stops live).
14
+ * @module dsh-ui-tweaks/client/mcp
15
+ */
16
+
17
+ import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
18
+ import { createPortal } from 'react-dom'
19
+ import type { SettingsClient } from './index.tsx'
20
+
21
+ /** Route matching the host half (src/mcp.ts). */
22
+ const MCP_ROUTE = '/_dsh/ui-tweaks/mcp'
23
+
24
+ /** Locale keys the MCP manager reads off the `ui-tweaks` dictionary. */
25
+ type McpLabelKey =
26
+ | 'mcpTitle' | 'mcpEmpty' | 'mcpStatusActive' | 'mcpStatusFailed' | 'mcpStatusLoading'
27
+ | 'mcpStatusStopped' | 'mcpStatusDisabled' | 'mcpTools' | 'mcpEnv' | 'mcpUnavailable'
28
+ | 'mcpDisabledHint' | 'mcpEnable' | 'mcpServerDetail' | 'mcpAdd' | 'mcpAddTitle' | 'mcpEditTitle'
29
+ | 'mcpEdit' | 'mcpDelete' | 'mcpEnabledAction' | 'mcpDisabledAction' | 'mcpSaved' | 'mcpRemoved'
30
+ | 'mcpFormTab' | 'mcpYamlTab' | 'mcpYamlHint' | 'mcpYamlPlaceholder' | 'mcpFieldId'
31
+ | 'mcpFieldIdHint' | 'mcpFieldName' | 'mcpFieldNameHint' | 'mcpFieldType' | 'mcpFieldTimeout'
32
+ | 'mcpFieldCommand' | 'mcpFieldCommandPlaceholder' | 'mcpFieldArgs' | 'mcpFieldEnv'
33
+ | 'mcpFieldUrl' | 'mcpFieldHeaders' | 'mcpFieldEnabled' | 'mcpSave' | 'mcpCancel'
34
+ | 'mcpInvalidId' | 'mcpInvalidName' | 'mcpInvalidTimeout' | 'mcpInvalidUrl' | 'mcpInvalidCommand'
35
+ | 'unavailable' | 'loading'
36
+
37
+ type Translate = (key: McpLabelKey) => string
38
+
39
+ interface ApiSuccess<T> { ok: true; value: T }
40
+ interface ApiFailure { ok: false; error: { code: string; message: string } }
41
+
42
+ interface McpServerView {
43
+ id: string
44
+ serverName: string
45
+ transport: string
46
+ command?: string
47
+ args: string[]
48
+ url?: string
49
+ headers: Record<string, string>
50
+ env: Record<string, string>
51
+ toolCallTimeoutMs?: number
52
+ /** The server's config rendered as YAML (prefills the YAML editor). */
53
+ yaml: string
54
+ disabled: boolean
55
+ status: 'disabled' | 'stopped' | 'active' | 'failed' | 'loading'
56
+ toolCount: number
57
+ tools: string[]
58
+ }
59
+
60
+ interface McpSnapshot {
61
+ servers: McpServerView[]
62
+ }
63
+
64
+ async function mcpRequest<T>(init?: RequestInit): Promise<T> {
65
+ const response = await fetch(MCP_ROUTE, { credentials: 'same-origin', ...init })
66
+ let body: ApiSuccess<T> | ApiFailure | undefined
67
+ try {
68
+ body = await response.json() as ApiSuccess<T> | ApiFailure
69
+ } catch {
70
+ body = undefined
71
+ }
72
+ if (!response.ok || body === undefined || !body.ok) {
73
+ const failure = body as ApiFailure | undefined
74
+ const message = body === undefined
75
+ ? `MCP route unavailable (HTTP ${response.status}; restart ChatCode CLI to load the plugin server code)`
76
+ : failure?.error?.message ?? `UI Tweaks MCP request failed with HTTP ${response.status}`
77
+ const error = new Error(message) as Error & { code: string | undefined }
78
+ error.code = failure?.error?.code
79
+ throw error
80
+ }
81
+ return body.value
82
+ }
83
+
84
+ /** Split a textarea into one trimmed entry per non-empty line. */
85
+ function linesToArray(text: string): string[] {
86
+ return text.split(/\r?\n/u).map(line => line.trim()).filter(line => line.length > 0)
87
+ }
88
+
89
+ /** Parse `KEY=value` (or `Key: value`) lines into a record; throws on malformed lines. */
90
+ function linesToRecord(text: string, separator: '=' | ':'): Record<string, string> {
91
+ const out: Record<string, string> = {}
92
+ for (const line of text.split(/\r?\n/u)) {
93
+ const trimmed = line.trim()
94
+ if (trimmed.length === 0) continue
95
+ const at = trimmed.indexOf(separator)
96
+ if (at <= 0) throw new Error(`格式不正确:${trimmed}`)
97
+ const key = trimmed.slice(0, at).trim()
98
+ const value = trimmed.slice(at + 1).trim()
99
+ if (key.length === 0) throw new Error(`格式不正确:${trimmed}`)
100
+ out[key] = value
101
+ }
102
+ return out
103
+ }
104
+
105
+ function recordToLines(record: Record<string, string>, separator: '=' | ':'): string {
106
+ return Object.entries(record).map(([key, value]) => `${key}${separator}${value}`).join('\n')
107
+ }
108
+
109
+ export const MCP_CSS = `
110
+ .dut-mcp{display:grid;gap:12px;max-width:680px;padding:6px 2px 36px}
111
+ .dut-mcp-head{display:flex;align-items:center;gap:10px;padding:14px 16px 0}
112
+ .dut-mcp-head h2{margin:0;font-size:15px;font-weight:600;letter-spacing:-.01em;color:var(--dsw-alias-label-primary)}
113
+ .dut-mcp-count{font-size:11.5px;color:var(--dsw-alias-label-secondary);padding:2px 8px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1)}
114
+ .dut-mcp-spacer{flex:1}
115
+ .dut-mcp-btn{display:inline-flex;align-items:center;height:28px;padding:0 12px;border-radius:999px;border:1px solid var(--dsw-alias-border-l1);background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer;transition:background .15s ease,color .15s ease,border-color .15s ease}
116
+ .dut-mcp-btn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}
117
+ .dut-mcp-btn:disabled{opacity:.5;cursor:default}
118
+ .dut-mcp-btn.dut-mcp-del:hover{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 55%,transparent);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}
119
+ .dut-mcp-btn.dut-mcp-confirm{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 55%,transparent);color:var(--dsw-alias-state-error-primary);font-weight:600}
120
+ .dut-mcp-body{display:grid;gap:2px;margin-top:4px}
121
+ .dut-mcp-row{display:grid;gap:6px;padding:10px 16px;border-radius:12px}
122
+ .dut-mcp-row:hover{background:var(--dsw-alias-interactive-bg-hover)}
123
+ .dut-mcp-row-main{display:flex;align-items:center;gap:8px;min-width:0}
124
+ .dut-mcp-name{font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary);white-space:nowrap;text-overflow:ellipsis;overflow:hidden}
125
+ .dut-mcp-badge{flex:none;font-size:11px;line-height:1;padding:4px 9px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary)}
126
+ .dut-mcp-badge.dut-mcp-ok{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent);border-color:transparent;color:var(--dsw-alias-state-success-primary)}
127
+ .dut-mcp-badge.dut-mcp-err{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent);border-color:transparent;color:var(--dsw-alias-state-error-primary)}
128
+ .dut-mcp-badge.dut-mcp-warn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 14%,transparent);border-color:transparent;color:var(--dsw-alias-state-warn-label)}
129
+ .dut-mcp-sub{font-size:11.5px;color:var(--dsw-alias-label-secondary);white-space:nowrap;text-overflow:ellipsis;overflow:hidden}
130
+ .dut-mcp-toggle{flex:none;font-size:11.5px;color:var(--dsw-alias-label-tertiary);background:transparent;border:none;padding:0;cursor:pointer;text-align:left}
131
+ .dut-mcp-toggle:hover{color:var(--dsw-alias-label-primary)}
132
+ .dut-mcp-tools{margin-left:2px;display:flex;flex-wrap:wrap;gap:4px}
133
+ .dut-mcp-tool{font-size:11px;line-height:1;padding:3px 8px;border-radius:999px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);color:var(--dsw-alias-label-secondary)}
134
+ .dut-mcp-note{margin:2px 16px 0;padding:8px 12px;border-radius:10px;font-size:11.5px;line-height:1.55;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1)}
135
+ .dut-mcp-empty{padding:32px 16px;text-align:center;font-size:12.5px;color:var(--dsw-alias-label-secondary)}
136
+ .dut-mcp-alert{margin:8px 16px 0;padding:8px 12px;border-radius:10px;font-size:12px;line-height:1.5;background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}
137
+ .dut-mcp-notice{margin:8px 16px 0;padding:8px 12px;border-radius:10px;font-size:12px;line-height:1.5;background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 12%,transparent);color:var(--dsw-alias-state-success-primary)}
138
+ .dut-mcp-off{padding:20px 16px;display:grid;gap:12px}
139
+ .dut-mcp-off p{margin:0;font-size:12.5px;line-height:1.6;color:var(--dsw-alias-label-secondary)}
140
+ .dut-mcp-overlay{z-index:1100;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}
141
+ .dut-mcp-mask{background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur);position:absolute;inset:0}
142
+ .dut-mcp-panel{z-index:1;background:var(--dsw-alias-bg-layer-2);box-sizing:border-box;width:720px;max-width:calc(100vw - 48px);height:min(700px,100vh - 48px);box-shadow:var(--dsw-shadow-lv3);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border-radius:20px;display:flex;flex-direction:column;overflow:hidden}
143
+ .dut-mcp-panel-head{box-sizing:border-box;flex:none;display:flex;align-items:center;gap:10px;height:52px;padding:0 12px 0 20px}
144
+ .dut-mcp-panel-head h2{margin:0;font-size:16px;font-weight:600;letter-spacing:-.01em;color:var(--dsw-alias-label-primary)}
145
+ .dut-mcp-panel-close{cursor:pointer;width:28px;height:28px;color:var(--dsw-alias-label-primary);background:transparent;border:none;border-radius:28px;display:inline-flex;justify-content:center;align-items:center;padding:0;font-size:13px}
146
+ .dut-mcp-panel-close:hover{background:var(--dsw-alias-interactive-bg-hover)}
147
+ .dut-mcp-tabs{flex:none;display:inline-flex;padding:3px;gap:3px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-layer-2);margin:0 20px 10px;align-self:flex-start}
148
+ .dut-mcp-tabs button{border:none;border-radius:7px;padding:5px 14px;background:transparent;color:var(--dsw-alias-label-secondary);font:inherit;font-size:12.5px;cursor:pointer}
149
+ .dut-mcp-tabs button.dut-mcp-tab-on{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,transparent);color:var(--dsw-alias-state-business-primary);font-weight:600}
150
+ .dut-mcp-form{flex:1;min-height:0;overflow-y:auto;padding:0 20px 20px;display:grid;gap:12px;align-content:start}
151
+ .dut-mcp-field{display:grid;gap:6px}
152
+ .dut-mcp-field>span{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary)}
153
+ .dut-mcp-field small{font-size:11px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
154
+ .dut-mcp-input{box-sizing:border-box;width:100%;height:34px;padding:0 10px;border-radius:9px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-size:13px}
155
+ .dut-mcp-input:focus{outline:none;border-color:color-mix(in srgb,var(--dsw-alias-state-business-primary) 55%,transparent)}
156
+ .dut-mcp-input:disabled{opacity:.55;cursor:not-allowed}
157
+ textarea.dut-mcp-input{height:auto;min-height:72px;padding:8px 10px;line-height:1.5;resize:vertical;font-family:var(--dsw-font-markdown-code-block-font-family, Consolas, monospace);font-size:12px}
158
+ textarea.dut-mcp-input-lg{min-height:120px}
159
+ textarea.dut-mcp-yaml{min-height:320px}
160
+ .dut-mcp-row2{display:grid;grid-template-columns:1fr 1fr;gap:12px;align-items:start}
161
+ .dut-mcp-field .dut-seg{width:fit-content}
162
+ .dut-mcp-row2 .dut-seg{height:34px;box-sizing:border-box;align-items:center}
163
+ .dut-mcp-row2 .dut-seg button{flex:none;white-space:nowrap;padding:0 16px;height:26px}
164
+ .dut-mcp-foot{flex:none;display:flex;align-items:center;gap:8px;justify-content:flex-end;padding:12px 20px 16px;border-top:1px solid var(--dsw-alias-border-l1)}
165
+ .dut-mcp-error{margin:0 20px 10px;padding:8px 12px;border-radius:10px;font-size:12px;line-height:1.5;background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent);color:var(--dsw-alias-state-error-primary)}
166
+ `
167
+
168
+ /** Install the MCP stylesheet once (idempotent); returns the disposer. */
169
+ export function installMcpStyles(): () => void {
170
+ const id = 'dsh-ui-tweaks-mcp'
171
+ const existing = document.querySelector(`style[data-plugin-css="${id}"]`)
172
+ if (existing !== null) return () => {}
173
+ const style = document.createElement('style')
174
+ style.dataset.plugin = 'dsh-ui-tweaks'
175
+ style.dataset.pluginCss = id
176
+ style.textContent = MCP_CSS
177
+ document.head.appendChild(style)
178
+ return () => { style.remove() }
179
+ }
180
+
181
+ function statusLabel(status: McpServerView['status'], t: Translate): string {
182
+ switch (status) {
183
+ case 'active': return t('mcpStatusActive')
184
+ case 'failed': return t('mcpStatusFailed')
185
+ case 'loading': return t('mcpStatusLoading')
186
+ case 'stopped': return t('mcpStatusStopped')
187
+ case 'disabled': return t('mcpStatusDisabled')
188
+ }
189
+ }
190
+
191
+ function statusClass(status: McpServerView['status']): string {
192
+ switch (status) {
193
+ case 'active': return ' dut-mcp-ok'
194
+ case 'failed': return ' dut-mcp-err'
195
+ case 'loading': return ' dut-mcp-warn'
196
+ default: return ''
197
+ }
198
+ }
199
+
200
+ export interface McpSectionProps {
201
+ controller: SettingsClient
202
+ t: Translate
203
+ }
204
+
205
+ export function McpSection({ controller, t }: McpSectionProps) {
206
+ const settingsState = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot)
207
+ const enabled = settingsState.value?.mcpManagerEnabled === true
208
+ const [servers, setServers] = useState<McpServerView[] | null>(null)
209
+ const [busy, setBusy] = useState<string | null>(null)
210
+ const [confirmId, setConfirmId] = useState<string | null>(null)
211
+ const [error, setError] = useState<string | null>(null)
212
+ const [notice, setNotice] = useState<string | null>(null)
213
+ const [expanded, setExpanded] = useState<string | null>(null)
214
+ const [editing, setEditing] = useState<McpServerView | 'new' | null>(null)
215
+ const generation = useMemo(() => ({ current: 0 }), [])
216
+
217
+ const load = (): void => {
218
+ const gen = ++generation.current
219
+ setError(null)
220
+ void mcpRequest<McpSnapshot>().then((snapshot) => {
221
+ if (gen !== generation.current) return
222
+ setServers(snapshot.servers)
223
+ }).catch((reason: unknown) => {
224
+ if (gen !== generation.current) return
225
+ setError(reason instanceof Error ? reason.message : String(reason))
226
+ setServers([])
227
+ })
228
+ }
229
+
230
+ useEffect(() => {
231
+ if (!enabled) return
232
+ load()
233
+ // eslint-disable-next-line react-hooks/exhaustive-deps
234
+ }, [enabled])
235
+
236
+ const run = (key: string, body: unknown, after?: () => void): void => {
237
+ setBusy(key)
238
+ setError(null)
239
+ setNotice(null)
240
+ void mcpRequest<McpSnapshot>({
241
+ method: 'POST',
242
+ headers: { 'Content-Type': 'application/json' },
243
+ body: JSON.stringify(body),
244
+ }).then(() => { after?.() }).catch((reason: unknown) => {
245
+ setError(reason instanceof Error ? reason.message : String(reason))
246
+ }).finally(() => { setBusy(current => (current === key ? null : current)) })
247
+ }
248
+
249
+ /** Re-fetch the list every `interval` ms until `stop` returns true (or attempts run out).
250
+ * The DSH patch watcher applies changes asynchronously and an MCP server takes
251
+ * time to spawn/connect, so a single delayed reload can miss the transition. */
252
+ const pollAfter = (stop: (servers: McpServerView[]) => boolean, attempts = 12, interval = 1000): void => {
253
+ let left = attempts
254
+ const tick = (): void => {
255
+ const gen = ++generation.current
256
+ void mcpRequest<McpSnapshot>().then((snapshot) => {
257
+ if (gen !== generation.current) return
258
+ setServers(snapshot.servers)
259
+ left -= 1
260
+ if (left <= 0 || stop(snapshot.servers)) return
261
+ window.setTimeout(tick, interval)
262
+ }).catch(() => {
263
+ left -= 1
264
+ if (left > 0) window.setTimeout(tick, interval)
265
+ })
266
+ }
267
+ tick()
268
+ }
269
+
270
+ const remove = (id: string): void => {
271
+ if (confirmId !== id) {
272
+ setConfirmId(id)
273
+ window.setTimeout(() => { setConfirmId(current => (current === id ? null : current)) }, 3000)
274
+ return
275
+ }
276
+ setConfirmId(null)
277
+ run(`remove:${id}`, { action: 'remove', id }, () => {
278
+ setNotice(t('mcpRemoved'))
279
+ pollAfter(servers => !servers.some(server => server.id === id), 10)
280
+ })
281
+ }
282
+
283
+ const setEnabled = (id: string, enabled: boolean): void => {
284
+ run(`set-enabled:${id}`, { action: 'set-enabled', id, enabled }, () => {
285
+ // Keep refreshing until the target reaches a TERMINAL status. The
286
+ // `disabled` flag flips within ~1s, but the MCP spawn + connect takes
287
+ // longer — stopping at the flag would freeze the row mid-transition.
288
+ pollAfter(servers => {
289
+ const target = servers.find(server => server.id === id)
290
+ if (target === undefined) return true
291
+ return enabled
292
+ ? target.status === 'active' || target.status === 'failed'
293
+ : target.status === 'disabled'
294
+ }, 45, 1000)
295
+ })
296
+ }
297
+
298
+ if (!enabled) {
299
+ return (
300
+ <div className="dut-mcp">
301
+ <div className="dut-panel dut-mcp-off">
302
+ <p>{t('mcpDisabledHint')}</p>
303
+ <div>
304
+ <button type="button" className="dut-btn dut-btn-active" onClick={() => {
305
+ void controller.set('mcpManagerEnabled', true).catch(() => { setError(t('unavailable')) })
306
+ }}>{t('mcpEnable')}</button>
307
+ </div>
308
+ </div>
309
+ </div>
310
+ )
311
+ }
312
+
313
+ const list = servers ?? []
314
+ return (
315
+ <div className="dut-mcp">
316
+ <div className="dut-panel">
317
+ <div className="dut-mcp-head">
318
+ <h2>{t('mcpTitle')}</h2>
319
+ <span className="dut-mcp-count">{list.length} · {list.filter(s => s.status === 'active').length} {t('mcpStatusActive')}</span>
320
+ <span className="dut-mcp-spacer" />
321
+ <button type="button" className="dut-mcp-btn" onClick={() => { setEditing('new') }}>{t('mcpAdd')}</button>
322
+ </div>
323
+ <div className="dut-mcp-note">{t('mcpServerDetail')}</div>
324
+ {error !== null ? <div className="dut-mcp-alert">{error}</div> : null}
325
+ {notice !== null ? <div className="dut-mcp-notice">{notice}</div> : null}
326
+ <div className="dut-mcp-body">
327
+ {servers === null ? (
328
+ <div className="dut-mcp-empty">{t('loading')}</div>
329
+ ) : list.length === 0 ? (
330
+ <div className="dut-mcp-empty">{t('mcpEmpty')}</div>
331
+ ) : list.map(server => {
332
+ const command = server.url !== undefined ? server.url : [server.command, ...server.args].filter(Boolean).join(' ')
333
+ const open = expanded === server.id
334
+ return (
335
+ <div className="dut-mcp-row" key={server.id}>
336
+ <div className="dut-mcp-row-main">
337
+ <span className="dut-mcp-name">{server.serverName}</span>
338
+ <span className={'dut-mcp-badge' + statusClass(server.status)}>{statusLabel(server.status, t)}</span>
339
+ <span className="dut-mcp-sub">· {server.toolCount} {t('mcpTools')}</span>
340
+ <span className="dut-mcp-spacer" />
341
+ <button type="button" className="dut-mcp-btn" disabled={busy !== null} onClick={() => { setEnabled(server.id, server.disabled) }}>{server.disabled ? t('mcpEnabledAction') : t('mcpDisabledAction')}</button>
342
+ <button type="button" className="dut-mcp-btn" disabled={busy !== null} onClick={() => { setEditing(server) }}>{t('mcpEdit')}</button>
343
+ <button
344
+ type="button"
345
+ className={'dut-mcp-btn dut-mcp-del' + (confirmId === server.id ? ' dut-mcp-confirm' : '')}
346
+ disabled={busy !== null}
347
+ onClick={() => { remove(server.id) }}
348
+ >
349
+ {confirmId === server.id ? t('mcpDelete') + '?' : t('mcpDelete')}
350
+ </button>
351
+ </div>
352
+ <div className="dut-mcp-sub" title={command}>{server.id} · {server.transport} · {command}</div>
353
+ <button
354
+ type="button"
355
+ className="dut-mcp-toggle"
356
+ onClick={() => { setExpanded(open ? null : server.id) }}
357
+ >
358
+ {open ? '▾ ' : '▸ '}{server.toolCount} {t('mcpTools')}{server.env && Object.keys(server.env).length > 0 ? ` · ${t('mcpEnv')}: ${Object.keys(server.env).join(', ')}` : ''}
359
+ </button>
360
+ {open ? (
361
+ <div className="dut-mcp-tools">
362
+ {server.tools.length === 0 ? <span className="dut-mcp-sub">—</span> : server.tools.map(tool => <span className="dut-mcp-tool" key={tool}>{tool}</span>)}
363
+ </div>
364
+ ) : null}
365
+ </div>
366
+ )
367
+ })}
368
+ </div>
369
+ </div>
370
+ {editing !== null ? (
371
+ <McpEditor
372
+ key={editing === 'new' ? 'new' : editing.id}
373
+ initial={editing === 'new' ? null : editing}
374
+ t={t}
375
+ onClose={() => { setEditing(null) }}
376
+ onSaved={() => { setEditing(null); setNotice(t('mcpSaved')); pollAfter(() => false, 10) }}
377
+ />
378
+ ) : null}
379
+ </div>
380
+ )
381
+ }
382
+
383
+ interface McpEditorProps {
384
+ initial: McpServerView | null
385
+ t: Translate
386
+ onClose: () => void
387
+ onSaved: () => void
388
+ }
389
+
390
+ function McpEditor({ initial, t, onClose, onSaved }: McpEditorProps) {
391
+ const isEdit = initial !== null
392
+ const [mode, setMode] = useState<'form' | 'yaml'>('form')
393
+ const [id, setId] = useState(initial?.id ?? '')
394
+ const [serverName, setServerName] = useState(initial?.serverName ?? '')
395
+ const [transport, setTransport] = useState<'stdio' | 'streamable-http'>(initial?.transport === 'streamable-http' ? 'streamable-http' : 'stdio')
396
+ const [timeoutText, setTimeoutText] = useState(initial?.toolCallTimeoutMs !== undefined ? String(initial.toolCallTimeoutMs) : '')
397
+ const [command, setCommand] = useState(initial?.command ?? '')
398
+ const [argsText, setArgsText] = useState(initial ? initial.args.join('\n') : '')
399
+ const [envText, setEnvText] = useState(initial ? recordToLines(initial.env, '=') : '')
400
+ const [url, setUrl] = useState(initial?.url ?? '')
401
+ const [headersText, setHeadersText] = useState(initial ? recordToLines(initial.headers, ':') : '')
402
+ const [disabled, setDisabled] = useState(initial?.disabled ?? false)
403
+ const [yamlText, setYamlText] = useState(initial?.yaml ?? '')
404
+ const [error, setError] = useState<string | null>(null)
405
+ const [busy, setBusy] = useState(false)
406
+
407
+ useEffect(() => {
408
+ const onKeyDown = (event: KeyboardEvent): void => {
409
+ if (event.key === 'Escape') onClose()
410
+ }
411
+ document.addEventListener('keydown', onKeyDown)
412
+ return () => { document.removeEventListener('keydown', onKeyDown) }
413
+ }, [onClose])
414
+
415
+ const validateForm = (): string | null => {
416
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(id)) return t('mcpInvalidId')
417
+ if (!/^[A-Za-z0-9_-]{1,32}$/u.test(serverName)) return t('mcpInvalidName')
418
+ if (timeoutText !== '' && (!/^\d+$/u.test(timeoutText) || Number(timeoutText) <= 0)) return t('mcpInvalidTimeout')
419
+ if (transport === 'streamable-http') {
420
+ if (!/^https?:\/\//u.test(url)) return t('mcpInvalidUrl')
421
+ } else if (command.trim().length === 0) {
422
+ return t('mcpInvalidCommand')
423
+ }
424
+ return null
425
+ }
426
+
427
+ const save = (): void => {
428
+ setError(null)
429
+ setBusy(true)
430
+ let payload: unknown
431
+ if (mode === 'yaml') {
432
+ payload = { action: 'save', server: { id, disabled }, yaml: yamlText }
433
+ } else {
434
+ const invalid = validateForm()
435
+ if (invalid !== null) {
436
+ setError(invalid)
437
+ setBusy(false)
438
+ return
439
+ }
440
+ let env: Record<string, string> = {}
441
+ let headers: Record<string, string> = {}
442
+ try {
443
+ env = linesToRecord(envText, '=')
444
+ headers = linesToRecord(headersText, ':')
445
+ } catch (parseError) {
446
+ setError(parseError instanceof Error ? parseError.message : String(parseError))
447
+ setBusy(false)
448
+ return
449
+ }
450
+ payload = {
451
+ action: 'save',
452
+ server: {
453
+ id,
454
+ serverName,
455
+ transport,
456
+ command,
457
+ args: linesToArray(argsText),
458
+ env,
459
+ url,
460
+ headers,
461
+ ...(timeoutText !== '' ? { toolCallTimeoutMs: Number(timeoutText) } : {}),
462
+ disabled,
463
+ },
464
+ }
465
+ }
466
+ void mcpRequest<McpSnapshot>({
467
+ method: 'POST',
468
+ headers: { 'Content-Type': 'application/json' },
469
+ body: JSON.stringify(payload),
470
+ }).then(() => { onSaved() }).catch((reason: unknown) => {
471
+ setError(reason instanceof Error ? reason.message : String(reason))
472
+ }).finally(() => { setBusy(false) })
473
+ }
474
+
475
+ return createPortal(
476
+ <div className="dut-mcp-overlay" role="presentation">
477
+ <div className="dut-mcp-mask" aria-hidden="true" onClick={onClose} />
478
+ <div className="dut-mcp-panel" role="dialog" aria-modal="true" aria-label={isEdit ? t('mcpEditTitle') : t('mcpAddTitle')}>
479
+ <div className="dut-mcp-panel-head">
480
+ <h2>{isEdit ? t('mcpEditTitle') : t('mcpAddTitle')}</h2>
481
+ <span className="dut-mcp-spacer" />
482
+ <button type="button" className="dut-mcp-panel-close" onClick={onClose} aria-label="✕">✕</button>
483
+ </div>
484
+ <div className="dut-mcp-tabs" role="tablist">
485
+ <button type="button" role="tab" aria-selected={mode === 'form'} className={mode === 'form' ? 'dut-mcp-tab-on' : ''} onClick={() => { setMode('form') }}>{t('mcpFormTab')}</button>
486
+ <button type="button" role="tab" aria-selected={mode === 'yaml'} className={mode === 'yaml' ? 'dut-mcp-tab-on' : ''} onClick={() => { setMode('yaml') }}>{t('mcpYamlTab')}</button>
487
+ </div>
488
+ {error !== null ? <div className="dut-mcp-error">{error}</div> : null}
489
+ {mode === 'yaml' ? (
490
+ <div className="dut-mcp-form">
491
+ <div className="dut-mcp-row2">
492
+ <div className="dut-mcp-field">
493
+ <span>{t('mcpFieldId')}</span>
494
+ <input className="dut-mcp-input" value={id} disabled={isEdit} onChange={event => { setId(event.target.value) }} />
495
+ <small>{t('mcpFieldIdHint')}</small>
496
+ </div>
497
+ <div className="dut-mcp-field">
498
+ <span>{t('mcpFieldEnabled')}</span>
499
+ <div className="dut-seg">
500
+ <button type="button" className={!disabled ? 'dut-seg-active' : ''} onClick={() => { setDisabled(false) }}>{t('mcpEnabledAction')}</button>
501
+ <button type="button" className={disabled ? 'dut-seg-active' : ''} onClick={() => { setDisabled(true) }}>{t('mcpDisabledAction')}</button>
502
+ </div>
503
+ </div>
504
+ </div>
505
+ <div className="dut-mcp-field">
506
+ <span>{t('mcpYamlTab')}</span>
507
+ <textarea className="dut-mcp-input dut-mcp-yaml" value={yamlText} placeholder={t('mcpYamlPlaceholder')} spellCheck={false} onChange={event => { setYamlText(event.target.value) }} />
508
+ <small>{t('mcpYamlHint')}</small>
509
+ </div>
510
+ </div>
511
+ ) : (
512
+ <div className="dut-mcp-form">
513
+ <div className="dut-mcp-row2">
514
+ <div className="dut-mcp-field">
515
+ <span>{t('mcpFieldId')}</span>
516
+ <input className="dut-mcp-input" value={id} disabled={isEdit} onChange={event => { setId(event.target.value) }} />
517
+ <small>{t('mcpFieldIdHint')}</small>
518
+ </div>
519
+ <div className="dut-mcp-field">
520
+ <span>{t('mcpFieldEnabled')}</span>
521
+ <div className="dut-seg">
522
+ <button type="button" className={!disabled ? 'dut-seg-active' : ''} onClick={() => { setDisabled(false) }}>{t('mcpEnabledAction')}</button>
523
+ <button type="button" className={disabled ? 'dut-seg-active' : ''} onClick={() => { setDisabled(true) }}>{t('mcpDisabledAction')}</button>
524
+ </div>
525
+ </div>
526
+ </div>
527
+ <div className="dut-mcp-row2">
528
+ <div className="dut-mcp-field">
529
+ <span>{t('mcpFieldName')}</span>
530
+ <input className="dut-mcp-input" value={serverName} onChange={event => { setServerName(event.target.value) }} />
531
+ <small>{t('mcpFieldNameHint')}</small>
532
+ </div>
533
+ <div className="dut-mcp-field">
534
+ <span>{t('mcpFieldType')}</span>
535
+ <div className="dut-seg">
536
+ <button type="button" className={transport === 'stdio' ? 'dut-seg-active' : ''} onClick={() => { setTransport('stdio') }}>stdio</button>
537
+ <button type="button" className={transport === 'streamable-http' ? 'dut-seg-active' : ''} onClick={() => { setTransport('streamable-http') }}>HTTP</button>
538
+ </div>
539
+ </div>
540
+ </div>
541
+ <div className="dut-mcp-field">
542
+ <span>{t('mcpFieldTimeout')}</span>
543
+ <input className="dut-mcp-input" inputMode="numeric" value={timeoutText} placeholder="60000" onChange={event => { setTimeoutText(event.target.value) }} />
544
+ </div>
545
+ {transport === 'stdio' ? (
546
+ <>
547
+ <div className="dut-mcp-field">
548
+ <span>{t('mcpFieldCommand')}</span>
549
+ <input className="dut-mcp-input" value={command} placeholder={t('mcpFieldCommandPlaceholder')} onChange={event => { setCommand(event.target.value) }} />
550
+ </div>
551
+ <div className="dut-mcp-field">
552
+ <span>{t('mcpFieldArgs')}</span>
553
+ <textarea className="dut-mcp-input" value={argsText} placeholder={'-y\n@some/mcp-server'} spellCheck={false} onChange={event => { setArgsText(event.target.value) }} />
554
+ </div>
555
+ <div className="dut-mcp-field">
556
+ <span>{t('mcpFieldEnv')}</span>
557
+ <textarea className="dut-mcp-input dut-mcp-input-lg" value={envText} placeholder={'KEY=value'} spellCheck={false} onChange={event => { setEnvText(event.target.value) }} />
558
+ </div>
559
+ </>
560
+ ) : (
561
+ <>
562
+ <div className="dut-mcp-field">
563
+ <span>{t('mcpFieldUrl')}</span>
564
+ <input className="dut-mcp-input" value={url} placeholder="https://mcp.example.com/sse" onChange={event => { setUrl(event.target.value) }} />
565
+ </div>
566
+ <div className="dut-mcp-field">
567
+ <span>{t('mcpFieldHeaders')}</span>
568
+ <textarea className="dut-mcp-input dut-mcp-input-lg" value={headersText} placeholder={'Authorization: Bearer …'} spellCheck={false} onChange={event => { setHeadersText(event.target.value) }} />
569
+ </div>
570
+ </>
571
+ )}
572
+ </div>
573
+ )}
574
+ <div className="dut-mcp-foot">
575
+ <button type="button" className="dut-mcp-btn" disabled={busy} onClick={onClose}>{t('mcpCancel')}</button>
576
+ <button type="button" className="dut-btn dut-btn-active" disabled={busy} onClick={save}>{t('mcpSave')}</button>
577
+ </div>
578
+ </div>
579
+ </div>,
580
+ document.body,
581
+ )
582
+ }