@xiaoso/dsh-tool-plus 0.1.7 → 0.1.8-beta.2

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 (93) hide show
  1. package/lib/{abort-Caqff3M6.mjs → abort-BtBy5Svf.mjs} +1 -1
  2. package/lib/{alibaba-coding-plan-DTupMiAi.mjs → alibaba-coding-plan-oxHCHJtG.mjs} +2 -2
  3. package/lib/{alibaba-token-plan-Dj3OZhEE.mjs → alibaba-token-plan-B0WzvYnB.mjs} +2 -2
  4. package/lib/{amazon-bedrock-DbFSYXfJ.mjs → amazon-bedrock-BvfYGhrD.mjs} +6 -6
  5. package/lib/{anthropic-CXfWlp-x.mjs → anthropic-C6-vdduI.mjs} +1 -1
  6. package/lib/{anthropic-CTJ3IQVa.mjs → anthropic-Id561lwb.mjs} +13 -13
  7. package/lib/{anthropic-wire-CKuhDwZ1.mjs → anthropic-wire-Cb8AQ1tS.mjs} +13 -13
  8. package/lib/{api-key-validation-CGCJFbFr.mjs → api-key-validation-BXJKlsJg.mjs} +1 -1
  9. package/lib/{azure-openai-responses-ChFbWaGj.mjs → azure-openai-responses-CGws_ijz.mjs} +8 -8
  10. package/lib/{browser-render-9o0Rknop.mjs → browser-render-i7uYOl4f.mjs} +2 -2
  11. package/lib/client.js +2045 -19
  12. package/lib/{cloudflare-ai-gateway-BupNXyKK.mjs → cloudflare-ai-gateway-BK6eadm9.mjs} +2 -2
  13. package/lib/{cloudflare-ai-gateway-EWo0VkP5.mjs → cloudflare-ai-gateway-B_kWnF30.mjs} +1 -1
  14. package/lib/{collapse-DOR8sOGf.mjs → collapse-DU3g7d3B.mjs} +1 -1
  15. package/lib/{coreweave-Ct8cF-da.mjs → coreweave-CkR_FlQi.mjs} +2 -2
  16. package/lib/{cursor-LKGjucn2.mjs → cursor-BXx_5h4a.mjs} +2 -2
  17. package/lib/{cursor-DJV_dcgB.mjs → cursor-BcHwv8qq.mjs} +10 -10
  18. package/lib/{cursor-B95LzVWV.mjs → cursor-BjWbJJLe.mjs} +1 -1
  19. package/lib/{cursor-proto-B8Wj8bv9.mjs → cursor-proto-DPXXYx8S.mjs} +1 -1
  20. package/lib/{devin-DSp0Rw_E.mjs → devin-BZBfzyIz.mjs} +6 -6
  21. package/lib/{devin-BMTdx_hx.mjs → devin-Ce3Wz8_E.mjs} +5 -5
  22. package/lib/{devin-proto-Br8fdyNl.mjs → devin-proto-B6HwDwHK.mjs} +1 -1
  23. package/lib/{error-CORgGg5a.mjs → error-DdCn4m1q.mjs} +3 -3
  24. package/lib/{event-stream-ulcDenZD.mjs → event-stream-ldcUvOA0.mjs} +1 -1
  25. package/lib/{github-copilot-Dstq1D3s.mjs → github-copilot-BT5dkIJl.mjs} +2 -2
  26. package/lib/{github-copilot-headers-DOh1gt_w.mjs → github-copilot-headers-BGL8JO5c.mjs} +2 -2
  27. package/lib/{gitlab-duo-DSCMrU0m.mjs → gitlab-duo-B0qSYCa3.mjs} +1 -1
  28. package/lib/{google-CtsKkOQK.mjs → google-6cRgRmh0.mjs} +3 -3
  29. package/lib/{google-antigravity-z-5KsEci.mjs → google-antigravity-D1llOVad.mjs} +3 -3
  30. package/lib/{google-auth-ieLBaneO.mjs → google-auth-DYhqX1rJ.mjs} +5 -5
  31. package/lib/{google-gemini-cli-CAIPSl7e.mjs → google-gemini-cli-ByQUuKp9.mjs} +7 -7
  32. package/lib/{google-gemini-cli-C6NE5mWz.mjs → google-gemini-cli-CjHy6mFG.mjs} +3 -3
  33. package/lib/{google-oauth-shared-DhMllAkt.mjs → google-oauth-shared-CSwCT0Yl.mjs} +1 -1
  34. package/lib/{google-shared-B0aNCYEH.mjs → google-shared-DYjJp0mQ.mjs} +5 -5
  35. package/lib/{google-vertex-Bbd1tYNc.mjs → google-vertex-CbOvlE5j.mjs} +4 -4
  36. package/lib/{idle-iterator-DlUkEfzW.mjs → idle-iterator-DTBaEIbM.mjs} +2 -2
  37. package/lib/index.mjs +1278 -184
  38. package/lib/{kilo-ChJL54wq.mjs → kilo-cVG1aNZM.mjs} +1 -1
  39. package/lib/{kimi-C2c_Zq8A.mjs → kimi-BFfnFDhu.mjs} +4 -4
  40. package/lib/{launch-DKDH71En.mjs → launch-Uq7920ng.mjs} +7 -7
  41. package/lib/{markit-CmRfm4E2.mjs → markit-BmsfOFVh.mjs} +18 -18
  42. package/lib/{muse-code-Di1VZmB1.mjs → muse-code-CtcevJab.mjs} +1 -1
  43. package/lib/{ollama-B8ic3sEp.mjs → ollama-B5hR1P1p.mjs} +8 -8
  44. package/lib/{openai-codex-DuJNC99x.mjs → openai-codex-CxkPyvyt.mjs} +2 -2
  45. package/lib/{openai-codex-responses-BL29nh9K.mjs → openai-codex-responses-BbM3Q7nJ.mjs} +9 -9
  46. package/lib/{openai-completions-BK2f2dBv.mjs → openai-completions-CffXVOCL.mjs} +13 -13
  47. package/lib/{openai-reasoning-fallback-BdB3RCbj.mjs → openai-reasoning-fallback-DFdD1ok5.mjs} +2 -2
  48. package/lib/{openai-responses-CGQPlIIy.mjs → openai-responses-DgqOvSn_.mjs} +9 -9
  49. package/lib/{perplexity-TBjz3VMB.mjs → perplexity-JFp3knZN.mjs} +2 -2
  50. package/lib/{protobuf-aab1UGVn.mjs → protobuf-BJHNU9-E.mjs} +1 -1
  51. package/lib/{proxy-5nWqRL1f.mjs → proxy-B51IWEMt.mjs} +2 -2
  52. package/lib/{scrapers-CtQrQ5kG.mjs → scrapers-DU4u4-Co.mjs} +7 -7
  53. package/lib/{src-BwW51vA5.mjs → src-DUUqyo9y.mjs} +42 -42
  54. package/lib/{stream-B_7qN-bA.mjs → stream-DHdFrAG4.mjs} +54 -54
  55. package/lib/{stream-markup-healing-DKT1lnkW.mjs → stream-markup-healing-DlJ9abTw.mjs} +2 -2
  56. package/lib/{thinking-CiLa2LFB.mjs → thinking-D6LJxkz_.mjs} +1 -1
  57. package/lib/types/client/locales.d.ts +1 -1
  58. package/lib/types/config/settings.d.ts +8 -0
  59. package/lib/types/tools/ast-grep/adapter/index.d.ts +23 -0
  60. package/lib/types/tools/edit/adapter/index.d.ts +3 -0
  61. package/lib/types/tools/glob/adapter/index.d.ts +2 -0
  62. package/lib/types/tools/grep/adapter/index.d.ts +4 -0
  63. package/lib/types/tools/read/adapter/index.d.ts +18 -0
  64. package/lib/types/tools/write/adapter/index.d.ts +2 -0
  65. package/lib/types/web/client/ToolCardShell.d.ts +111 -0
  66. package/lib/types/web/client/labels.d.ts +36 -0
  67. package/lib/types/web/client/registerToolCards.d.ts +35 -0
  68. package/lib/types/web/client/row-utils.d.ts +177 -0
  69. package/lib/types/web/client/rows/AstEditRow.d.ts +22 -0
  70. package/lib/types/web/client/rows/FileMutationRow.d.ts +23 -0
  71. package/lib/types/web/client/rows/ReadRow.d.ts +31 -0
  72. package/lib/types/web/client/rows/SearchRow.d.ts +21 -0
  73. package/lib/types/web/client/rows/TerminalRow.d.ts +24 -0
  74. package/lib/types/web/client/rows/WriteRow.d.ts +22 -0
  75. package/lib/types/web/client/rows/image-card.d.ts +60 -0
  76. package/lib/types/web/contract.d.ts +249 -0
  77. package/lib/types/web/host/ast-edit.d.ts +10 -0
  78. package/lib/types/web/host/bash.d.ts +30 -0
  79. package/lib/types/web/host/edit.d.ts +33 -0
  80. package/lib/types/web/host/read.d.ts +48 -0
  81. package/lib/types/web/host/search.d.ts +41 -0
  82. package/lib/types/web/host/write.d.ts +13 -0
  83. package/lib/{types-PVueGi9S.mjs → types-DT9vgZ3S.mjs} +1 -1
  84. package/lib/{utils-CUPhpw9A.mjs → utils-BMmwLB0f.mjs} +2 -2
  85. package/lib/{utils-ECAwvaPQ.mjs → utils-C-Lo_JC6.mjs} +41 -41
  86. package/lib/{utils-DeWqNWV8.mjs → utils-D31VFael.mjs} +1 -1
  87. package/lib/{vision-guard-_XwsL1DF.mjs → vision-guard-BlAAqGUC.mjs} +1 -1
  88. package/lib/{xai-oauth-CXhnz0gO.mjs → xai-oauth-QPyScm4X.mjs} +1 -1
  89. package/lib/{xiaomi-psoUItal.mjs → xiaomi-CLzjxHAi.mjs} +1 -1
  90. package/lib/{zai-ApJ8HBgT.mjs → zai-wDp0g7VC.mjs} +1 -1
  91. package/lib/{zip-Kd602XNe.mjs → zip-CNtIFfyV.mjs} +7 -7
  92. package/package.json +3 -1
  93. package/presets/package.json +1 -1
package/lib/client.js CHANGED
@@ -8,6 +8,7 @@ window.__ModuleLoader__.load({
8
8
  let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
9
9
  let react_jsx_runtime = require("react/jsx-runtime");
10
10
  let _deepseek_ai_dsh_client_connection_client = require("@deepseek-ai/dsh-client-connection/client");
11
+ let react_dom = require("react-dom");
11
12
  //#region src/config/fields.ts
12
13
  /** Field group ids -> locale key of the group heading. */
13
14
  const TOOL_PLUS_GROUP_LABELS = {
@@ -94,6 +95,15 @@ window.__ModuleLoader__.load({
94
95
  group: "behavior",
95
96
  tool: "bash"
96
97
  },
98
+ {
99
+ name: "webCards",
100
+ kind: "boolean",
101
+ default: true,
102
+ labelKey: "webCards",
103
+ hintKey: "webCardsHint",
104
+ group: "behavior",
105
+ tool: "bash"
106
+ },
97
107
  {
98
108
  name: "autoBackgroundMs",
99
109
  kind: "number",
@@ -1136,7 +1146,7 @@ window.__ModuleLoader__.load({
1136
1146
  return SCHEMA_DEFAULTS[field];
1137
1147
  }
1138
1148
  /** Card chrome CSS, keyed by `data-plugin-css` and injected once (design-token driven). */
1139
- const CSS$1 = `
1149
+ const CSS$2 = `
1140
1150
  .tp-card{list-style:none;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-3);transition:border-color .16s,background .16s;margin:0 0 8px}
1141
1151
  .tp-card:hover{border-color:var(--dsw-alias-label-dimmed)}
1142
1152
  .tp-card.tp-open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
@@ -1175,16 +1185,16 @@ window.__ModuleLoader__.load({
1175
1185
  }
1176
1186
  `;
1177
1187
  /** Inject the card stylesheet once per page; the loader removes plugin-owned style tags on unload. */
1178
- let cssInjected$1 = false;
1188
+ let cssInjected$2 = false;
1179
1189
  function injectCardCss() {
1180
- if (cssInjected$1 || typeof document === "undefined") return;
1181
- cssInjected$1 = true;
1190
+ if (cssInjected$2 || typeof document === "undefined") return;
1191
+ cssInjected$2 = true;
1182
1192
  const id = "tool-plus-card";
1183
1193
  if (document.querySelector("style[data-plugin-css=\"tool-plus-card\"]") !== null) return;
1184
1194
  const tag = document.createElement("style");
1185
1195
  tag.dataset.plugin = "@xiaoso/dsh-tool-plus";
1186
1196
  tag.dataset.pluginCss = id;
1187
- tag.textContent = CSS$1;
1197
+ tag.textContent = CSS$2;
1188
1198
  document.head.appendChild(tag);
1189
1199
  }
1190
1200
  /** Text of a staged (or resolved) value for the number controls. */
@@ -1710,13 +1720,13 @@ window.__ModuleLoader__.load({
1710
1720
  return (c === "x" ? r : r & 3 | 8).toString(16);
1711
1721
  });
1712
1722
  }
1713
- function isRecord(value) {
1723
+ function isRecord$3(value) {
1714
1724
  return typeof value === "object" && value !== null && !Array.isArray(value);
1715
1725
  }
1716
1726
  function parseConnectionResponse(value) {
1717
- if (!isRecord(value) || value.type !== "server-response" || typeof value.rpcId !== "string") throw new TypeError("connection: invalid server-response envelope");
1727
+ if (!isRecord$3(value) || value.type !== "server-response" || typeof value.rpcId !== "string") throw new TypeError("connection: invalid server-response envelope");
1718
1728
  const result = value.result;
1719
- if (!isRecord(result)) throw new TypeError("connection: invalid server-response result");
1729
+ if (!isRecord$3(result)) throw new TypeError("connection: invalid server-response result");
1720
1730
  if (result.ok === true) return {
1721
1731
  rpcId: value.rpcId,
1722
1732
  result: {
@@ -1724,9 +1734,9 @@ window.__ModuleLoader__.load({
1724
1734
  value: result.value
1725
1735
  }
1726
1736
  };
1727
- if (result.ok !== false || !isRecord(result.error)) throw new TypeError("connection: invalid server-response result");
1737
+ if (result.ok !== false || !isRecord$3(result.error)) throw new TypeError("connection: invalid server-response result");
1728
1738
  const error = result.error;
1729
- if (typeof error.code !== "string" || typeof error.message !== "string" || !isRecord(error.details)) throw new TypeError("connection: invalid server-response failure");
1739
+ if (typeof error.code !== "string" || typeof error.message !== "string" || !isRecord$3(error.details)) throw new TypeError("connection: invalid server-response failure");
1730
1740
  return {
1731
1741
  rpcId: value.rpcId,
1732
1742
  result: {
@@ -2131,7 +2141,7 @@ window.__ModuleLoader__.load({
2131
2141
  * off under prefers-reduced-motion (CSS here, matchMedia for the
2132
2142
  * JS-driven underline).
2133
2143
  */
2134
- const CSS = `
2144
+ const CSS$1 = `
2135
2145
  .tps-section{box-sizing:border-box;display:flex;flex-direction:column;gap:16px;padding-top:2px}
2136
2146
  .tps-heading{margin:0;font-size:18px;font-weight:600;line-height:26px;color:var(--dsw-alias-label-primary);animation:tps-rise .38s cubic-bezier(.22,1,.36,1) both}
2137
2147
  .tps-intro{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary);max-width:64ch;animation:tps-rise .38s cubic-bezier(.22,1,.36,1) .05s both}
@@ -2176,16 +2186,16 @@ window.__ModuleLoader__.load({
2176
2186
  }
2177
2187
  `;
2178
2188
  /** Inject the section stylesheet once per page. */
2179
- let cssInjected = false;
2189
+ let cssInjected$1 = false;
2180
2190
  function injectSectionCss() {
2181
- if (cssInjected || typeof document === "undefined") return;
2182
- cssInjected = true;
2191
+ if (cssInjected$1 || typeof document === "undefined") return;
2192
+ cssInjected$1 = true;
2183
2193
  const id = "tool-plus-section";
2184
2194
  if (document.querySelector("style[data-plugin-css=\"tool-plus-section\"]") !== null) return;
2185
2195
  const tag = document.createElement("style");
2186
2196
  tag.dataset.plugin = "@xiaoso/dsh-tool-plus";
2187
2197
  tag.dataset.pluginCss = id;
2188
- tag.textContent = CSS;
2198
+ tag.textContent = CSS$1;
2189
2199
  document.head.appendChild(tag);
2190
2200
  }
2191
2201
  /** Tool tabs with their owned fields, derived from the single-source table. */
@@ -2621,7 +2631,7 @@ window.__ModuleLoader__.load({
2621
2631
  //#endregion
2622
2632
  //#region src/client/locales.ts
2623
2633
  /** Simplified Chinese copy. */
2624
- const zh = {
2634
+ const zh$1 = {
2625
2635
  title: "Bash(bash-plus)",
2626
2636
  description: "持久化 shell 工具:超时、后台化、命令拦截与运行环境。",
2627
2637
  nav: "Tool Plus",
@@ -2774,6 +2784,8 @@ window.__ModuleLoader__.load({
2774
2784
  rmSafeReasonRuntimeNotEffective: "运行时未生效",
2775
2785
  useShellCommandWrapper: "用 bash -c 包裹命令",
2776
2786
  useShellCommandWrapperHint: "把每条命令用 bash -c 包裹,以获得完整的 bash 环境。",
2787
+ webCards: "Web 工具卡片",
2788
+ webCardsHint: "在对话里用本插件的卡片展示 write / edit / read / grep / glob / ast_grep / ast_edit / bash 的调用结果;关闭后回到官方行。",
2777
2789
  outputTruncateStrategy: "截断单位",
2778
2790
  outputTruncateStrategyHint: "后台任务完成消息达到阈值时的截断单位:按字节或按行。切换后下方显示对应单位的配置。",
2779
2791
  outputTruncateTriggerBytes: "字节触发阈值",
@@ -2811,7 +2823,7 @@ window.__ModuleLoader__.load({
2811
2823
  appliesTo: "保存后对新调用生效。"
2812
2824
  };
2813
2825
  /** English copy. */
2814
- const en = {
2826
+ const en$1 = {
2815
2827
  title: "Bash (bash-plus)",
2816
2828
  description: "Persistent shell tool: timeouts, backgrounding, interception, and environment.",
2817
2829
  nav: "Tool Plus",
@@ -2964,6 +2976,8 @@ window.__ModuleLoader__.load({
2964
2976
  rmSafeReasonRuntimeNotEffective: "not effective at runtime",
2965
2977
  useShellCommandWrapper: "Wrap commands in bash -c",
2966
2978
  useShellCommandWrapperHint: "Wrap every command in bash -c for a full bash environment.",
2979
+ webCards: "Web tool cards",
2980
+ webCardsHint: "Show this plugin's cards for write / edit / read / grep / glob / ast_grep / ast_edit / bash calls in chat; off falls back to the shipped rows.",
2967
2981
  outputTruncateStrategy: "Truncate unit",
2968
2982
  outputTruncateStrategyHint: "Unit used when a background completion message crosses its threshold: bytes or lines. Switching shows the matching unit's settings below.",
2969
2983
  outputTruncateTriggerBytes: "Byte trigger threshold",
@@ -3001,6 +3015,2017 @@ window.__ModuleLoader__.load({
3001
3015
  appliesTo: "Values apply to new calls."
3002
3016
  };
3003
3017
  //#endregion
3018
+ //#region src/web/contract.ts
3019
+ /**
3020
+ * Web tool-card contract: the payload this plugin's Host half projects into
3021
+ * `output.presentationMeta` and its browser half narrows back before drawing.
3022
+ *
3023
+ * Dependency-free by design (no React, no cordis, no Node built-ins): the Node
3024
+ * half imports it to build metadata, the browser half imports it to validate
3025
+ * it, and because it carries no React import it can never drag the browser
3026
+ * bundle into the Node build.
3027
+ *
3028
+ * `edit` / `read` / `search` mirror the exact shapes the shipped
3029
+ * `@deepseek-ai/dsh-client-ui-tool` card models accept — a `kind` tag rides on
3030
+ * top, and those models ignore unknown keys — so the same payload stays
3031
+ * readable if this plugin ever stops shadowing those rows. `write`,
3032
+ * `ast_edit` and `terminal` are this plugin's own shapes: the shipped rows have
3033
+ * no equivalent (their write card is a diff card; ours is a content preview,
3034
+ * matching the OMP card the model already sees).
3035
+ *
3036
+ * Everything here is defensive: a card never throws. The browser half turns any
3037
+ * malformed, oversized or foreign payload into `null`, and the row falls back
3038
+ * to the generic shell.
3039
+ *
3040
+ * @module @xiaoso/dsh-tool-plus/web/contract
3041
+ */
3042
+ /**
3043
+ * Cell-shadowing rank of every card entry this plugin registers.
3044
+ *
3045
+ * The shipped `tool.call.toolview` entries all register at the default
3046
+ * priority 0, and the lowest live entry of a cell renders. A large negative
3047
+ * constant keeps this plugin first even if a future release starts ranking its
3048
+ * own rows below zero — the gap is deliberate, not a tuned value.
3049
+ */
3050
+ const CARD_TAKEOVER_PRIORITY = -1e3;
3051
+ /**
3052
+ * Wire tool names whose `tool.call.toolview` cell this plugin renders.
3053
+ *
3054
+ * `ast_grep` / `ast_edit` are this plugin's own tool names (no shipped entry
3055
+ * claims those keys), so registering them is purely additive; the other six
3056
+ * shadow a shipped row.
3057
+ */
3058
+ const CARD_TOOL_KEYS = [
3059
+ "bash",
3060
+ "read",
3061
+ "write",
3062
+ "edit",
3063
+ "grep",
3064
+ "glob",
3065
+ "ast_grep",
3066
+ "ast_edit"
3067
+ ];
3068
+ /** UTF-8 byte length of `text`, without allocating an encoder. */
3069
+ function utf8ByteLength(text) {
3070
+ let bytes = 0;
3071
+ for (let index = 0; index < text.length; index += 1) {
3072
+ const code = text.charCodeAt(index);
3073
+ if (code < 128) bytes += 1;
3074
+ else if (code < 2048) bytes += 2;
3075
+ else if (code >= 55296 && code <= 56319) {
3076
+ bytes += 4;
3077
+ index += 1;
3078
+ } else bytes += 3;
3079
+ }
3080
+ return bytes;
3081
+ }
3082
+ /** UTF-8 byte length of a value's JSON form; the unit every cap above uses. */
3083
+ function jsonByteLength(value) {
3084
+ const json = JSON.stringify(value);
3085
+ return json === void 0 ? 0 : utf8ByteLength(json);
3086
+ }
3087
+ /** Whether `value` is a plain object (never an array or `null`). */
3088
+ function isRecord$2(value) {
3089
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3090
+ }
3091
+ /** Non-blank string, or `null`. */
3092
+ function nonBlankString(value) {
3093
+ return typeof value === "string" && value.trim() !== "" ? value : null;
3094
+ }
3095
+ /** 1-based integer, or `null` (the shipped card models' own line rule). */
3096
+ function positiveInteger(value) {
3097
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : null;
3098
+ }
3099
+ /** Non-negative integer, or `null`. */
3100
+ function nonNegativeInteger(value) {
3101
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
3102
+ }
3103
+ /** Optional grammar hint: a non-empty string stays, anything else drops. */
3104
+ function lang(value) {
3105
+ return typeof value === "string" && value !== "" ? { lang: value } : {};
3106
+ }
3107
+ /**
3108
+ * Narrow opaque metadata to a {@link WriteCardMeta}.
3109
+ * @param meta - the result node's `meta` field.
3110
+ * @returns the narrowed metadata, or `null` when it is not a usable write card.
3111
+ */
3112
+ function narrowWriteCardMeta(meta) {
3113
+ if (!isRecord$2(meta) || meta.kind !== "write") return null;
3114
+ const path = nonBlankString(meta.path);
3115
+ if (path === null) return null;
3116
+ return {
3117
+ kind: "write",
3118
+ path,
3119
+ ...lang(meta.lang),
3120
+ ...meta.madeExecutable === true ? { madeExecutable: true } : {}
3121
+ };
3122
+ }
3123
+ /**
3124
+ * Narrow opaque metadata to an {@link EditCardMeta}.
3125
+ *
3126
+ * Mirrors the shipped diff card's own validation (a hunk needs a non-blank
3127
+ * `path`, an explicit `null` or string `oldText`, and a string `newText`) and
3128
+ * additionally rejects a payload past {@link EDIT_META_MAX_BYTES}.
3129
+ * @param meta - the result node's `meta` field.
3130
+ * @returns the narrowed metadata, or `null` when it is not a usable diff card.
3131
+ */
3132
+ function narrowEditCardMeta(meta) {
3133
+ if (!isRecord$2(meta) || meta.kind !== "edit") return null;
3134
+ if (jsonByteLength(meta) > 131072) return null;
3135
+ const raw = meta.diffs;
3136
+ if (!Array.isArray(raw) || raw.length === 0) return null;
3137
+ const diffs = [];
3138
+ for (const hunk of raw) {
3139
+ if (!isRecord$2(hunk)) return null;
3140
+ const path = nonBlankString(hunk.path);
3141
+ if (path === null) return null;
3142
+ const { oldText, newText } = hunk;
3143
+ if (oldText !== null && typeof oldText !== "string") return null;
3144
+ if (typeof newText !== "string") return null;
3145
+ diffs.push({
3146
+ path,
3147
+ oldText,
3148
+ newText
3149
+ });
3150
+ }
3151
+ return {
3152
+ kind: "edit",
3153
+ diffs
3154
+ };
3155
+ }
3156
+ /**
3157
+ * Narrow opaque metadata to an {@link AstEditCardMeta}.
3158
+ * @param meta - the result node's `meta` field.
3159
+ * @returns the narrowed metadata, or `null` when it is not a usable preview.
3160
+ */
3161
+ function narrowAstEditCardMeta(meta) {
3162
+ if (!isRecord$2(meta) || meta.kind !== "ast_edit") return null;
3163
+ if (jsonByteLength(meta) > 65536) return null;
3164
+ if (typeof meta.preview !== "string") return null;
3165
+ const replacements = nonNegativeInteger(meta.replacements);
3166
+ if (replacements === null || typeof meta.applied !== "boolean") return null;
3167
+ if (!Array.isArray(meta.files)) return null;
3168
+ const files = [];
3169
+ for (const file of meta.files) {
3170
+ if (!isRecord$2(file)) return null;
3171
+ const path = nonBlankString(file.path);
3172
+ const count = nonNegativeInteger(file.count);
3173
+ if (path === null || count === null) return null;
3174
+ files.push({
3175
+ path,
3176
+ count
3177
+ });
3178
+ }
3179
+ return {
3180
+ kind: "ast_edit",
3181
+ preview: meta.preview,
3182
+ files,
3183
+ replacements,
3184
+ applied: meta.applied
3185
+ };
3186
+ }
3187
+ /**
3188
+ * Narrow opaque metadata to a {@link ReadCardMeta}.
3189
+ *
3190
+ * Mirrors the shipped read card's own validation: a 1-based `offset`, lines
3191
+ * strictly increasing in `number` and never past `totalLines`. A window whose
3192
+ * lines were all elided therefore narrows to a card with an empty body, not to
3193
+ * a malformed one.
3194
+ * @param meta - the result node's `meta` field.
3195
+ * @returns the narrowed metadata, or `null` when it is not a usable read card.
3196
+ */
3197
+ function narrowReadCardMeta(meta) {
3198
+ if (!isRecord$2(meta) || meta.kind !== "read") return null;
3199
+ if (jsonByteLength(meta) > 262144) return null;
3200
+ const path = nonBlankString(meta.path);
3201
+ const offset = positiveInteger(meta.offset);
3202
+ const totalLines = nonNegativeInteger(meta.totalLines);
3203
+ if (path === null || offset === null || totalLines === null) return null;
3204
+ if (!Array.isArray(meta.lines)) return null;
3205
+ const lines = [];
3206
+ let previous = offset - 1;
3207
+ for (const line of meta.lines) {
3208
+ if (!isRecord$2(line)) return null;
3209
+ const number = positiveInteger(line.number);
3210
+ if (number === null || number <= previous || number > totalLines) return null;
3211
+ if (typeof line.text !== "string") return null;
3212
+ previous = number;
3213
+ lines.push({
3214
+ number,
3215
+ text: line.text
3216
+ });
3217
+ }
3218
+ return {
3219
+ kind: "read",
3220
+ path,
3221
+ offset,
3222
+ lines,
3223
+ totalLines,
3224
+ ...lang(meta.lang)
3225
+ };
3226
+ }
3227
+ /** Narrow one grouped-matches file entry, or `null` when malformed. */
3228
+ function narrowSearchFile(file) {
3229
+ if (!isRecord$2(file)) return null;
3230
+ const path = nonBlankString(file.path);
3231
+ if (path === null || !Array.isArray(file.matches)) return null;
3232
+ const matches = [];
3233
+ for (const match of file.matches) {
3234
+ if (!isRecord$2(match)) return null;
3235
+ const lineNumber = positiveInteger(match.lineNumber);
3236
+ if (lineNumber === null || typeof match.line !== "string") return null;
3237
+ matches.push({
3238
+ lineNumber,
3239
+ line: match.line
3240
+ });
3241
+ }
3242
+ return {
3243
+ path,
3244
+ matches
3245
+ };
3246
+ }
3247
+ /**
3248
+ * Narrow opaque metadata to a {@link SearchCardMeta}.
3249
+ * @param meta - the result node's `meta` field.
3250
+ * @returns the narrowed metadata, or `null` when it is not a usable search card.
3251
+ */
3252
+ function narrowSearchCardMeta(meta) {
3253
+ if (!isRecord$2(meta) || meta.kind !== "search") return null;
3254
+ if (jsonByteLength(meta) > 65536) return null;
3255
+ if (typeof meta.truncated !== "boolean") return null;
3256
+ const total = nonNegativeInteger(meta.total);
3257
+ if (total === null) return null;
3258
+ if (meta.shape === "matches") {
3259
+ if (!Array.isArray(meta.files)) return null;
3260
+ const files = [];
3261
+ for (const file of meta.files) {
3262
+ const narrowed = narrowSearchFile(file);
3263
+ if (narrowed === null) return null;
3264
+ files.push(narrowed);
3265
+ }
3266
+ return {
3267
+ kind: "search",
3268
+ shape: "matches",
3269
+ files,
3270
+ truncated: meta.truncated,
3271
+ total
3272
+ };
3273
+ }
3274
+ if (meta.shape !== "paths" || !Array.isArray(meta.paths)) return null;
3275
+ const paths = [];
3276
+ for (const path of meta.paths) {
3277
+ if (typeof path !== "string") return null;
3278
+ paths.push(path);
3279
+ }
3280
+ return {
3281
+ kind: "search",
3282
+ shape: "paths",
3283
+ paths,
3284
+ truncated: meta.truncated,
3285
+ total
3286
+ };
3287
+ }
3288
+ /**
3289
+ * Narrow opaque metadata to a {@link TerminalCardMeta}.
3290
+ * @param meta - the result node's `meta` field.
3291
+ * @returns the narrowed metadata, or `null` when it is not a usable terminal card.
3292
+ */
3293
+ function narrowTerminalCardMeta(meta) {
3294
+ if (!isRecord$2(meta) || meta.kind !== "terminal") return null;
3295
+ if (meta.mode === "background") {
3296
+ const jobId = nonBlankString(meta.jobId);
3297
+ return jobId === null ? null : {
3298
+ kind: "terminal",
3299
+ mode: "background",
3300
+ jobId
3301
+ };
3302
+ }
3303
+ if (meta.mode !== "foreground") return null;
3304
+ const { exitCode, timedOut, aborted, workingDir } = meta;
3305
+ if (exitCode !== null && (typeof exitCode !== "number" || !Number.isInteger(exitCode))) return null;
3306
+ if (typeof timedOut !== "boolean" || typeof aborted !== "boolean") return null;
3307
+ if (workingDir !== void 0 && typeof workingDir !== "string") return null;
3308
+ return {
3309
+ kind: "terminal",
3310
+ mode: "foreground",
3311
+ exitCode,
3312
+ timedOut,
3313
+ aborted,
3314
+ ...typeof workingDir === "string" && workingDir !== "" ? { workingDir } : {}
3315
+ };
3316
+ }
3317
+ //#endregion
3318
+ //#region src/web/client/labels.ts
3319
+ /**
3320
+ * Card copy for the browser tool views: every string the six row components
3321
+ * and the shared shell draw, in its own locale namespace so it can never
3322
+ * collide with the settings card's `tool-plus` bundle (the slots registry
3323
+ * merges namespaces, and re-declaring one would be a compile error).
3324
+ *
3325
+ * zh is the source of truth for the key set; both dictionaries are
3326
+ * `Record<ToolCardLocaleKey, string>`, so a missing or extra key in either is
3327
+ * a compile error. Template parameters use the host's `{name}` placeholder
3328
+ * convention (the seat interpolates the params record into the string).
3329
+ *
3330
+ * The wording mirrors the shipped conversation namespace for the same
3331
+ * primitives (diff / read / search / terminal chrome) so a takeover does not
3332
+ * change how the cards read; tool titles and the generic-row copy are ours.
3333
+ * @module @xiaoso/dsh-tool-plus/web/client/labels
3334
+ */
3335
+ /** Locale namespace of the tool-card bundle (registered by `registerToolCards`). */
3336
+ const CARD_LOCALE_NS = "tool-plus-cards";
3337
+ /** The bilingual bundle handed to `ctx.locale.register(CARD_LOCALE_NS, …)`. */
3338
+ const cardLocales = {
3339
+ zh: {
3340
+ copy: "复制",
3341
+ copied: "已复制",
3342
+ collapse: "收起",
3343
+ expand: "展开",
3344
+ collapseAria: "收起",
3345
+ expandAria: "展开其余 {count} 行",
3346
+ expandRest: "… 其余 {count} 行",
3347
+ "files.one": "{count} 个文件",
3348
+ "files.other": "{count} 个文件",
3349
+ running: "运行中",
3350
+ done: "完成",
3351
+ failed: "失败",
3352
+ cancelled: "已取消",
3353
+ timedOut: "已超时",
3354
+ noOutput: "无输出",
3355
+ "title.bash": "Bash",
3356
+ "title.read": "读取",
3357
+ "title.write": "写入",
3358
+ "title.edit": "编辑",
3359
+ "title.grep": "Grep",
3360
+ "title.glob": "Glob",
3361
+ "title.astGrep": "AST Grep",
3362
+ "title.astEdit": "AST Edit",
3363
+ "write.lines": "{count} 行",
3364
+ "write.madeExecutable": "已赋予可执行权限",
3365
+ "astEdit.replacements": "{count} 处替换",
3366
+ "astEdit.files": "{count} 个文件",
3367
+ "astEdit.parseErrors": "解析错误",
3368
+ "read.window": "显示 {shown} / {total} 行",
3369
+ "read.collapseAria": "收起内容",
3370
+ "read.expandAria": "展开其余 {count} 行",
3371
+ "read.expandRest": "… 其余 {count} 行",
3372
+ "search.paths": "{shown} 个路径",
3373
+ "search.paths.truncated": "显示 {shown} / 共 {total} 个路径",
3374
+ "search.matches": "{shown} 处匹配 · {files} 个文件",
3375
+ "search.matches.truncated": "显示 {shown} / 共 {total} 处匹配 · {files} 个文件",
3376
+ "search.noResults": "无结果",
3377
+ "search.collapseAria": "收起结果",
3378
+ "search.expandAria": "展开其余 {count} 行结果",
3379
+ "search.expandRest": "… 其余 {count} 行",
3380
+ "terminal.signal": "信号 {signal}",
3381
+ "terminal.exitCode": "退出码 {code}",
3382
+ "terminal.running": "运行中",
3383
+ "terminal.failed": "失败",
3384
+ "terminal.done": "已完成",
3385
+ "terminal.noOutput": "无输出",
3386
+ "bash.background": "后台任务 {jobId}",
3387
+ "image.loading": "加载中",
3388
+ "image.loadFailed": "加载失败",
3389
+ "image.open": "查看原图",
3390
+ "image.openNamed": "查看原图:{name}",
3391
+ "image.dialog": "原图预览",
3392
+ "image.close": "关闭",
3393
+ "image.unnamed": "图片",
3394
+ "image.caption": "{name} · {width}×{height} · {size}",
3395
+ "generic.params": "{count} 个参数",
3396
+ "generic.noDetail": "没有更多可展示的细节",
3397
+ inspect: "查看"
3398
+ },
3399
+ en: {
3400
+ copy: "Copy",
3401
+ copied: "Copied",
3402
+ collapse: "Collapse",
3403
+ expand: "Expand",
3404
+ collapseAria: "Collapse",
3405
+ expandAria: "Expand {count} more lines",
3406
+ expandRest: "… {count} more lines",
3407
+ "files.one": "{count} file",
3408
+ "files.other": "{count} files",
3409
+ running: "Running",
3410
+ done: "Done",
3411
+ failed: "Failed",
3412
+ cancelled: "Cancelled",
3413
+ timedOut: "Timed out",
3414
+ noOutput: "No output",
3415
+ "title.bash": "Bash",
3416
+ "title.read": "Read",
3417
+ "title.write": "Write",
3418
+ "title.edit": "Edit",
3419
+ "title.grep": "Grep",
3420
+ "title.glob": "Glob",
3421
+ "title.astGrep": "AST Grep",
3422
+ "title.astEdit": "AST Edit",
3423
+ "write.lines": "{count} lines",
3424
+ "write.madeExecutable": "Made executable",
3425
+ "astEdit.replacements": "{count} replacements",
3426
+ "astEdit.files": "{count} files",
3427
+ "astEdit.parseErrors": "Parse errors",
3428
+ "read.window": "Showing {shown} of {total} lines",
3429
+ "read.collapseAria": "Collapse content",
3430
+ "read.expandAria": "Expand {count} more lines",
3431
+ "read.expandRest": "… {count} more lines",
3432
+ "search.paths": "{shown} paths",
3433
+ "search.paths.truncated": "Showing {shown} of {total} paths",
3434
+ "search.matches": "{shown} matches · {files} files",
3435
+ "search.matches.truncated": "Showing {shown} of {total} matches · {files} files",
3436
+ "search.noResults": "No results",
3437
+ "search.collapseAria": "Collapse results",
3438
+ "search.expandAria": "Expand {count} more result lines",
3439
+ "search.expandRest": "… {count} more lines",
3440
+ "terminal.signal": "signal {signal}",
3441
+ "terminal.exitCode": "exit code {code}",
3442
+ "terminal.running": "Running",
3443
+ "terminal.failed": "Failed",
3444
+ "terminal.done": "Done",
3445
+ "terminal.noOutput": "No output",
3446
+ "bash.background": "Background job {jobId}",
3447
+ "image.loading": "Loading",
3448
+ "image.loadFailed": "Load failed",
3449
+ "image.open": "View original",
3450
+ "image.openNamed": "View original: {name}",
3451
+ "image.dialog": "Original image",
3452
+ "image.close": "Close",
3453
+ "image.unnamed": "Image",
3454
+ "image.caption": "{name} · {width}×{height} · {size}",
3455
+ "generic.params": "{count} params",
3456
+ "generic.noDetail": "No further detail",
3457
+ inspect: "Inspect"
3458
+ }
3459
+ };
3460
+ //#endregion
3461
+ //#region src/web/client/row-utils.ts
3462
+ /** Run state word the shell exposes to assistive technology; `ok` says nothing. */
3463
+ const STATE_STATUS_KEY = {
3464
+ running: "running",
3465
+ ok: null,
3466
+ error: "failed",
3467
+ warning: "failed"
3468
+ };
3469
+ /** Parsed-argument cache: the node is immutable, so one parse serves every render. */
3470
+ const parsedArgs = /* @__PURE__ */ new WeakMap();
3471
+ /** Whether a block is the settled half of a call pair. */
3472
+ function isSettled(block) {
3473
+ return block.kind === "tool-result";
3474
+ }
3475
+ /**
3476
+ * The call head paired with a block: a running call is its own head; a settled
3477
+ * result carries the head backfilled from the in-window `tool/call`, which is
3478
+ * `null` when window truncation left that event outside.
3479
+ */
3480
+ function callHead(block) {
3481
+ if (!isSettled(block)) return block;
3482
+ const call = block.call;
3483
+ return typeof call === "object" && call !== null ? call : null;
3484
+ }
3485
+ /** Whether `value` is a plain object, the only argument shape the cards accept. */
3486
+ function isRecord$1(value) {
3487
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3488
+ }
3489
+ /**
3490
+ * Parse the call's raw argument JSON.
3491
+ *
3492
+ * The parsed object is memoized per node (the node is immutable), so repeated
3493
+ * renders of one card pay for a single parse.
3494
+ * @param block - the frozen running-or-settled call node.
3495
+ * @returns the argument object, or `null` when the head is missing or its JSON
3496
+ * is not an object.
3497
+ */
3498
+ function parseCardArgs(block) {
3499
+ const cacheable = typeof block === "object" && block !== null ? block : null;
3500
+ if (cacheable !== null) {
3501
+ const hit = parsedArgs.get(cacheable);
3502
+ if (hit !== void 0 || parsedArgs.has(cacheable)) return hit ?? null;
3503
+ }
3504
+ const raw = callHead(block)?.argsRaw;
3505
+ let parsed = null;
3506
+ if (typeof raw === "string") try {
3507
+ const value = JSON.parse(raw);
3508
+ if (isRecord$1(value)) parsed = value;
3509
+ } catch {
3510
+ parsed = null;
3511
+ }
3512
+ if (cacheable !== null) parsedArgs.set(cacheable, parsed);
3513
+ return parsed;
3514
+ }
3515
+ /**
3516
+ * Run state of one call.
3517
+ *
3518
+ * A settled `isError` result is an error; a foreground command that timed out
3519
+ * or was cancelled is amber rather than green (the shipped terminal model
3520
+ * reads only the trailing exit marker, which is exactly what the card owner
3521
+ * was told not to trust). A non-zero exit stays `ok`: bash reports a failing
3522
+ * command as result data, and the card's own status pill carries the red.
3523
+ * @param block - the frozen running-or-settled call node.
3524
+ * @returns the card run state.
3525
+ */
3526
+ function cardState(block) {
3527
+ if (!isSettled(block)) return "running";
3528
+ if (block.isError === true) return "error";
3529
+ const terminal = narrowTerminalCardMeta(cardMeta(block));
3530
+ if (terminal !== null && terminal.mode === "foreground" && (terminal.timedOut || terminal.aborted)) return "warning";
3531
+ return "ok";
3532
+ }
3533
+ /** Map a card run state onto the primitive's dot semantics. */
3534
+ function dotState(state) {
3535
+ switch (state) {
3536
+ case "running": return "ongoing";
3537
+ case "error": return "error";
3538
+ case "warning": return "warning";
3539
+ default: return "done";
3540
+ }
3541
+ }
3542
+ /**
3543
+ * The assistive-technology status word for a run state, or `null` when the
3544
+ * state is already carried by the row's content.
3545
+ */
3546
+ function stateStatus(state, t) {
3547
+ const key = STATE_STATUS_KEY[state];
3548
+ return key === null ? null : t(key);
3549
+ }
3550
+ /**
3551
+ * Flatten a settled result's text blocks.
3552
+ * @param block - the frozen call node.
3553
+ * @returns the joined text blocks (empty for a running call or non-text output).
3554
+ */
3555
+ function resultText(block) {
3556
+ if (!isSettled(block) || !Array.isArray(block.content)) return "";
3557
+ const parts = [];
3558
+ for (const part of block.content) {
3559
+ if (!isRecord$1(part)) continue;
3560
+ if (part.type === "text" && typeof part.text === "string") parts.push(part.text);
3561
+ }
3562
+ return parts.join("\n");
3563
+ }
3564
+ /**
3565
+ * The opaque metadata a call's result carried, exactly as the transport
3566
+ * delivered it. Callers must run it through a `narrow*` guard before reading.
3567
+ * @param block - the frozen call node.
3568
+ * @returns the raw metadata, or `undefined` while the call is running.
3569
+ */
3570
+ function cardMeta(block) {
3571
+ return isSettled(block) ? block.meta : void 0;
3572
+ }
3573
+ /**
3574
+ * Whether the call was dispatched by another tool (a Code Dispatch child).
3575
+ * The card rows fall back to the generic shell for those, mirroring the
3576
+ * shipped rows: a child diff/read/search inside a parent card would nest a
3577
+ * surface inside the parent's own body.
3578
+ */
3579
+ function isSubCall(block) {
3580
+ return typeof block.parentCallId === "string";
3581
+ }
3582
+ /** Longest summary the shell renders before it is clipped for the row. */
3583
+ const FIRST_LINE_MAX = 200;
3584
+ /**
3585
+ * The first non-empty line of a text, trimmed — the collapsed summary of an
3586
+ * error, and the closing line of the generic shell's body.
3587
+ * @param text - the source text.
3588
+ * @returns the first line, clipped to a row-sized maximum (never throws).
3589
+ */
3590
+ function firstLine(text) {
3591
+ if (typeof text !== "string") return "";
3592
+ const line = text.trim().split("\n", 1)[0]?.trim() ?? "";
3593
+ return line.length > FIRST_LINE_MAX ? `${line.slice(0, 199)}…` : line;
3594
+ }
3595
+ /**
3596
+ * A string argument by name.
3597
+ * @param args - parsed arguments, or `null`.
3598
+ * @param name - the argument key.
3599
+ * @returns its value when it is a non-empty string, else `null`.
3600
+ */
3601
+ function argText(args, name) {
3602
+ const value = args?.[name];
3603
+ return typeof value === "string" && value !== "" ? value : null;
3604
+ }
3605
+ /**
3606
+ * Shorten an absolute path against the session workspace so a summary stays
3607
+ * readable in a narrow row.
3608
+ * @param path - the path to display.
3609
+ * @param cwd - the session workspace root, when known.
3610
+ * @returns the path relative to `cwd`, or `path` unchanged.
3611
+ */
3612
+ function displayPath(path, cwd) {
3613
+ if (cwd === void 0 || cwd === "") return path;
3614
+ const root = cwd.endsWith("/") || cwd.endsWith("\\") ? cwd : `${cwd}/`;
3615
+ const normalized = path.replace(/\\/g, "/");
3616
+ const prefix = root.replace(/\\/g, "/");
3617
+ if (!normalized.startsWith(prefix)) return path;
3618
+ const rest = normalized.slice(prefix.length);
3619
+ return rest === "" ? path : rest;
3620
+ }
3621
+ /**
3622
+ * The collapsed summary for a call whose card could not be built: how many
3623
+ * arguments the model sent, so the row is not blank.
3624
+ * @param args - parsed arguments, or `null`.
3625
+ * @param t - the card translate seat.
3626
+ * @returns the localized parameter count.
3627
+ */
3628
+ function argsSummary(args, t) {
3629
+ const count = args === null ? 0 : Object.keys(args).length;
3630
+ return count === 0 ? t("generic.noDetail") : t("generic.params", { count });
3631
+ }
3632
+ /** Card title key of every wire tool name this plugin renders. */
3633
+ const TITLE_KEYS = {
3634
+ bash: "title.bash",
3635
+ read: "title.read",
3636
+ write: "title.write",
3637
+ edit: "title.edit",
3638
+ grep: "title.grep",
3639
+ glob: "title.glob",
3640
+ ast_grep: "title.astGrep",
3641
+ ast_edit: "title.astEdit"
3642
+ };
3643
+ /**
3644
+ * The card title for a wire tool name.
3645
+ *
3646
+ * The same row component can be registered under several keys (one search row
3647
+ * serves `grep`, `glob`, and `ast_grep`), and a window-truncated call has no
3648
+ * name at all, so the caller names the key it was registered for as a fallback.
3649
+ * @param toolName - the node's wire tool name (empty when truncated).
3650
+ * @param t - the card translate seat.
3651
+ * @param fallback - title key to use when the name is unknown.
3652
+ * @returns the localized title.
3653
+ */
3654
+ function cardTitle(toolName, t, fallback) {
3655
+ return t((typeof toolName === "string" ? TITLE_KEYS[toolName] : void 0) ?? fallback);
3656
+ }
3657
+ /**
3658
+ * Count a preview's content lines, ignoring the single trailing newline that
3659
+ * terminates the last line.
3660
+ * @param text - the preview text.
3661
+ * @returns its line count (0 for empty text).
3662
+ */
3663
+ function lineCount(text) {
3664
+ if (typeof text !== "string" || text === "") return 0;
3665
+ return (text.endsWith("\n") ? text.slice(0, -1) : text).split("\n").length;
3666
+ }
3667
+ /** `[timed out after 30000ms]` — `@deepseek-ai/dsh-shell/render`'s own literal. */
3668
+ const TIMEOUT_MARKER = /^\[timed out after [^\]\n]*\]$/;
3669
+ /** `[killed by signal: SIGTERM]`. */
3670
+ const SIGNAL_MARKER = /^\[killed by signal: ([^\]\n]+)\]$/;
3671
+ /** `[exit code: 1]`, or `[exit code: null]` for an unknown status. */
3672
+ const EXIT_MARKER = /^\[exit code: (\d+|null)\]$/;
3673
+ /**
3674
+ * Split a settled shell result into its output and the status markers the
3675
+ * renderer appended (timeout, terminating signal, exit code — in that order,
3676
+ * so every marker is at the end and any run of them is stripped).
3677
+ *
3678
+ * The Host metadata already carries the run state; parsing is what keeps the
3679
+ * markers out of the terminal card's output pane, and it is the only source
3680
+ * for a signal name the projection does not carry.
3681
+ * @param text - the result text.
3682
+ * @returns the marker-free output and the facts the markers named.
3683
+ */
3684
+ function parseShellStatus(text) {
3685
+ let rest = typeof text === "string" ? text : "";
3686
+ let exitCode;
3687
+ let signal;
3688
+ let timedOut = false;
3689
+ for (;;) {
3690
+ const trimmed = rest.endsWith("\n") ? rest.slice(0, -1) : rest;
3691
+ const breakAt = trimmed.lastIndexOf("\n");
3692
+ const last = breakAt === -1 ? trimmed : trimmed.slice(breakAt + 1);
3693
+ const exit = EXIT_MARKER.exec(last);
3694
+ if (exit !== null) {
3695
+ exitCode = exit[1] === "null" ? null : Number(exit[1]);
3696
+ rest = breakAt === -1 ? "" : trimmed.slice(0, breakAt);
3697
+ continue;
3698
+ }
3699
+ const killed = SIGNAL_MARKER.exec(last);
3700
+ if (killed !== null) {
3701
+ signal = killed[1];
3702
+ rest = breakAt === -1 ? "" : trimmed.slice(0, breakAt);
3703
+ continue;
3704
+ }
3705
+ if (TIMEOUT_MARKER.test(last)) {
3706
+ timedOut = true;
3707
+ rest = breakAt === -1 ? "" : trimmed.slice(0, breakAt);
3708
+ continue;
3709
+ }
3710
+ break;
3711
+ }
3712
+ return {
3713
+ output: rest,
3714
+ exitCode,
3715
+ signal,
3716
+ timedOut
3717
+ };
3718
+ }
3719
+ /** Bind the card dictionary to the diff primitive's chrome labels. */
3720
+ function diffBlockLabels(t) {
3721
+ return {
3722
+ copy: t("copy"),
3723
+ copied: t("copied"),
3724
+ collapseAria: t("collapseAria"),
3725
+ expandAria: (count) => t("expandAria", { count }),
3726
+ collapse: t("collapse"),
3727
+ expand: (count) => t("expandRest", { count }),
3728
+ files: (count) => t(count === 1 ? "files.one" : "files.other", { count })
3729
+ };
3730
+ }
3731
+ /** Bind the card dictionary to the read primitive's chrome labels. */
3732
+ function readBlockLabels(t) {
3733
+ return {
3734
+ window: (shown, total) => t("read.window", {
3735
+ shown,
3736
+ total
3737
+ }),
3738
+ copy: t("copy"),
3739
+ copied: t("copied"),
3740
+ collapseAria: t("read.collapseAria"),
3741
+ expandAria: (count) => t("read.expandAria", { count }),
3742
+ collapse: t("collapse"),
3743
+ expand: (count) => t("read.expandRest", { count })
3744
+ };
3745
+ }
3746
+ /** Bind the card dictionary to the search primitive's chrome labels. */
3747
+ function searchBlockLabels(t) {
3748
+ return {
3749
+ pathsSummary: (shown, total, truncated) => t(truncated ? "search.paths.truncated" : "search.paths", {
3750
+ shown,
3751
+ total
3752
+ }),
3753
+ matchesSummary: (shown, total, files, truncated) => t(truncated ? "search.matches.truncated" : "search.matches", {
3754
+ shown,
3755
+ total,
3756
+ files
3757
+ }),
3758
+ copy: t("copy"),
3759
+ copied: t("copied"),
3760
+ noResults: t("search.noResults"),
3761
+ collapseAria: t("search.collapseAria"),
3762
+ expandAria: (count) => t("search.expandAria", { count }),
3763
+ collapse: t("collapse"),
3764
+ expand: (count) => t("search.expandRest", { count })
3765
+ };
3766
+ }
3767
+ /** Bind the card dictionary to the terminal primitive's chrome labels. */
3768
+ function terminalBlockLabels(t) {
3769
+ return {
3770
+ signal: (signal) => t("terminal.signal", { signal }),
3771
+ exitCode: (exitCode) => t("terminal.exitCode", { code: exitCode }),
3772
+ running: t("terminal.running"),
3773
+ failed: t("terminal.failed"),
3774
+ done: t("terminal.done"),
3775
+ copy: t("copy"),
3776
+ copied: t("copied"),
3777
+ noOutput: t("terminal.noOutput"),
3778
+ collapseAria: t("collapseAria"),
3779
+ collapse: t("collapse"),
3780
+ expandAria: (hidden) => t("expandAria", { count: hidden }),
3781
+ expand: (hidden) => t("expandRest", { count: hidden })
3782
+ };
3783
+ }
3784
+ //#endregion
3785
+ //#region src/web/client/ToolCardShell.tsx
3786
+ /**
3787
+ * The shared card shell every row renders through.
3788
+ *
3789
+ * The shipped `ToolRow` is not reusable from a plugin (ui-tool is not part of
3790
+ * the client platform baseline), so this is our own: one 24px header line
3791
+ * (state dot + title + separator + ellipsized summary + optional `+N -M`
3792
+ * suffix + clickable path), a keyboard-reachable disclosure, and a body that
3793
+ * is either the card a row built or the flattened result text.
3794
+ *
3795
+ * Two properties matter more than the visuals:
3796
+ * - **Never throw.** Every value is coerced defensively, and {@link CardBoundary}
3797
+ * turns a crash inside a card into the same generic shell, because an
3798
+ * exception reaching the slot retires the cell and abdicates the call to the
3799
+ * shipped row.
3800
+ * - **Theme tokens only.** The sheet below reads `--dsw-alias-*` (plus the two
3801
+ * published content-font vars) and hard-codes no colour, so light and dark
3802
+ * both come out right.
3803
+ * @module @xiaoso/dsh-tool-plus/web/client/ToolCardShell
3804
+ */
3805
+ /** Chrome CSS, keyed by `data-plugin-css` and injected once per page. */
3806
+ const CSS = `
3807
+ .twc-root{display:flex;flex-direction:column;min-width:0}
3808
+ .twc-row{position:relative;min-width:0}
3809
+ .twc-leading{flex-shrink:0}
3810
+ .twc-chevron{color:var(--dsw-alias-label-secondary)}
3811
+ .twc-title{font-weight:400}
3812
+ .twc-sep{flex:none;width:2px;height:2px;border-radius:1px;margin:0 8px;background:var(--dsw-alias-label-caption)}
3813
+ .twc-summary{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(24px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary)}
3814
+ .twc-summarySuffix{flex:none;margin-left:4px;white-space:nowrap;font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(24px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-tertiary)}
3815
+ .twc-diffStat{margin-left:10px;font-family:var(--ds-font-family-code);font-size:calc(var(--dsh-content-font-size-secondary,13px) - 2px);color:var(--dsw-alias-label-caption)}
3816
+ .twc-errorSummary{color:var(--dsw-alias-state-error-primary)}
3817
+ .twc-fileLink{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0;padding:0;border:none;background:none;font:inherit;text-align:left;font-size:var(--dsh-content-font-size-secondary,13px);line-height:calc(24px + var(--dsh-content-font-delta,0px));color:var(--dsw-alias-label-secondary);text-decoration:underline dotted;text-decoration-color:var(--dsw-alias-label-tertiary);text-decoration-thickness:1px;text-underline-offset:3px;cursor:pointer;border-radius:4px}
3818
+ .twc-fileLink:hover{color:var(--dsw-alias-label-primary);text-decoration-color:currentColor}
3819
+ .twc-fileLink:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
3820
+ .twc-bodyWrap{display:flex;flex-direction:column;min-width:0}
3821
+ .twc-body{margin:4px 0 4px 4px;min-width:0}
3822
+ .twc-text{margin:4px 0 4px 4px;padding:10px 14px;max-height:260px;overflow:auto;border-radius:12px;background:var(--dsw-alias-markdown-code-block);color:var(--dsw-alias-label-secondary);font-family:var(--ds-font-family-code);font-size:12px;line-height:18px;white-space:pre-wrap;word-break:break-word}
3823
+ .twc-note{margin:4px 0 4px 4px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
3824
+ .twc-media{display:flex;flex-direction:column;align-items:flex-start;gap:8px;margin:4px 0 4px 4px;min-width:0}
3825
+ /* Image gallery: the wrapping group the message gallery uses, so a card's
3826
+ images and a message's images wrap the same way. */
3827
+ .twc-imgGallery{display:flex;flex-wrap:wrap;gap:10px;max-width:100%;min-width:0}
3828
+ /* Thumbnail frame (figma message-image rule): a 16px-radius tile that crops
3829
+ with object-fit cover, sized inline for a lone image and 64×64 in a gallery. */
3830
+ .twc-imgFrame{display:grid;flex:0 0 auto;place-items:center;box-sizing:border-box;min-width:44px;min-height:44px;padding:0;overflow:hidden;border:0.5px solid var(--dsw-alias-border-l2-darkmode-thin);border-radius:16px;background:var(--dsw-alias-interactive-bg-hover);cursor:zoom-in}
3831
+ .twc-imgFrame[data-variant='tile']{width:64px;height:64px;min-width:64px;min-height:64px}
3832
+ .twc-imgFrame:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
3833
+ .twc-mediaImg{display:block;width:100%;height:100%;object-fit:cover}
3834
+ .twc-imgLoading{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
3835
+ /* Failed load: the frame itself becomes the retry control (the official
3836
+ behaviour), so a transient failure costs one click instead of a reload. It
3837
+ carries the frame's own box model — border-box, same 44px floor — so the
3838
+ reserved box survives loading → loaded → failed unchanged (no jump). */
3839
+ .twc-imgError{box-sizing:border-box;min-width:44px;min-height:44px;max-width:240px;padding:10px 12px;overflow:hidden;border:0.5px solid var(--dsw-alias-border-l2-darkmode-thin);border-radius:10px;background:var(--dsw-alias-interactive-bg-hover-danger);color:var(--dsw-alias-label-tertiary);font:inherit;font-size:12px;line-height:18px;text-align:left;cursor:pointer}
3840
+ .twc-imgError[data-variant='tile']{width:64px;height:64px;min-width:64px;min-height:64px;padding:4px;border-radius:16px}
3841
+ .twc-imgError:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
3842
+ /* Original-image lightbox: a body portal, a mask on its own layer (blurring
3843
+ the backdrop itself would blur the preview and the close control), and the
3844
+ preview stacked with its caption so the two always read as one object. */
3845
+ .twc-lightbox{position:fixed;inset:0;z-index:1000;display:grid;place-items:center;padding:40px}
3846
+ .twc-lightboxMask{position:absolute;inset:0;background:var(--dsw-alias-bg-mask-1);backdrop-filter:var(--dsw-mask-blur)}
3847
+ .twc-lightboxStack{position:relative;display:flex;flex-direction:column;align-items:center;gap:10px;max-height:calc(100vh - 80px);min-width:0}
3848
+ .twc-lightboxImg{max-width:min(100%,1600px);max-height:calc(100vh - 130px);object-fit:contain;border-radius:12px;background:var(--dsw-specific-input-major);box-shadow:var(--dsw-shadow-lv3)}
3849
+ .twc-lightboxClose{position:fixed;top:20px;right:20px;z-index:1;display:grid;place-items:center;width:36px;height:36px;border:0.5px solid var(--dsw-alias-border-l2-darkmode-thin);border-radius:999px;background:var(--dsw-specific-input-major);color:var(--dsw-alias-label-primary);cursor:pointer}
3850
+ .twc-lightboxClose:hover{background:var(--dsw-alias-interactive-bg-hover-solid)}
3851
+ .twc-lightboxClose:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
3852
+ /* The caption carries the reference's own numbers — name, intrinsic size,
3853
+ byte length — so the enlarged view states what the file actually is. It sits
3854
+ in the stack under the image: fixed to the viewport it read as a separate
3855
+ object from a centred image, and a tall image could squeeze it out. */
3856
+ .twc-lightboxCaption{margin:0;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:5px 14px;border:0.5px solid var(--dsw-alias-border-l2-darkmode-thin);border-radius:999px;background:var(--dsw-specific-input-major);color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px}
3857
+ /* Motion is feedback, never decoration: the hover lift, the skeleton pulse,
3858
+ and the lightbox entrance all sit behind the user's motion preference. A
3859
+ reduced-motion reader keeps every state change, just without the glide. */
3860
+ .twc-imgFrame:hover{box-shadow:var(--dsw-shadow-lv1-blur)}
3861
+ @media (prefers-reduced-motion: no-preference){
3862
+ .twc-imgFrame{transition:transform 160ms ease,box-shadow 160ms ease}
3863
+ .twc-imgFrame:hover{transform:scale(1.01)}
3864
+ .twc-imgFrame:active{transform:scale(0.99)}
3865
+ .twc-imgLoading{animation:twc-img-pulse 1.6s ease-in-out infinite}
3866
+ .twc-lightbox{animation:twc-lightbox-in 160ms ease-out}
3867
+ .twc-lightboxImg{animation:twc-lightbox-img-in 180ms ease-out}
3868
+ @keyframes twc-img-pulse{0%,100%{opacity:.55}50%{opacity:1}}
3869
+ @keyframes twc-lightbox-in{from{opacity:0}to{opacity:1}}
3870
+ @keyframes twc-lightbox-img-in{from{opacity:0;transform:scale(.98)}to{opacity:1;transform:scale(1)}}
3871
+ }
3872
+ .twc-inspect{display:inline-flex;align-self:flex-start;align-items:center;gap:4px;margin:4px 0 2px 4px;padding:2px 8px;border:0.5px solid var(--dsw-alias-border-l3);border-radius:999px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;line-height:16px;cursor:pointer;opacity:0;transition:opacity 100ms ease}
3873
+ .twc-root:hover .twc-inspect,.twc-inspect:focus-visible{opacity:1}
3874
+ .twc-inspect:hover{background:var(--dsw-alias-interactive-bg-hover-solid);color:var(--dsw-alias-label-primary)}
3875
+ .twc-visuallyHidden{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
3876
+ .twc-fallbackDot{flex:none;width:8px;height:8px;border-radius:999px;background:var(--dsw-alias-state-error-primary)}
3877
+ @media (prefers-reduced-motion: reduce){.twc-inspect{transition:none}}
3878
+ `;
3879
+ /** Inject the card stylesheet once per page; the loader drops plugin style tags on unload. */
3880
+ let cssInjected = false;
3881
+ function injectCss() {
3882
+ if (cssInjected) return;
3883
+ if (typeof document === "undefined") return;
3884
+ cssInjected = true;
3885
+ const id = "tool-plus-cards";
3886
+ if (document.querySelector("style[data-plugin-css=\"tool-plus-cards\"]") !== null) return;
3887
+ const tag = document.createElement("style");
3888
+ tag.dataset.plugin = "@xiaoso/dsh-tool-plus";
3889
+ tag.dataset.pluginCss = id;
3890
+ tag.textContent = CSS;
3891
+ document.head.appendChild(tag);
3892
+ }
3893
+ /** Whether a React node would draw anything. */
3894
+ function hasContent(node) {
3895
+ return node !== void 0 && node !== null && node !== false;
3896
+ }
3897
+ /**
3898
+ * Render the shared card shell.
3899
+ * @param props - see {@link ToolCardShellProps}.
3900
+ * @returns the card row and, when expanded, its body.
3901
+ */
3902
+ function ToolCardShell(props) {
3903
+ injectCss();
3904
+ const { t, state, title, children, filePath, openFile, inspect } = props;
3905
+ const [expanded, setExpanded] = (0, react.useState)(props.defaultOpen === true);
3906
+ const bodyCard = hasContent(children) ? children : null;
3907
+ const bodyText = typeof props.text === "string" && props.text !== "" ? props.text : null;
3908
+ const expandable = bodyCard !== null || bodyText !== null;
3909
+ const open = expanded && expandable;
3910
+ const status = stateStatus(state, t);
3911
+ const summaryText = typeof props.summary === "string" ? props.summary : "";
3912
+ const suffix = typeof props.summarySuffix === "string" && props.summarySuffix !== "" ? props.summarySuffix : null;
3913
+ const fileLink = filePath !== void 0 && filePath !== null && filePath !== "" && openFile !== void 0;
3914
+ const errorTone = props.summaryTone === "error";
3915
+ const toggle = () => {
3916
+ setExpanded((value) => !value);
3917
+ };
3918
+ const openPath = (event) => {
3919
+ event.stopPropagation();
3920
+ if (!fileLink || openFile === void 0) return;
3921
+ const line = props.filePathLine;
3922
+ if (typeof line === "number") openFile(filePath, { line });
3923
+ else openFile(filePath);
3924
+ };
3925
+ const openPathKeyDown = (event) => {
3926
+ if (event.key === "Enter" || event.key === " ") event.stopPropagation();
3927
+ };
3928
+ const collapsed = summaryText === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
3929
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3930
+ className: "twc-sep",
3931
+ "aria-hidden": true
3932
+ }),
3933
+ fileLink ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3934
+ type: "button",
3935
+ className: "twc-fileLink",
3936
+ onClick: openPath,
3937
+ onKeyDown: openPathKeyDown,
3938
+ children: summaryText
3939
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3940
+ className: errorTone ? "twc-summary twc-errorSummary" : "twc-summary",
3941
+ children: summaryText
3942
+ }),
3943
+ suffix !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3944
+ className: "twc-summarySuffix",
3945
+ children: suffix
3946
+ })
3947
+ ] });
3948
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3949
+ className: "twc-root",
3950
+ "data-state": state,
3951
+ "data-tool": title,
3952
+ children: [status !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
3953
+ className: "twc-visuallyHidden",
3954
+ children: status
3955
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.DisclosureRow, {
3956
+ rowClassName: "twc-row",
3957
+ leadingClassName: "twc-leading",
3958
+ titleClassName: "twc-title",
3959
+ chevronClassName: "twc-chevron",
3960
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, { state: dotState(state) }),
3961
+ title,
3962
+ open,
3963
+ expandable,
3964
+ expandOnRowClick: true,
3965
+ keepContentWhenOpen: true,
3966
+ onToggle: toggle,
3967
+ collapsedContent: collapsed,
3968
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3969
+ className: "twc-bodyWrap",
3970
+ children: [bodyCard ?? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
3971
+ className: "twc-text",
3972
+ children: bodyText
3973
+ }), inspect !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3974
+ type: "button",
3975
+ className: "twc-inspect",
3976
+ onClick: inspect,
3977
+ children: t("inspect")
3978
+ })]
3979
+ })
3980
+ })]
3981
+ });
3982
+ }
3983
+ /**
3984
+ * The generic card: every row's degradation path.
3985
+ *
3986
+ * A card is only drawn once its metadata passed a `narrow*` guard, so anything
3987
+ * else — a running call, a window-truncated call, a Code Dispatch child, a
3988
+ * failure, a payload from another plugin version — lands here with the call's
3989
+ * own title, run state, argument count, and result text.
3990
+ * @param props - see {@link GenericCardProps}.
3991
+ * @returns the generic shell.
3992
+ */
3993
+ function GenericCard({ t, title, block, args, inspect }) {
3994
+ const state = cardState(block);
3995
+ const output = resultText(block);
3996
+ const failure = state === "error" ? firstLine(output) : "";
3997
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolCardShell, {
3998
+ t,
3999
+ state,
4000
+ title,
4001
+ summary: failure === "" ? argsSummary(args, t) : failure,
4002
+ summaryTone: failure === "" ? "default" : "error",
4003
+ text: output,
4004
+ inspect
4005
+ });
4006
+ }
4007
+ /** Best-effort message for a thrown value; never throws itself. */
4008
+ function failureText(error) {
4009
+ try {
4010
+ if (error instanceof Error && typeof error.message === "string" && error.message !== "") return error.message;
4011
+ if (typeof error === "string" && error !== "") return error;
4012
+ } catch {}
4013
+ return "Unknown card error";
4014
+ }
4015
+ /**
4016
+ * Contain a card's render failure: the fallback is a generic shell (never the
4017
+ * card's own chrome), so one malformed payload cannot take the call's cell —
4018
+ * or the whole slot — down with it.
4019
+ */
4020
+ var CardBoundary = class extends react.Component {
4021
+ state = { failure: null };
4022
+ /** React's error-boundary hook: record the failure and render the fallback. */
4023
+ static getDerivedStateFromError(error) {
4024
+ return { failure: failureText(error) };
4025
+ }
4026
+ /** Report the failure without rethrowing. */
4027
+ componentDidCatch(error) {
4028
+ console.error("[tool-plus] a web tool card failed to render; showing the generic card instead.", error);
4029
+ }
4030
+ render() {
4031
+ const { failure } = this.state;
4032
+ if (failure === null) return this.props.children;
4033
+ const { t, title, block, summary } = this.props;
4034
+ let text = null;
4035
+ try {
4036
+ const output = block === void 0 ? "" : resultText(block);
4037
+ text = output === "" ? null : output;
4038
+ } catch {
4039
+ text = null;
4040
+ }
4041
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolCardShell, {
4042
+ t,
4043
+ state: "error",
4044
+ title,
4045
+ summary: failure === "" ? summary ?? null : firstLine(failure),
4046
+ summaryTone: "error",
4047
+ text
4048
+ });
4049
+ }
4050
+ };
4051
+ //#endregion
4052
+ //#region src/web/client/rows/AstEditRow.tsx
4053
+ /**
4054
+ * `ast_edit` row: the engine's own change preview.
4055
+ *
4056
+ * The AST engine already renders a grouped preview (file header, replacement
4057
+ * count, the first line of each change) and hands it to the Host as
4058
+ * `displayContent`; that text is what this card shows, because the exact
4059
+ * before/after hunks are dropped inside the ported tool and the write happens
4060
+ * in the native engine. No diff is computed here, and no `±` totals are shown.
4061
+ * @module @xiaoso/dsh-tool-plus/web/client/rows/AstEditRow
4062
+ */
4063
+ /** The engine reports parse failures in its result text, not in the metadata. */
4064
+ const PARSE_ERROR = /parse error/i;
4065
+ /**
4066
+ * Render an `ast_edit` call as a change preview.
4067
+ * @param props - the slot's owner props and this plugin's translate seat.
4068
+ * @returns the preview card, or the generic shell when there is no preview.
4069
+ */
4070
+ function AstEditRow(props) {
4071
+ const { t, block } = props;
4072
+ const title = cardTitle(props.toolName, t, "title.astEdit");
4073
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardBoundary, {
4074
+ t,
4075
+ title,
4076
+ block,
4077
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AstEditCard, {
4078
+ ...props,
4079
+ title
4080
+ })
4081
+ });
4082
+ }
4083
+ /**
4084
+ * The card itself, inside the boundary.
4085
+ * @param props - row props plus the resolved title.
4086
+ * @returns the preview card.
4087
+ */
4088
+ function AstEditCard(props) {
4089
+ const { t, block, cwd, inspect, openFile, title } = props;
4090
+ const args = parseCardArgs(block);
4091
+ const meta = narrowAstEditCardMeta(cardMeta(block));
4092
+ if (isSubCall(block) || block.isError === true || meta === null || meta.preview === "") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GenericCard, {
4093
+ t,
4094
+ title,
4095
+ block,
4096
+ args,
4097
+ inspect
4098
+ });
4099
+ const files = meta.files.length;
4100
+ const single = files === 1 ? meta.files[0]?.path : void 0;
4101
+ const output = resultText(block);
4102
+ const parseErrors = PARSE_ERROR.test(output);
4103
+ const summary = single !== void 0 && single !== "" ? displayPath(single, cwd) : t("astEdit.files", { count: files });
4104
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolCardShell, {
4105
+ t,
4106
+ state: cardState(block),
4107
+ title,
4108
+ summary,
4109
+ summarySuffix: `${t("astEdit.replacements", { count: meta.replacements })} · ${t("astEdit.files", { count: files })}`,
4110
+ filePath: single,
4111
+ openFile,
4112
+ inspect,
4113
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4114
+ className: "twc-body",
4115
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.CodeBlock, {
4116
+ code: meta.preview,
4117
+ copyLabel: t("copy"),
4118
+ copiedLabel: t("copied")
4119
+ }), output !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4120
+ className: "twc-note",
4121
+ children: parseErrors ? `${t("astEdit.parseErrors")}: ${output}` : output
4122
+ })]
4123
+ })
4124
+ });
4125
+ }
4126
+ //#endregion
4127
+ //#region src/web/client/rows/FileMutationRow.tsx
4128
+ /**
4129
+ * `edit` row: a diff card.
4130
+ *
4131
+ * Two sources, in order: the Host projection (`meta.diffs`, built from the
4132
+ * engine's own unified diff — the change is already computed and needs no
4133
+ * extra I/O), then the replace-form arguments while the call is still running
4134
+ * (or when the Host could not project a diff). Every other edit mode (patch,
4135
+ * hashline, apply_patch) has no diff until it settles, so it falls back to the
4136
+ * generic shell rather than guessing from `patch`/`input` text.
4137
+ * @module @xiaoso/dsh-tool-plus/web/client/rows/FileMutationRow
4138
+ */
4139
+ /**
4140
+ * Render an `edit` call as a diff card.
4141
+ * @param props - the slot's owner props and this plugin's translate seat.
4142
+ * @returns the diff card, or the generic shell for a mode with no diff yet.
4143
+ */
4144
+ function FileMutationRow(props) {
4145
+ const { t, block } = props;
4146
+ const title = cardTitle(props.toolName, t, "title.edit");
4147
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardBoundary, {
4148
+ t,
4149
+ title,
4150
+ block,
4151
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(FileMutationCard, {
4152
+ ...props,
4153
+ title
4154
+ })
4155
+ });
4156
+ }
4157
+ /**
4158
+ * The card itself, inside the boundary.
4159
+ * @param props - row props plus the resolved title.
4160
+ * @returns the diff card.
4161
+ */
4162
+ function FileMutationCard(props) {
4163
+ const { t, block, cwd, inspect, openFile, title } = props;
4164
+ const args = parseCardArgs(block);
4165
+ const meta = narrowEditCardMeta(cardMeta(block));
4166
+ const replaceForm = replaceHunks(args);
4167
+ if (isSubCall(block) || block.isError === true || meta === null && replaceForm === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GenericCard, {
4168
+ t,
4169
+ title,
4170
+ block,
4171
+ args,
4172
+ inspect
4173
+ });
4174
+ const diffs = meta === null ? replaceForm ?? [] : meta.diffs;
4175
+ const paths = [...new Set(diffs.map((hunk) => hunk.path))];
4176
+ const single = paths.length === 1 ? paths[0] : void 0;
4177
+ const totals = (0, _deepseek_ai_dsh_client_ui_primitives.diffTotals)(diffs);
4178
+ const summary = single === void 0 ? t("files.other", { count: paths.length }) : displayPath(single, cwd);
4179
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolCardShell, {
4180
+ t,
4181
+ state: cardState(block),
4182
+ title,
4183
+ summary,
4184
+ summarySuffix: `+${totals.added} -${totals.removed}`,
4185
+ filePath: single,
4186
+ openFile,
4187
+ inspect,
4188
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.DiffBlock, {
4189
+ diffs,
4190
+ labels: diffBlockLabels(t),
4191
+ className: "twc-body"
4192
+ })
4193
+ });
4194
+ }
4195
+ /**
4196
+ * The replace form's diff, straight from the call's arguments: one hunk for
4197
+ * `old_string` → `new_string`. The empty `new_string` is a real value (it
4198
+ * deletes), so only the path and `old_string` are required to be non-empty.
4199
+ * @param args - the call's parsed arguments, or `null`.
4200
+ * @returns one hunk, or `null` when the call is not the replace form.
4201
+ */
4202
+ function replaceHunks(args) {
4203
+ const path = argText(args, "file_path");
4204
+ const oldText = argText(args, "old_string");
4205
+ const newText = args?.new_string;
4206
+ if (path === null || oldText === null || typeof newText !== "string") return null;
4207
+ return [{
4208
+ path,
4209
+ oldText,
4210
+ newText
4211
+ }];
4212
+ }
4213
+ //#endregion
4214
+ //#region src/web/client/rows/image-card.ts
4215
+ /** Whether `value` is a plain object. */
4216
+ function isRecord(value) {
4217
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4218
+ }
4219
+ /** A finite number — a `NaN` dimension would only break layout later. */
4220
+ function isFiniteNumber(value) {
4221
+ return typeof value === "number" && Number.isFinite(value);
4222
+ }
4223
+ /**
4224
+ * Display box for a lone image — DSH's own rule, copied from
4225
+ * `ui-attachment/src/MessageImage.tsx::singleFit` so a tool-card image and a
4226
+ * message image of the same file occupy the same box: long edge 240px, the
4227
+ * rendered aspect ratio clamped to [0.25, 4] (the clamped overflow is cropped
4228
+ * by `object-fit: cover`), never upscaled past the natural size, and the crop
4229
+ * anchored to the top of very tall images / the left of very wide ones, where
4230
+ * the informative content usually starts.
4231
+ *
4232
+ * The clamp is deliberately not strict: an exactly 4:1 image stays centred.
4233
+ * @param size - the reference's intrinsic dimensions, in pixels.
4234
+ * @returns the rendered box and the crop anchor.
4235
+ */
4236
+ function imageFitBox(size) {
4237
+ const natural = size.width / size.height;
4238
+ const ratio = Math.min(4, Math.max(.25, natural));
4239
+ const box = ratio >= 1 ? {
4240
+ width: 240,
4241
+ height: 240 / ratio
4242
+ } : {
4243
+ width: 240 * ratio,
4244
+ height: 240
4245
+ };
4246
+ const scale = Math.min(1, size.width / box.width, size.height / box.height);
4247
+ return {
4248
+ width: Math.max(1, Math.round(box.width * scale)),
4249
+ height: Math.max(1, Math.round(box.height * scale)),
4250
+ objectPosition: natural < .25 ? "center top" : natural > 4 ? "left center" : "center"
4251
+ };
4252
+ }
4253
+ /**
4254
+ * The call's arguments.
4255
+ *
4256
+ * A settled node carries the paired call head under `call`, and that is the
4257
+ * only source: the node-level `argsRaw` belongs to a *running* call, so reading
4258
+ * it here would let a truncated settled call (head outside the window) render a
4259
+ * card whose argument summary is empty. `parseCardArgs` narrows the same field
4260
+ * the same way.
4261
+ * @param block - the settled call node.
4262
+ * @returns the parsed argument object, or `null`.
4263
+ */
4264
+ function callArgs(block) {
4265
+ const raw = block.call?.argsRaw;
4266
+ if (typeof raw !== "string") return null;
4267
+ try {
4268
+ const value = JSON.parse(raw);
4269
+ return isRecord(value) ? value : null;
4270
+ } catch {
4271
+ return null;
4272
+ }
4273
+ }
4274
+ /**
4275
+ * Validate one wire attachment object as a durable image reference.
4276
+ *
4277
+ * The store's `ImageAttachmentRef` brands `attachmentId` and `mediaType`; the
4278
+ * result's JSON carries plain strings. This is the single boundary where a
4279
+ * validated wire object becomes that typed reference, field by field — and the
4280
+ * copy keeps the optional fields (`name`, `originalDimensions`) the wire may
4281
+ * carry.
4282
+ * @param value - the image block's `attachment` field.
4283
+ * @returns the typed reference, or `null` when any required field is wrong.
4284
+ */
4285
+ function typedAttachment(value) {
4286
+ if (!isRecord(value)) return null;
4287
+ if (typeof value.attachmentId !== "string") return null;
4288
+ if (typeof value.mediaType !== "string") return null;
4289
+ if (!isFiniteNumber(value.bytes)) return null;
4290
+ if (!isFiniteNumber(value.width)) return null;
4291
+ if (!isFiniteNumber(value.height)) return null;
4292
+ if (value.width <= 0 || value.height <= 0) return null;
4293
+ return { ...value };
4294
+ }
4295
+ /**
4296
+ * Derive the read card's image material from a settled result.
4297
+ *
4298
+ * Declines — rather than guessing — whenever the result would not be fully
4299
+ * representable: a running call, a failed call, a result whose content carries
4300
+ * a block that is neither text nor image, an image whose reference is
4301
+ * malformed, a text-only result (the text card owns those), or a call whose
4302
+ * arguments name no path.
4303
+ * @param block - the frozen call node.
4304
+ * @param cwd - the session workspace root, for shortening the label.
4305
+ * @returns the material, or `null` to leave the row on its existing path.
4306
+ */
4307
+ function imageCardMaterial(block, cwd) {
4308
+ if (!isSettled(block) || block.isError === true) return null;
4309
+ const content = block.content;
4310
+ if (!Array.isArray(content)) return null;
4311
+ const images = [];
4312
+ for (const blockContent of content) {
4313
+ if (!isRecord(blockContent)) return null;
4314
+ if (blockContent.type === "text") continue;
4315
+ if (blockContent.type !== "image") return null;
4316
+ const image = typedAttachment(blockContent.attachment);
4317
+ if (image === null) return null;
4318
+ images.push(image);
4319
+ }
4320
+ if (images.length === 0) return null;
4321
+ const path = callArgs(block)?.path;
4322
+ if (typeof path !== "string" || path.trim() === "") return null;
4323
+ return {
4324
+ label: displayPath(path, cwd),
4325
+ path,
4326
+ images
4327
+ };
4328
+ }
4329
+ //#endregion
4330
+ //#region src/web/client/rows/ReadRow.tsx
4331
+ /**
4332
+ * `read` row: a line-numbered window view, or the image the read returned.
4333
+ *
4334
+ * Two mutually exclusive bodies hang off one call:
4335
+ * - The Host projected a readable window (`meta`) → the line-numbered text card.
4336
+ * - It did not (an image, a directory, a sqlite or archive target, an internal
4337
+ * URL, a multi-range `:raw` read) → this row tries the image branch, which
4338
+ * draws the result's image blocks through the session-authorized loader the
4339
+ * slot hands every tool view. Anything else keeps the generic shell and its
4340
+ * result text.
4341
+ *
4342
+ * The image branch reimplements the shipped message-image interaction — the
4343
+ * `singleFit` box, 64px gallery tiles, a retry control on a failed load, and a
4344
+ * body-portal lightbox for the original — so a tool-card image and a message
4345
+ * image of the same file behave identically. It lives here rather than in
4346
+ * `image-card.ts` because that module stays pure: this file owns the loader
4347
+ * calls, their cancellation, and the DOM.
4348
+ * @module @xiaoso/dsh-tool-plus/web/client/rows/ReadRow
4349
+ */
4350
+ /**
4351
+ * Render a `read` call as a line-numbered window or an image.
4352
+ * @param props - the slot's owner props and this plugin's translate seat.
4353
+ * @returns the read card, or the generic shell when nothing else fits.
4354
+ */
4355
+ function ReadRow(props) {
4356
+ const { t, block } = props;
4357
+ const title = cardTitle(props.toolName, t, "title.read");
4358
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardBoundary, {
4359
+ t,
4360
+ title,
4361
+ block,
4362
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ReadCard, {
4363
+ ...props,
4364
+ title
4365
+ })
4366
+ });
4367
+ }
4368
+ /**
4369
+ * The card itself, inside the boundary.
4370
+ * @param props - row props plus the resolved title.
4371
+ * @returns the read card.
4372
+ */
4373
+ function ReadCard(props) {
4374
+ const { t, block, cwd, inspect, openFile, title } = props;
4375
+ const args = parseCardArgs(block);
4376
+ const meta = narrowReadCardMeta(cardMeta(block));
4377
+ const material = (0, react.useMemo)(() => imageCardMaterial(block, cwd), [block, cwd]);
4378
+ if (isSubCall(block) || block.isError === true) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GenericCard, {
4379
+ t,
4380
+ title,
4381
+ block,
4382
+ args,
4383
+ inspect
4384
+ });
4385
+ if (meta === null) {
4386
+ if (material === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GenericCard, {
4387
+ t,
4388
+ title,
4389
+ block,
4390
+ args,
4391
+ inspect
4392
+ });
4393
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageReadCard, {
4394
+ t,
4395
+ block,
4396
+ material,
4397
+ title,
4398
+ inspect,
4399
+ openFile,
4400
+ loadImage: props.loadImage
4401
+ });
4402
+ }
4403
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolCardShell, {
4404
+ t,
4405
+ state: cardState(block),
4406
+ title,
4407
+ summary: displayPath(meta.path, cwd),
4408
+ filePath: meta.path,
4409
+ filePathLine: meta.offset,
4410
+ openFile,
4411
+ inspect,
4412
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.ReadBlock, {
4413
+ label: displayPath(meta.path, cwd),
4414
+ lines: meta.lines,
4415
+ totalLines: meta.totalLines,
4416
+ lang: meta.lang,
4417
+ labels: readBlockLabels(t),
4418
+ className: "twc-body"
4419
+ })
4420
+ });
4421
+ }
4422
+ /**
4423
+ * Ask the loader's synchronous cache for an already-known URL.
4424
+ * @param loader - the host's image loader.
4425
+ * @param attachment - the reference to resolve.
4426
+ * @returns the cached URL, or `null` when there is none.
4427
+ */
4428
+ function peekUrl(loader, attachment) {
4429
+ if (loader?.peek === void 0) return null;
4430
+ try {
4431
+ const url = loader.peek(attachment);
4432
+ return typeof url === "string" && url !== "" ? url : null;
4433
+ } catch {
4434
+ return null;
4435
+ }
4436
+ }
4437
+ /** Whether two per-image states are the same, so state identity can be kept. */
4438
+ function sameImageState(left, right) {
4439
+ return left.url === right.url && left.failed === right.failed;
4440
+ }
4441
+ /**
4442
+ * The image body: one framed thumbnail per reference over the result's own
4443
+ * envelope text.
4444
+ *
4445
+ * A rejected load or a missing loader leaves that image's frame as a retry
4446
+ * control and the text in place: an image card that cannot show its picture
4447
+ * must still show that the call returned one.
4448
+ * @param props - see {@link ImageReadCardProps}.
4449
+ * @returns the image card.
4450
+ */
4451
+ function ImageReadCard({ t, block, material, title, inspect, openFile, loadImage }) {
4452
+ const images = material.images;
4453
+ const text = resultText(block);
4454
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(ToolCardShell, {
4455
+ t,
4456
+ state: cardState(block),
4457
+ title,
4458
+ summary: material.label,
4459
+ filePath: material.path,
4460
+ openFile,
4461
+ inspect,
4462
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4463
+ className: "twc-media",
4464
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4465
+ className: "twc-imgGallery",
4466
+ children: images.map((image, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardImage, {
4467
+ t,
4468
+ image,
4469
+ single: images.length === 1,
4470
+ loadImage
4471
+ }, `${image.attachmentId}:${index}`))
4472
+ })
4473
+ }), text !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4474
+ className: "twc-note",
4475
+ children: text
4476
+ })]
4477
+ });
4478
+ }
4479
+ /**
4480
+ * One image: a framed thumbnail that opens the original.
4481
+ *
4482
+ * The load runs under a liveness guard and is re-armed by {@link CardImage}'s
4483
+ * retry counter, so a failed load costs one click rather than a page reload.
4484
+ * @param props - see {@link CardImageProps}.
4485
+ * @returns the frame, or the retry control when the load failed.
4486
+ */
4487
+ function CardImage({ t, image, single, loadImage }) {
4488
+ const [state, setState] = (0, react.useState)(() => ({
4489
+ url: peekUrl(loadImage, image),
4490
+ failed: false
4491
+ }));
4492
+ const [attempt, setAttempt] = (0, react.useState)(0);
4493
+ const [open, setOpen] = (0, react.useState)(false);
4494
+ const close = (0, react.useCallback)(() => {
4495
+ setOpen(false);
4496
+ }, []);
4497
+ const retry = (0, react.useCallback)(() => {
4498
+ setAttempt((value) => value + 1);
4499
+ }, []);
4500
+ const frameRef = (0, react.useRef)(null);
4501
+ const focusOpener = (0, react.useCallback)(() => {
4502
+ frameRef.current?.focus();
4503
+ }, []);
4504
+ (0, react.useEffect)(() => {
4505
+ let live = true;
4506
+ setState((previous) => previous.failed ? {
4507
+ url: peekUrl(loadImage, image),
4508
+ failed: false
4509
+ } : previous);
4510
+ const load = async () => {
4511
+ if (loadImage === void 0) {
4512
+ if (live) setState((previous) => previous.failed ? previous : {
4513
+ url: null,
4514
+ failed: true
4515
+ });
4516
+ return;
4517
+ }
4518
+ try {
4519
+ const url = await loadImage(image);
4520
+ const next = typeof url === "string" && url !== "" ? {
4521
+ url,
4522
+ failed: false
4523
+ } : {
4524
+ url: null,
4525
+ failed: true
4526
+ };
4527
+ if (live) setState((previous) => sameImageState(previous, next) ? previous : next);
4528
+ } catch {
4529
+ if (live) setState((previous) => previous.failed ? previous : {
4530
+ url: null,
4531
+ failed: true
4532
+ });
4533
+ }
4534
+ };
4535
+ load();
4536
+ return () => {
4537
+ live = false;
4538
+ };
4539
+ }, [
4540
+ image,
4541
+ loadImage,
4542
+ attempt
4543
+ ]);
4544
+ const variant = single ? "single" : "tile";
4545
+ const name = image.name ?? t("image.unnamed");
4546
+ const fit = (0, react.useMemo)(() => imageFitBox(image), [image]);
4547
+ if (state.failed) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4548
+ type: "button",
4549
+ className: "twc-imgError",
4550
+ "data-variant": variant,
4551
+ style: single ? {
4552
+ width: fit.width,
4553
+ height: fit.height
4554
+ } : void 0,
4555
+ title: t("image.loadFailed"),
4556
+ "aria-label": t("image.loadFailed"),
4557
+ onClick: retry,
4558
+ children: t("image.loadFailed")
4559
+ });
4560
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4561
+ ref: frameRef,
4562
+ type: "button",
4563
+ className: "twc-imgFrame",
4564
+ "data-variant": variant,
4565
+ style: single ? {
4566
+ width: fit.width,
4567
+ height: fit.height
4568
+ } : void 0,
4569
+ title: t("image.open"),
4570
+ "aria-label": t("image.openNamed", { name }),
4571
+ onClick: () => {
4572
+ if (state.url !== null) setOpen(true);
4573
+ },
4574
+ children: state.url === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
4575
+ className: "twc-imgLoading",
4576
+ children: t("image.loading")
4577
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
4578
+ className: "twc-mediaImg",
4579
+ src: state.url,
4580
+ alt: name,
4581
+ style: single ? { objectPosition: fit.objectPosition } : void 0
4582
+ })
4583
+ }), open && state.url !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardLightbox, {
4584
+ t,
4585
+ src: state.url,
4586
+ image,
4587
+ name,
4588
+ onClose: close,
4589
+ focusOpener
4590
+ })] });
4591
+ }
4592
+ /**
4593
+ * The document-level original-image preview.
4594
+ *
4595
+ * Closes on Escape, a mask press, or the close control, and restores focus to
4596
+ * the opener on unmount. Rendered through a body portal: an opener inside a
4597
+ * transformed or filtered ancestor would otherwise trap the fixed backdrop in
4598
+ * that ancestor's box instead of covering the viewport.
4599
+ * @param props - see {@link CardLightboxProps}.
4600
+ * @returns the modal preview.
4601
+ */
4602
+ function CardLightbox({ t, src, image, name, onClose, focusOpener }) {
4603
+ const closeRef = (0, react.useRef)(null);
4604
+ (0, react.useEffect)(() => {
4605
+ closeRef.current?.focus();
4606
+ const onKeyDown = (event) => {
4607
+ if (event.key === "Escape") onClose();
4608
+ };
4609
+ window.addEventListener("keydown", onKeyDown);
4610
+ return () => {
4611
+ window.removeEventListener("keydown", onKeyDown);
4612
+ focusOpener();
4613
+ };
4614
+ }, [onClose, focusOpener]);
4615
+ const caption = t("image.caption", {
4616
+ name,
4617
+ width: image.width,
4618
+ height: image.height,
4619
+ size: (0, _deepseek_ai_dsh_client_ui_primitives.fileSizeText)(image.bytes)
4620
+ });
4621
+ return (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4622
+ className: "twc-lightbox",
4623
+ role: "dialog",
4624
+ "aria-modal": "true",
4625
+ "aria-label": t("image.dialog"),
4626
+ children: [
4627
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
4628
+ className: "twc-lightboxMask",
4629
+ "aria-hidden": "true",
4630
+ onMouseDown: (event) => {
4631
+ event.preventDefault();
4632
+ onClose();
4633
+ }
4634
+ }),
4635
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4636
+ className: "twc-lightboxStack",
4637
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
4638
+ className: "twc-lightboxImg",
4639
+ src,
4640
+ alt: name
4641
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4642
+ className: "twc-lightboxCaption",
4643
+ children: caption
4644
+ })]
4645
+ }),
4646
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
4647
+ ref: closeRef,
4648
+ type: "button",
4649
+ className: "twc-lightboxClose",
4650
+ "aria-label": t("image.close"),
4651
+ onClick: onClose,
4652
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCloseOutline16, { size: 16 })
4653
+ })
4654
+ ]
4655
+ }), document.body);
4656
+ }
4657
+ //#endregion
4658
+ //#region src/web/client/rows/SearchRow.tsx
4659
+ /**
4660
+ * `grep` / `glob` / `ast_grep` row: a search result card.
4661
+ *
4662
+ * One component serves all three keys because the Host projects one
4663
+ * `shape`-discriminated metadata form for them: grouped matches for the two
4664
+ * grep engines, a flat path list for glob. The title follows the call's own
4665
+ * wire name, so the same component reads as Grep, Glob, or AST Grep.
4666
+ * @module @xiaoso/dsh-tool-plus/web/client/rows/SearchRow
4667
+ */
4668
+ /**
4669
+ * Render a search call as a result card.
4670
+ * @param props - the slot's owner props and this plugin's translate seat.
4671
+ * @returns the search card, or the generic shell with the result text.
4672
+ */
4673
+ function SearchRow(props) {
4674
+ const { t, block } = props;
4675
+ const title = cardTitle(props.toolName, t, "title.grep");
4676
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardBoundary, {
4677
+ t,
4678
+ title,
4679
+ block,
4680
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SearchCard, {
4681
+ ...props,
4682
+ title
4683
+ })
4684
+ });
4685
+ }
4686
+ /**
4687
+ * The card itself, inside the boundary.
4688
+ * @param props - row props plus the resolved title.
4689
+ * @returns the search card.
4690
+ */
4691
+ function SearchCard(props) {
4692
+ const { t, block, inspect, title } = props;
4693
+ const args = parseCardArgs(block);
4694
+ const meta = narrowSearchCardMeta(cardMeta(block));
4695
+ if (isSubCall(block) || block.isError === true || meta === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GenericCard, {
4696
+ t,
4697
+ title,
4698
+ block,
4699
+ args,
4700
+ inspect
4701
+ });
4702
+ const subject = argText(args, "pattern") ?? argText(args, "pat") ?? argText(args, "path");
4703
+ const labels = searchBlockLabels(t);
4704
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolCardShell, {
4705
+ t,
4706
+ state: cardState(block),
4707
+ title,
4708
+ summary: subject ?? argsSummary(args, t),
4709
+ inspect,
4710
+ children: meta.shape === "matches" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.SearchBlock, {
4711
+ kind: "matches",
4712
+ files: meta.files,
4713
+ truncated: meta.truncated,
4714
+ total: meta.total,
4715
+ labels,
4716
+ className: "twc-body"
4717
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.SearchBlock, {
4718
+ kind: "paths",
4719
+ paths: meta.paths,
4720
+ truncated: meta.truncated,
4721
+ total: meta.total,
4722
+ labels,
4723
+ className: "twc-body"
4724
+ })
4725
+ });
4726
+ }
4727
+ //#endregion
4728
+ //#region src/web/client/rows/TerminalRow.tsx
4729
+ /**
4730
+ * `bash` row: a terminal card for foreground runs, a generic row for jobs.
4731
+ *
4732
+ * The card's command comes from the arguments, its working directory from the
4733
+ * arguments falling back to the Host projection and then the session root, and
4734
+ * its exit status from the Host projection — the trailing `[exit code: N]`
4735
+ * marker is only parsed to keep it out of the output pane (and to recover the
4736
+ * signal name the projection does not carry). A call the Host handed to a
4737
+ * background job shows the job id instead: its result is a hand-off notice,
4738
+ * not a finished command.
4739
+ * @module @xiaoso/dsh-tool-plus/web/client/rows/TerminalRow
4740
+ */
4741
+ /**
4742
+ * Render a `bash` call as a terminal card.
4743
+ * @param props - the slot's owner props and this plugin's translate seat.
4744
+ * @returns the terminal card, the job hand-off row, or the generic shell.
4745
+ */
4746
+ function TerminalRow(props) {
4747
+ const { t, block } = props;
4748
+ const title = cardTitle(props.toolName, t, "title.bash");
4749
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardBoundary, {
4750
+ t,
4751
+ title,
4752
+ block,
4753
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TerminalCard, {
4754
+ ...props,
4755
+ title
4756
+ })
4757
+ });
4758
+ }
4759
+ /**
4760
+ * The card itself, inside the boundary.
4761
+ * @param props - row props plus the resolved title.
4762
+ * @returns the terminal card or the generic shell.
4763
+ */
4764
+ function TerminalCard(props) {
4765
+ const { t, block, cwd: sessionCwd, home, inspect, title } = props;
4766
+ const args = parseCardArgs(block);
4767
+ const meta = narrowTerminalCardMeta(cardMeta(block));
4768
+ const command = argText(args, "command");
4769
+ const state = cardState(block);
4770
+ if (block.isError === true || command === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GenericCard, {
4771
+ t,
4772
+ title,
4773
+ block,
4774
+ args,
4775
+ inspect
4776
+ });
4777
+ if (meta !== null && meta.mode === "background") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolCardShell, {
4778
+ t,
4779
+ state,
4780
+ title,
4781
+ summary: t("bash.background", { jobId: meta.jobId }),
4782
+ text: resultText(block),
4783
+ inspect
4784
+ });
4785
+ const foreground = meta !== null && meta.mode === "foreground" ? meta : null;
4786
+ const status = parseShellStatus(resultText(block));
4787
+ const running = state === "running";
4788
+ const timedOut = status.timedOut || foreground?.timedOut === true;
4789
+ const aborted = foreground?.aborted === true;
4790
+ const interrupted = timedOut || aborted;
4791
+ const exitCode = running || interrupted ? void 0 : typeof foreground?.exitCode === "number" ? foreground.exitCode : typeof status.exitCode === "number" ? status.exitCode : void 0;
4792
+ const workdir = argText(args, "workdir") ?? foreground?.workingDir ?? sessionCwd;
4793
+ const summary = interrupted ? timedOut ? t("timedOut") : t("cancelled") : argText(args, "description") ?? firstLine(command);
4794
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolCardShell, {
4795
+ t,
4796
+ state,
4797
+ title,
4798
+ summary,
4799
+ inspect,
4800
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.TerminalBlock, {
4801
+ command,
4802
+ cwd: workdir,
4803
+ home,
4804
+ output: status.output,
4805
+ exitCode,
4806
+ signal: status.signal,
4807
+ running,
4808
+ labels: terminalBlockLabels(t),
4809
+ className: "twc-body"
4810
+ })
4811
+ });
4812
+ }
4813
+ //#endregion
4814
+ //#region src/web/client/rows/WriteRow.tsx
4815
+ /**
4816
+ * `write` row: a content preview card.
4817
+ *
4818
+ * The engine's write card is a preview of what was written, not a diff, and
4819
+ * the content is in the arguments — so the card is complete while the call is
4820
+ * still running, and the line count is counted on the client exactly as the
4821
+ * engine's own header does. The language hint comes from the Host projection
4822
+ * (`meta.lang`), which is the only part the arguments cannot supply.
4823
+ * @module @xiaoso/dsh-tool-plus/web/client/rows/WriteRow
4824
+ */
4825
+ /**
4826
+ * Render a `write` call as a content preview.
4827
+ * @param props - the slot's owner props and this plugin's translate seat.
4828
+ * @returns the card, or the generic shell when the arguments are unusable.
4829
+ */
4830
+ function WriteRow(props) {
4831
+ const { t, block } = props;
4832
+ const title = cardTitle(props.toolName, t, "title.write");
4833
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardBoundary, {
4834
+ t,
4835
+ title,
4836
+ block,
4837
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(WriteCard, {
4838
+ ...props,
4839
+ title
4840
+ })
4841
+ });
4842
+ }
4843
+ /**
4844
+ * The card itself, inside the boundary.
4845
+ * @param props - row props plus the resolved title.
4846
+ * @returns the preview card.
4847
+ */
4848
+ function WriteCard(props) {
4849
+ const { t, block, cwd, inspect, openFile, title } = props;
4850
+ const args = parseCardArgs(block);
4851
+ const meta = narrowWriteCardMeta(cardMeta(block));
4852
+ const content = argText(args, "content");
4853
+ const path = meta?.path ?? argText(args, "path");
4854
+ if (isSubCall(block) || block.isError === true || content === null || path === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GenericCard, {
4855
+ t,
4856
+ title,
4857
+ block,
4858
+ args,
4859
+ inspect
4860
+ });
4861
+ const lines = t("write.lines", { count: lineCount(content) });
4862
+ const skip = meta?.madeExecutable === true ? ` · ${t("write.madeExecutable")}` : "";
4863
+ const output = resultText(block);
4864
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolCardShell, {
4865
+ t,
4866
+ state: cardState(block),
4867
+ title,
4868
+ summary: displayPath(path, cwd),
4869
+ summarySuffix: lines + skip,
4870
+ filePath: path,
4871
+ openFile,
4872
+ inspect,
4873
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
4874
+ className: "twc-body",
4875
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.CodeBlock, {
4876
+ code: content,
4877
+ lang: meta?.lang,
4878
+ lineNumbers: true,
4879
+ copyLabel: t("copy"),
4880
+ copiedLabel: t("copied")
4881
+ }), output !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
4882
+ className: "twc-note",
4883
+ children: output
4884
+ })]
4885
+ })
4886
+ });
4887
+ }
4888
+ //#endregion
4889
+ //#region src/web/client/registerToolCards.ts
4890
+ /** The shipped atomic tool-view slot every card registers into. */
4891
+ const TOOLVIEW_SLOT = "tool.call.toolview";
4892
+ /** Settings namespace of this plugin (the browser half spells the Host's). */
4893
+ const SETTINGS_NS = "tool-plus";
4894
+ /** Every row component this module owns, for the winner self-check. */
4895
+ const OWN_COMPONENTS = /* @__PURE__ */ new Set([
4896
+ WriteRow,
4897
+ FileMutationRow,
4898
+ AstEditRow,
4899
+ ReadRow,
4900
+ SearchRow,
4901
+ TerminalRow
4902
+ ]);
4903
+ /**
4904
+ * Mount the tool cards.
4905
+ *
4906
+ * Registers the card dictionaries and one entry per tool key, then keeps the
4907
+ * registration in step with the `webCards` switch. Everything
4908
+ * installed here rides the caller's fiber, so plugin unload removes it all.
4909
+ * @param ctx - the browser plugin context.
4910
+ */
4911
+ function registerToolCards(ctx) {
4912
+ ctx.effect(() => ctx.locale.register(CARD_LOCALE_NS, cardLocales), "tool-plus: card dictionaries");
4913
+ let disposeEntries;
4914
+ const installEntries = () => {
4915
+ const disposers = [
4916
+ ctx.slots.register({
4917
+ name: TOOLVIEW_SLOT,
4918
+ key: "write",
4919
+ priority: CARD_TAKEOVER_PRIORITY,
4920
+ locale: CARD_LOCALE_NS
4921
+ }, WriteRow),
4922
+ ctx.slots.register({
4923
+ name: TOOLVIEW_SLOT,
4924
+ key: "edit",
4925
+ priority: CARD_TAKEOVER_PRIORITY,
4926
+ locale: CARD_LOCALE_NS
4927
+ }, FileMutationRow),
4928
+ ctx.slots.register({
4929
+ name: TOOLVIEW_SLOT,
4930
+ key: "ast_edit",
4931
+ priority: CARD_TAKEOVER_PRIORITY,
4932
+ locale: CARD_LOCALE_NS
4933
+ }, AstEditRow),
4934
+ ctx.slots.register({
4935
+ name: TOOLVIEW_SLOT,
4936
+ key: "read",
4937
+ priority: CARD_TAKEOVER_PRIORITY,
4938
+ locale: CARD_LOCALE_NS
4939
+ }, ReadRow),
4940
+ ctx.slots.register({
4941
+ name: TOOLVIEW_SLOT,
4942
+ key: "grep",
4943
+ priority: CARD_TAKEOVER_PRIORITY,
4944
+ locale: CARD_LOCALE_NS
4945
+ }, SearchRow),
4946
+ ctx.slots.register({
4947
+ name: TOOLVIEW_SLOT,
4948
+ key: "glob",
4949
+ priority: CARD_TAKEOVER_PRIORITY,
4950
+ locale: CARD_LOCALE_NS
4951
+ }, SearchRow),
4952
+ ctx.slots.register({
4953
+ name: TOOLVIEW_SLOT,
4954
+ key: "ast_grep",
4955
+ priority: CARD_TAKEOVER_PRIORITY,
4956
+ locale: CARD_LOCALE_NS
4957
+ }, SearchRow),
4958
+ ctx.slots.register({
4959
+ name: TOOLVIEW_SLOT,
4960
+ key: "bash",
4961
+ priority: CARD_TAKEOVER_PRIORITY,
4962
+ locale: CARD_LOCALE_NS
4963
+ }, TerminalRow)
4964
+ ];
4965
+ if (!winnersAreOurs(ctx)) console.error(`[tool-plus] web tool cards did not win ${TOOLVIEW_SLOT} at priority ${String(CARD_TAKEOVER_PRIORITY)} for: ${lostCardKeys(ctx).join(", ")}. Keeping our entries; another component renders those cells.`);
4966
+ return disposers;
4967
+ };
4968
+ const install = () => {
4969
+ if (disposeEntries !== void 0) return;
4970
+ disposeEntries = ctx.slots.inject(TOOLVIEW_SLOT, () => installEntries());
4971
+ };
4972
+ const uninstall = () => {
4973
+ disposeEntries?.();
4974
+ disposeEntries = void 0;
4975
+ };
4976
+ const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NS });
4977
+ ctx.effect(() => {
4978
+ const sync = () => {
4979
+ if (cardsEnabled(scope.getSnapshot().value)) install();
4980
+ else uninstall();
4981
+ };
4982
+ const stop = scope.subscribe(sync);
4983
+ sync();
4984
+ return stop;
4985
+ }, "tool-plus: web card switch");
4986
+ }
4987
+ /**
4988
+ * Whether the cards should be mounted for one settings document.
4989
+ *
4990
+ * An absent field (first load, namespace not exposed to this client, plugin not
4991
+ * yet described) means on: the shipped rows are the fallback, not the target.
4992
+ * @param value - the namespace's last accepted section, when there is one.
4993
+ * @returns `true` unless the field is explicitly `false`.
4994
+ */
4995
+ function cardsEnabled(value) {
4996
+ return value?.webCards !== false;
4997
+ }
4998
+ /**
4999
+ * Read the current cell winners back and report whether every one of them is a
5000
+ * row this module registered.
5001
+ *
5002
+ * Called immediately after registering, while the ledger is synchronously
5003
+ * consistent: a cell whose winner is some other component means our entry did
5004
+ * not take the cell — something outranks it, or a boundary retired it.
5005
+ * @param ctx - the browser plugin context owning this registration.
5006
+ * @returns `true` when every card key resolves to one of this module's rows.
5007
+ */
5008
+ function winnersAreOurs(ctx) {
5009
+ return lostCardKeys(ctx).length === 0;
5010
+ }
5011
+ /**
5012
+ * The card keys whose current cell winner is not one of this module's rows.
5013
+ *
5014
+ * The takeover assumption is unverified host behaviour, so a lost cell is
5015
+ * reported by name rather than swallowed.
5016
+ * @param ctx - the browser plugin context owning this registration.
5017
+ * @returns the lost keys, in contract order (empty when every cell is ours).
5018
+ */
5019
+ function lostCardKeys(ctx) {
5020
+ const winners = ctx.slots.entriesOfSlot(TOOLVIEW_SLOT);
5021
+ const lost = [];
5022
+ for (const key of CARD_TOOL_KEYS) {
5023
+ const winner = winners.find((entry) => entry.options.key === key);
5024
+ if (winner === void 0 || !OWN_COMPONENTS.has(winner.component)) lost.push(key);
5025
+ }
5026
+ return lost;
5027
+ }
5028
+ //#endregion
3004
5029
  //#region src/client/index.ts
3005
5030
  /**
3006
5031
  * Settings namespace of this plugin, spelled here because a client package
@@ -3022,8 +5047,8 @@ window.__ModuleLoader__.load({
3022
5047
  */
3023
5048
  function apply(ctx) {
3024
5049
  ctx.effect(() => ctx.locale.register(BASH_PLUS_LOCALE_NS, {
3025
- zh,
3026
- en
5050
+ zh: zh$1,
5051
+ en: en$1
3027
5052
  }), "tool-plus: card dictionaries");
3028
5053
  const scope = ctx.settingsScope.bind({ namespace: BASH_PLUS_CLIENT_NS });
3029
5054
  ctx.slots.inject("settings.plugin.item", () => ctx.slots.register({
@@ -3032,6 +5057,7 @@ window.__ModuleLoader__.load({
3032
5057
  locale: BASH_PLUS_LOCALE_NS,
3033
5058
  inject: () => ({ scope })
3034
5059
  }, BashPlusCard));
5060
+ registerToolCards(ctx);
3035
5061
  const sectionScope = ctx.settingsScope.bind({ namespace: BASH_PLUS_CLIENT_NS });
3036
5062
  ctx.slots.inject("settings.section", () => ctx.slots.register({
3037
5063
  name: "settings.section",