iterate-plugin 3.13.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,6 +114,8 @@ Both modes share:
114
114
  - **ProgressCapsule** (`shell.overlay`) — popup notification on each round completion / convergence (incl. convergence confirm).
115
115
  - **SettingsPanel** (`settings.section`) — theme toggle, triage-persistence notes, config-management guide (covers `validation.commands` / `language` / `personalization.known_intentional` with an explicit "empty validation.commands = unprotected" warning; a field picker generates the matching `iterate_config write` instruction), **v3.7: a 回读当前配置 (read back current config) button that closes the write → read-back loop** — it copies an `iterate_config read` instruction and, once the model echoes the result into the session, shows the current value of the selected field, runtime status overview (artifact layout + view/cleanup tool guide + disk-snapshot pull), one-click triage data reset.
116
116
  - **Bilingual UI (v3.7)** — every piece of UI chrome (panels, empty states, tabs, badges, settings, stop-reason and completion labels) ships in 简体中文 and English. Pick the language under **Settings → 界面语言 / Interface language**; the choice persists in `localStorage` (`iterate-ui-lang`) and otherwise auto-detects from `navigator.language` (a stored choice always wins, and auto-detection never writes a preference). **Model-facing copy deliberately stays Chinese** — the `build*Instruction` payloads and the copy-paste guides are contract text mirroring `SKILL.md`, so the two expanded guides carry a 「中文(粘贴给模型的契约文案)」 chip instead of looking like a missed translation.
117
+ - **Right-click menu (v3.14, dsh ≥ 0.2.2-alpha.3)** — the iterate entry points now also hang off the **native unified context menus**: right-click a **Session** in the sidebar to `打开 iterate 质量指挥中心` (expands the docked observatory and jumps to the live stream), copy the preflight / dry-run / checkpoint-resume / disk-snapshot instructions, or right-click a **file** (with line) / **directory** to copy a *scoped* dry-run review instruction. Bilingual labels, copy-failure recovery guidance, and a shared success toast; a host without `contextMenus` degrades to no menu items (the docked panel remains the entry point).
118
+ - **Keyboard shortcut (v3.15, dsh ≥ 0.2.2-rc.1)** — the iterate command center is now one keystroke away: **Cmd/Ctrl+Shift+O** opens the docked observatory from anywhere in a session (dsh's new unified keyboard-command capability). The shortcut is registered on dsh's `shortcuts` service with the same command id the right-click "open command center" action carries, so the context menu shows the live keycap and dsh's own shortcut settings page lists it as an editable binding. A host that predates the `shortcuts` service degrades to no keyboard command (the docked panel and the context menu remain the entry points); a conflicting default binding is logged and skipped rather than breaking the plugin.
117
119
 
118
120
  The UI layer is **defensive by design**: it degrades gracefully if any of `slots` / `theme` / `React` is unavailable — it never crashes the client.
119
121
 
@@ -346,14 +348,13 @@ All runtime state lives under `.iterate/` at the project root (can be excluded v
346
348
  `0.1.7-alpha.1`, `0.1.7-alpha.2`, `0.1.7-rc.1`, `0.1.7-rc.2`, `0.2.0-rc.1`,
347
349
  `0.2.0-rc.2`, `0.2.1-alpha.1`, `0.2.1-alpha.2`, `0.2.1-alpha.3`,
348
350
  `0.2.1-alpha.4`, `0.2.1-alpha.5`, `0.2.1-rc.1`, `0.2.1-rc.2`,
349
- `0.2.1-rc.3`, `0.2.1-rc.4`, `0.2.1-rc.5`, `0.2.2-alpha.2` (28 releases,
350
- `0.1.1-rc.1` …
351
- `0.2.2-alpha.2`).
351
+ `0.2.1-rc.3`, `0.2.1-rc.4`, `0.2.1-rc.5`, `0.2.2-alpha.2`,
352
+ `0.2.2-alpha.3`, `0.2.2-rc.1` (30 releases, `0.1.1-rc.1` … `0.2.2-rc.1`).
352
353
  The plugin is built against `@deepseek-ai/dsh-tools` /
353
- `@deepseek-ai/dsh-util-values` `0.2.2-alpha.2` and uses only public contracts
354
+ `@deepseek-ai/dsh-util-values` `0.2.2-rc.1` and uses only public contracts
354
355
  (tool registration, client slots + theme, bundle patch, and — since v3.9 —
355
356
  the session state-record API for session-linked run records).
356
- It also declares `engines.dsh` (`>=0.1.1-rc.1`) so the dsh ≥ 0.2.2-alpha.2
357
+ It also declares `engines.dsh` (`>=0.1.1-rc.1`) so the dsh ≥ 0.2.2-rc.1
357
358
  runtime evaluates compatibility from that single range (earlier runtimes fall
358
359
  back to the `@deepseek-ai/dsh-*` peer versions listed in `package.json`).
359
360
  - **Disposable-Profile evidence (real, `dsh` CLI `0.1.1-rc.1`)**: on a temp
package/README.zh-CN.md CHANGED
@@ -113,6 +113,8 @@ dsh plugin --profile web add iterate-plugin
113
113
  - **进度胶囊 `ProgressCapsule`**(`shell.overlay`)— 每轮完成 / 收敛时右下角弹出通知(含收敛确认)。
114
114
  - **iterate 设置区 `SettingsPanel`**(`settings.section`)— 主题开关、分诊持久化说明、配置管理指引(覆盖 `validation.commands` / `language` / `personalization.known_intentional`,并显式警告"validation.commands 为空 = 本轮不受任何测试保护";字段选择器可生成对应的 `iterate_config write` 指令),**v3.7:新增「回读当前配置」补齐写入 → 回读闭环**——复制 `iterate_config read` 指令,模型把结果回显进会话后,卡片就地展示所选字段的当前值;运行时状态概览(产物布局 + 查看/清理工具指引 + 磁盘快照拉取)、一键清空分诊数据。
115
115
  - **双语 UI(v3.7)**——所有界面文案(面板、空态、页签、徽标、设置、停止原因与完成汇总)均提供简体中文与 English。在**设置 → 界面语言 / Interface language**切换;选择持久化在 `localStorage`(`iterate-ui-lang`),否则按 `navigator.language` 自动探测(已保存的选择始终优先,且自动探测不会写入偏好)。**面向模型的文案有意保持中文**——`build*Instruction` 指令与可粘贴指引是与 `SKILL.md` 对齐的契约文本,因此两处展开的指引带「中文(粘贴给模型的契约文案)」标签,避免被误当成漏翻。
116
+ - **右键菜单(v3.14,dsh ≥ 0.2.2-alpha.3)**——iterate 入口现在也挂在**原生统一右键菜单**上:右键侧边栏的**会话**可 `打开 iterate 质量指挥中心`(展开停靠观测台并跳到实时流)、复制预检 / dry-run / 断点恢复 / 磁盘快照指令;右键**文件**(带行号)/ **目录**可复制**定向** dry-run 审查指令(仅审查该文件/目录)。双语标签、复制失败的自救指引、共享成功 toast;宿主无 `contextMenus` 时降级为无菜单项(停靠面板仍是入口)。
117
+ - **键盘快捷键(v3.15,dsh ≥ 0.2.2-rc.1)**——iterate 指挥中心现在一键可达:任意会话内按 **Cmd/Ctrl+Shift+O** 即可打开停靠观测台(dsh 新增的统一键盘命令能力)。快捷键注册在 dsh 的 `shortcuts` 服务上,与右键「打开指挥中心」动作共用同一命令 id,因此右键菜单会显示实时键帽、dsh 自身的快捷键设置页也会将其列为可编辑绑定。宿主早于 `shortcuts` 服务时降级为无键盘命令(停靠面板与右键菜单仍是入口);默认绑定冲突时仅记录并跳过,绝不拖垮插件。
116
118
 
117
119
  UI 层为**防御式设计**:`slots` / `theme` / `React` 任一不可用时自动降级,不会崩溃客户端。
118
120
 
@@ -336,7 +338,7 @@ validation:
336
338
  ### Node.js / DSH 兼容范围
337
339
 
338
340
  - **Node.js**:`>=20`(`package.json` 的 `engines.node`)。
339
- - **DSH**:`package.json` 的 `dsh.compatibility.dshReleases` 字段是发布兼容性的**唯一真源**(本 README 逐字镜像该字段;两者需同步更新)。以下官方版本声明为 `compatible`:`0.1.1-rc.1`、`0.1.2-alpha.4`、`0.1.2-alpha.5`、`0.1.2-rc.1`、`0.1.3-alpha.1`、`0.1.5-alpha.1`、`0.1.5-alpha.2`、`0.1.5-rc.1`、`0.1.5-rc.2`、`0.1.6-alpha.1`、`0.1.6-alpha.2`、`0.1.7-alpha.1`、`0.1.7-alpha.2`、`0.1.7-rc.1`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-alpha.1`、`0.2.1-alpha.2`、`0.2.1-alpha.3`、`0.2.1-alpha.4`、`0.2.1-alpha.5`、`0.2.1-rc.1`、`0.2.1-rc.2`、`0.2.1-rc.3`、`0.2.1-rc.4`、`0.2.1-rc.5`、`0.2.2-alpha.2`(共 28 个版本,`0.1.1-rc.1` … `0.2.2-alpha.2`)。插件基于 `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.2.2-alpha.2` 构建,只使用公开契约(工具注册、客户端 slots + theme、bundle patch,以及 v3.9 起的会话状态记录接口)。插件同时声明 `engines.dsh`(`>=0.1.1-rc.1`),使 dsh ≥ 0.2.2-alpha.2 运行时只依据该范围判断兼容性(更早的运行时仍回退校验 `package.json` 中列出的 `@deepseek-ai/dsh-*` peer 版本)。
341
+ - **DSH**:`package.json` 的 `dsh.compatibility.dshReleases` 字段是发布兼容性的**唯一真源**(本 README 逐字镜像该字段;两者需同步更新)。以下官方版本声明为 `compatible`:`0.1.1-rc.1`、`0.1.2-alpha.4`、`0.1.2-alpha.5`、`0.1.2-rc.1`、`0.1.3-alpha.1`、`0.1.5-alpha.1`、`0.1.5-alpha.2`、`0.1.5-rc.1`、`0.1.5-rc.2`、`0.1.6-alpha.1`、`0.1.6-alpha.2`、`0.1.7-alpha.1`、`0.1.7-alpha.2`、`0.1.7-rc.1`、`0.1.7-rc.2`、`0.2.0-rc.1`、`0.2.0-rc.2`、`0.2.1-alpha.1`、`0.2.1-alpha.2`、`0.2.1-alpha.3`、`0.2.1-alpha.4`、`0.2.1-alpha.5`、`0.2.1-rc.1`、`0.2.1-rc.2`、`0.2.1-rc.3`、`0.2.1-rc.4`、`0.2.1-rc.5`、`0.2.2-alpha.2`、`0.2.2-alpha.3`、`0.2.2-rc.1`(共 30 个版本,`0.1.1-rc.1` … `0.2.2-rc.1`)。插件基于 `@deepseek-ai/dsh-tools` / `@deepseek-ai/dsh-util-values` `0.2.2-rc.1` 构建,只使用公开契约(工具注册、客户端 slots + theme、bundle patch,以及 v3.9 起的会话状态记录接口)。插件同时声明 `engines.dsh`(`>=0.1.1-rc.1`),使 dsh ≥ 0.2.2-rc.1 运行时只依据该范围判断兼容性(更早的运行时仍回退校验 `package.json` 中列出的 `@deepseek-ai/dsh-*` peer 版本)。
340
342
  - **一次性 Profile 证据(真实,`dsh` CLI `0.1.1-rc.1`)**:在临时 `$DSH_HOME` 下
341
343
  `dsh plugin --profile <p> add <本仓库>` 约 449ms 完成安装;`dsh --profile <p>
342
344
  --dump-config` 正确合成 `iterate-plugin` bundle patch;`dsh plugin --profile <p>
package/lib/client.js CHANGED
@@ -1,4 +1,4 @@
1
- /* iterate-plugin client source-digest: 909db487a440ae131a6de59f253a9d84b240e5fbd55bce9ae83a9876b711a9a2 */
1
+ /* iterate-plugin client source-digest: 4f3bcac77cef83b80ea8fa2387f254121b9f94f7a2a2ca9422dd6342581c3463 */
2
2
  window.__ModuleLoader__.load({ id: "iterate-plugin", factory: (require) => {
3
3
  var module = { exports: {} };
4
4
  var exports = module.exports;
@@ -1250,6 +1250,13 @@ var START_INSTRUCTIONS = {
1250
1250
  full: START_INSTRUCTION_FULL,
1251
1251
  reviewOnly: START_INSTRUCTION_REVIEW_ONLY
1252
1252
  };
1253
+ function buildScopedReviewInstruction(scope) {
1254
+ const hasFile = scope && typeof scope.file === "string" && scope.file.length > 0;
1255
+ const hasDir = !hasFile && scope && typeof scope.dir === "string" && scope.dir.length > 0;
1256
+ if (!hasFile && !hasDir) return "";
1257
+ const subject = hasFile ? `\u6587\u4EF6 \`${scope.file}\`${typeof scope.line === "number" && scope.line > 0 ? `\uFF08\u7B2C ${scope.line} \u884C\uFF09` : ""}` : `\u76EE\u5F55 \`${scope.dir}\``;
1258
+ return `\u5F00\u59CB\u4E00\u6B21\u4EC5\u8BC4\u5BA1\uFF08dry-run\uFF0C\u4E0D\u4FEE\u6539\u4EFB\u4F55\u6587\u4EF6\uFF09\uFF0C\u5BA1\u67E5\u8303\u56F4\u9650\u5B9A\u5728${subject}\uFF1A\u8BF7\u8C03\u7528 \`workflow\` \u5DE5\u5177\u8FD0\u884C iterate \u5DE5\u4F5C\u6D41\uFF0Cmode: "dry-run"\uFF0C\u6309\u7CFB\u7EDF\u63D0\u793A\u7684 Iterate Workflow \u6267\u884C plan \u2192 \u8BC4\u5BA1 \u2192 \u5206\u8BCA \u2192 \`iterate_review aggregate\` / \`meta-review\` \u7EC8\u62A5\u5E76 \`iterate_transcript\` capture\uFF1B\u8BC4\u5BA1\u65F6\u628A\u8303\u56F4\u9650\u5B9A\u5728${subject}\uFF0C\u5176\u4F59\u6587\u4EF6\u4EC5\u4F5C\u4E0A\u4E0B\u6587\u3001\u4E0D\u5F97\u7ED9\u51FA\u4FEE\u590D\u3002\u5168\u7A0B\u4E0D\u8981\u8C03\u7528 \`iterate_fix\` / \`iterate_rollback\`\u3002`;
1259
+ }
1253
1260
  function fixFindingPayload(finding) {
1254
1261
  const f = finding && typeof finding === "object" ? finding : {};
1255
1262
  const file = String(f.file ?? "");
@@ -2858,6 +2865,183 @@ function subscribeSession(listener) {
2858
2865
  };
2859
2866
  }
2860
2867
 
2868
+ // src/client/shortcuts.ts
2869
+ var OBSERVATORY_SHORTCUT_ID = "iterate.open-observatory";
2870
+ var DEFAULT_BINDING = { code: "KeyO", modifiers: ["primary", "shift"] };
2871
+ var DEFAULT_PROFILES = [
2872
+ "desktop:macos",
2873
+ "desktop:windows",
2874
+ "desktop:linux",
2875
+ "web:macos",
2876
+ "web:windows",
2877
+ "web:linux"
2878
+ ];
2879
+ var OBSERVATORY_DEFAULT_BINDINGS = Object.fromEntries(
2880
+ DEFAULT_PROFILES.map((profile) => [profile, DEFAULT_BINDING])
2881
+ );
2882
+ function buildObservatoryShortcutCommand(deps) {
2883
+ const label = () => deps.lang() === "zh" ? "\u6253\u5F00 iterate \u89C2\u6D4B\u53F0" : "Open iterate observatory";
2884
+ return {
2885
+ id: OBSERVATORY_SHORTCUT_ID,
2886
+ label,
2887
+ aliases: [],
2888
+ defaults: OBSERVATORY_DEFAULT_BINDINGS,
2889
+ regions: ["page", "editable", "terminal"],
2890
+ modals: [],
2891
+ resolve: () => ({ status: "handled", run: () => deps.openObservatory({}) })
2892
+ };
2893
+ }
2894
+ function registerIterateShortcuts(service, deps) {
2895
+ try {
2896
+ const dispose = service.register(buildObservatoryShortcutCommand(deps));
2897
+ let done = false;
2898
+ return () => {
2899
+ if (done) return;
2900
+ done = true;
2901
+ try {
2902
+ dispose();
2903
+ } catch {
2904
+ }
2905
+ };
2906
+ } catch (err) {
2907
+ if (typeof console !== "undefined" && typeof console.error === "function") {
2908
+ console.error("[iterate-ui] keyboard-command registration failed; continuing without it.", err);
2909
+ }
2910
+ return () => {
2911
+ };
2912
+ }
2913
+ }
2914
+
2915
+ // src/client/context-menu.ts
2916
+ var OBS_OPEN_EVENT = "iterate-plugin:open-observatory";
2917
+ function openObservatory(detail) {
2918
+ if (typeof window === "undefined" || typeof window.dispatchEvent !== "function") return false;
2919
+ try {
2920
+ window.dispatchEvent(new CustomEvent(OBS_OPEN_EVENT, { detail: detail ?? {} }));
2921
+ return true;
2922
+ } catch {
2923
+ return false;
2924
+ }
2925
+ }
2926
+ var LABELS = {
2927
+ "iterate.open-panel": { zh: "\u6253\u5F00 iterate \u8D28\u91CF\u6307\u6325\u4E2D\u5FC3", en: "Open iterate command center" },
2928
+ "iterate.preflight": { zh: "\u590D\u5236\u5F00\u8DD1\u9884\u68C0\u6307\u4EE4", en: "Copy preflight instruction" },
2929
+ "iterate.dry-run": { zh: "\u590D\u5236 dry-run \u5BA1\u67E5\u6307\u4EE4", en: "Copy dry-run review instruction" },
2930
+ "iterate.resume": { zh: "\u590D\u5236\u65AD\u70B9\u6062\u590D\u6307\u4EE4", en: "Copy checkpoint-resume instruction" },
2931
+ "iterate.pull": { zh: "\u590D\u5236\u62C9\u53D6\u78C1\u76D8\u5FEB\u7167\u6307\u4EE4", en: "Copy disk-snapshot pull instruction" },
2932
+ "iterate.review-file": { zh: "\u590D\u5236\u300C\u4EC5\u5BA1\u67E5\u6B64\u6587\u4EF6\u300D\u6307\u4EE4", en: 'Copy "review this file only" instruction' },
2933
+ "iterate.review-dir": { zh: "\u590D\u5236\u300C\u4EC5\u5BA1\u67E5\u6B64\u76EE\u5F55\u300D\u6307\u4EE4", en: 'Copy "review this directory only" instruction' }
2934
+ };
2935
+ function pick(id, lang) {
2936
+ const entry = LABELS[id];
2937
+ if (!entry) return id;
2938
+ return lang === "zh" ? entry.zh : entry.en;
2939
+ }
2940
+ var COPY_FAILURE = {
2941
+ zh: "\u590D\u5236\u5931\u8D25\uFF1A\u526A\u8D34\u677F\u88AB\u62D2\u7EDD\u3002\u8BF7\u6539\u4E3A\u5728\u89C2\u6D4B\u53F0\u9762\u677F\u91CC\u7528\u540C\u4E00\u6307\u4EE4\u7684\u300C\u590D\u5236\u300D\u6309\u94AE\uFF0C\u6216\u624B\u52A8\u9009\u4E2D\u6307\u4EE4\u6587\u672C\u590D\u5236\u3002",
2942
+ en: 'Copy failed: clipboard denied. Use the matching "Copy" button in the observatory panel, or select the instruction text manually.'
2943
+ };
2944
+ function buildSessionMenuEntries(lang, opts) {
2945
+ return [
2946
+ { id: "iterate.open-panel", label: pick("iterate.open-panel", lang), openTab: "live" },
2947
+ { id: "iterate.preflight", label: pick("iterate.preflight", lang), separatorBefore: true },
2948
+ { id: "iterate.dry-run", label: pick("iterate.dry-run", lang) },
2949
+ { id: "iterate.resume", label: pick("iterate.resume", lang), disabled: opts?.hasCheckpoint === false },
2950
+ { id: "iterate.pull", label: pick("iterate.pull", lang), separatorBefore: true }
2951
+ ];
2952
+ }
2953
+ function buildFileMenuEntries(lang) {
2954
+ return [
2955
+ { id: "iterate.review-file", label: pick("iterate.review-file", lang) },
2956
+ { id: "iterate.open-panel", label: pick("iterate.open-panel", lang), openTab: "live" }
2957
+ ];
2958
+ }
2959
+ function buildDirectoryMenuEntries(lang) {
2960
+ return [
2961
+ { id: "iterate.review-dir", label: pick("iterate.review-dir", lang) },
2962
+ { id: "iterate.preflight", label: pick("iterate.preflight", lang), separatorBefore: true },
2963
+ { id: "iterate.open-panel", label: pick("iterate.open-panel", lang), openTab: "live" }
2964
+ ];
2965
+ }
2966
+ function toAction(entry, deps, lang) {
2967
+ const action = {
2968
+ id: entry.id,
2969
+ label: entry.label,
2970
+ danger: entry.danger,
2971
+ separatorBefore: entry.separatorBefore
2972
+ };
2973
+ if (entry.disabled) action.disabled = true;
2974
+ if (entry.openTab) {
2975
+ action.commandId = OBSERVATORY_SHORTCUT_ID;
2976
+ action.run = () => {
2977
+ deps.openPanel({ tab: entry.openTab });
2978
+ deps.notify(lang === "zh" ? "\u5DF2\u6253\u5F00 iterate \u89C2\u6D4B\u53F0\u3002" : "Iterate observatory opened.", true);
2979
+ };
2980
+ return action;
2981
+ }
2982
+ if (entry.instruction) {
2983
+ const text = entry.instruction;
2984
+ const failureLabel = COPY_FAILURE[lang];
2985
+ action.failureLabel = failureLabel;
2986
+ action.run = async () => {
2987
+ const ok = await deps.copy(text);
2988
+ if (!ok) throw new Error(failureLabel);
2989
+ deps.notify(lang === "zh" ? "\u6307\u4EE4\u5DF2\u590D\u5236\uFF0C\u7C98\u8D34\u7ED9\u6A21\u578B\u5373\u53EF\u3002" : "Instruction copied \u2014 paste it to the model.", true);
2990
+ };
2991
+ }
2992
+ return action;
2993
+ }
2994
+ function registerIterateContextMenus(service, deps, instructionByEntry) {
2995
+ const unregister = [];
2996
+ const register = (target, order, build) => {
2997
+ try {
2998
+ const dispose = service.register({
2999
+ id: `iterate-plugin.${target}`,
3000
+ target,
3001
+ order,
3002
+ resolve: (t2, _signal) => {
3003
+ const lang = getLang();
3004
+ return build(lang, t2).map((entry) => {
3005
+ const full = { ...entry, instruction: entry.instruction ?? instructionByEntry(entry.id) };
3006
+ return toAction(full, deps, lang);
3007
+ });
3008
+ }
3009
+ });
3010
+ unregister.push(dispose);
3011
+ } catch (err) {
3012
+ if (typeof console !== "undefined" && typeof console.error === "function") {
3013
+ console.error("[iterate-ui] context-menu registration failed for target", target, err);
3014
+ }
3015
+ }
3016
+ };
3017
+ register("session", 300, (_lang, t2) => {
3018
+ const target = t2;
3019
+ return buildSessionMenuEntries(getLang(), { hasCheckpoint: target?.value?.sessionId ? true : void 0 });
3020
+ });
3021
+ register("file", 300, (_lang, t2) => {
3022
+ const target = t2;
3023
+ return buildFileMenuEntries(getLang()).map((e) => ({
3024
+ ...e,
3025
+ instruction: e.id === "iterate.review-file" ? instructionByEntry(`file:${target?.value?.path ?? ""}${typeof target?.value?.line === "number" ? `:${target.value.line}` : ""}`) : e.instruction
3026
+ }));
3027
+ });
3028
+ register("directory", 300, (_lang, t2) => {
3029
+ const target = t2;
3030
+ return buildDirectoryMenuEntries(getLang()).map((e) => ({
3031
+ ...e,
3032
+ instruction: e.id === "iterate.review-dir" ? instructionByEntry(`dir:${target?.value?.path ?? ""}`) : e.instruction
3033
+ }));
3034
+ });
3035
+ return () => {
3036
+ for (const dispose of unregister.splice(0)) {
3037
+ try {
3038
+ dispose();
3039
+ } catch {
3040
+ }
3041
+ }
3042
+ };
3043
+ }
3044
+
2861
3045
  // src/client/index.ts
2862
3046
  var React = require("react");
2863
3047
  var name = "iterate-plugin";
@@ -3294,12 +3478,51 @@ function readTheme(ctx) {
3294
3478
  }
3295
3479
  return void 0;
3296
3480
  }
3481
+ function readContextMenus(ctx) {
3482
+ if (!ctx || typeof ctx.get !== "function") return void 0;
3483
+ const raw = ctx.get("contextMenus", false);
3484
+ if (!raw || typeof raw !== "object") return void 0;
3485
+ const svc = raw;
3486
+ if (typeof svc.register !== "function") return void 0;
3487
+ if (typeof svc.notify !== "function") return void 0;
3488
+ return svc;
3489
+ }
3490
+ function readShortcuts(ctx) {
3491
+ if (!ctx || typeof ctx.get !== "function") return void 0;
3492
+ const raw = ctx.get("shortcuts", false);
3493
+ if (!raw || typeof raw !== "object") return void 0;
3494
+ const svc = raw;
3495
+ if (typeof svc.register !== "function") return void 0;
3496
+ return svc;
3497
+ }
3498
+ function sessionIdOfSession(session) {
3499
+ if (!session || typeof session !== "object") return null;
3500
+ const s = session;
3501
+ const candidates = [
3502
+ s.sessionId,
3503
+ s.id,
3504
+ s.header,
3505
+ s.session
3506
+ ];
3507
+ for (const c of candidates) {
3508
+ if (typeof c === "string" && c) return c;
3509
+ if (c && typeof c === "object") {
3510
+ const o = c;
3511
+ for (const k of ["sessionId", "id"]) {
3512
+ if (typeof o[k] === "string" && o[k]) return o[k];
3513
+ }
3514
+ }
3515
+ }
3516
+ return null;
3517
+ }
3297
3518
  var slotsSvc = void 0;
3298
3519
  var themeSvc = void 0;
3299
3520
  var storage2 = null;
3300
3521
  var themeDisposer = null;
3301
3522
  var themeListener = null;
3302
3523
  var themeEnabled = true;
3524
+ var menuDisposer = null;
3525
+ var shortcutDisposer = null;
3303
3526
  var roundPulseListeners = [];
3304
3527
  function emitRoundPulse(round, converged) {
3305
3528
  const payload = { round, converged: converged === true };
@@ -4607,6 +4830,21 @@ function ObservatoryPanel(props) {
4607
4830
  doc.addEventListener("keydown", onKeyDown);
4608
4831
  return () => doc.removeEventListener("keydown", onKeyDown);
4609
4832
  }, [open]);
4833
+ const panelSessionId = sessionIdOfSession(session);
4834
+ React.useEffect(() => {
4835
+ if (typeof window === "undefined" || typeof window.addEventListener !== "function") return;
4836
+ const onOpen = (ev) => {
4837
+ const detail = ev.detail;
4838
+ const detailSession = detail && typeof detail.sessionId === "string" && detail.sessionId ? detail.sessionId : null;
4839
+ if (detailSession && panelSessionId && detailSession !== panelSessionId) return;
4840
+ setOpen(true);
4841
+ const target = detail && typeof detail.tab === "string" ? detail.tab : "live";
4842
+ const valid = OBS_TABS.some((x) => x.key === target);
4843
+ setTab(valid ? target : "live");
4844
+ };
4845
+ window.addEventListener(OBS_OPEN_EVENT, onOpen);
4846
+ return () => window.removeEventListener(OBS_OPEN_EVENT, onOpen);
4847
+ }, [panelSessionId]);
4610
4848
  const copyInstruction = (key, text) => {
4611
4849
  if (!text) return;
4612
4850
  copyText(text).then((ok) => {
@@ -6217,6 +6455,80 @@ function apply(ctx) {
6217
6455
  )
6218
6456
  );
6219
6457
  }
6458
+ {
6459
+ const menuSvc = readContextMenus(ctx);
6460
+ if (menuSvc) {
6461
+ const instructionByEntry = (id) => {
6462
+ if (id === "iterate.preflight") return buildPreflightInstruction();
6463
+ if (id === "iterate.dry-run") return START_INSTRUCTIONS.reviewOnly;
6464
+ if (id === "iterate.resume") return buildCheckpointResumeInstruction();
6465
+ if (id === "iterate.pull") return buildDiskSnapshotInstruction();
6466
+ if (id.startsWith("file:")) {
6467
+ const rest = id.slice("file:".length);
6468
+ const colon = rest.lastIndexOf(":");
6469
+ const path = colon > 0 ? rest.slice(0, colon) : rest;
6470
+ const line = colon > 0 ? Number(rest.slice(colon + 1)) : void 0;
6471
+ return buildScopedReviewInstruction({ file: path, ...Number.isInteger(line) && line > 0 ? { line } : {} });
6472
+ }
6473
+ if (id.startsWith("dir:")) return buildScopedReviewInstruction({ dir: id.slice("dir:".length) });
6474
+ return "";
6475
+ };
6476
+ if (menuDisposer) {
6477
+ try {
6478
+ menuDisposer();
6479
+ } catch {
6480
+ }
6481
+ menuDisposer = null;
6482
+ }
6483
+ menuDisposer = registerIterateContextMenus(
6484
+ menuSvc,
6485
+ {
6486
+ copy: (text) => copyText(text),
6487
+ openPanel: (detail) => {
6488
+ openObservatory(detail);
6489
+ },
6490
+ notify: (text, success, action) => {
6491
+ menuSvc.notify(text, success, action);
6492
+ }
6493
+ },
6494
+ instructionByEntry
6495
+ );
6496
+ if (typeof ctx.effect === "function") {
6497
+ ctx.effect(() => {
6498
+ if (menuDisposer) {
6499
+ menuDisposer();
6500
+ menuDisposer = null;
6501
+ }
6502
+ });
6503
+ }
6504
+ }
6505
+ }
6506
+ {
6507
+ const shortcutSvc = readShortcuts(ctx);
6508
+ if (shortcutSvc) {
6509
+ if (shortcutDisposer) {
6510
+ try {
6511
+ shortcutDisposer();
6512
+ } catch {
6513
+ }
6514
+ shortcutDisposer = null;
6515
+ }
6516
+ shortcutDisposer = registerIterateShortcuts(shortcutSvc, {
6517
+ openObservatory: (detail) => {
6518
+ openObservatory(detail);
6519
+ },
6520
+ lang: () => getLang()
6521
+ });
6522
+ if (typeof ctx.effect === "function") {
6523
+ ctx.effect(() => {
6524
+ if (shortcutDisposer) {
6525
+ shortcutDisposer();
6526
+ shortcutDisposer = null;
6527
+ }
6528
+ });
6529
+ }
6530
+ }
6531
+ }
6220
6532
  registerThemeListener(ctx);
6221
6533
  }
6222
6534
  return module.exports; } });
package/lib/parse.js CHANGED
@@ -1788,6 +1788,34 @@ export const START_INSTRUCTIONS = {
1788
1788
  reviewOnly: START_INSTRUCTION_REVIEW_ONLY,
1789
1789
  }
1790
1790
 
1791
+ /**
1792
+ * Scoped dry-run review instruction (right-click menu: file / directory).
1793
+ *
1794
+ * `iterate_review plan` has no per-call `scope` parameter, so a single-file or
1795
+ * single-directory review is expressed as a directed instruction the model
1796
+ * resolves through the `workflow` tool exactly like the full dry-run flow, but
1797
+ * with the review scope explicitly narrowed to the clicked subject. The user
1798
+ * never has to type the path themselves.
1799
+ *
1800
+ * @param {{ file?: string, line?: number, dir?: string }} scope
1801
+ * @returns {string} empty when no scope is given (defensive; never throw)
1802
+ */
1803
+ export function buildScopedReviewInstruction(scope) {
1804
+ const hasFile = scope && typeof scope.file === 'string' && scope.file.length > 0
1805
+ const hasDir = !hasFile && scope && typeof scope.dir === 'string' && scope.dir.length > 0
1806
+ if (!hasFile && !hasDir) return ''
1807
+ const subject = hasFile
1808
+ ? `文件 \`${scope.file}\`${typeof scope.line === 'number' && scope.line > 0 ? `(第 ${scope.line} 行)` : ''}`
1809
+ : `目录 \`${scope.dir}\``
1810
+ return (
1811
+ `开始一次仅评审(dry-run,不修改任何文件),审查范围限定在${subject}:` +
1812
+ '请调用 `workflow` 工具运行 iterate 工作流,mode: "dry-run",' +
1813
+ `按系统提示的 Iterate Workflow 执行 plan → 评审 → 分诊 → \`iterate_review aggregate\` / \`meta-review\` 终报并 \`iterate_transcript\` capture;` +
1814
+ `评审时把范围限定在${subject},其余文件仅作上下文、不得给出修复。` +
1815
+ '全程不要调用 `iterate_fix` / `iterate_rollback`。'
1816
+ )
1817
+ }
1818
+
1791
1819
  /** Normalize an arbitrary finding-ish node into the payload field set. */
1792
1820
  function fixFindingPayload(finding) {
1793
1821
  const f = finding && typeof finding === 'object' ? finding : {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-plugin",
3
- "version": "3.13.0",
3
+ "version": "3.15.0",
4
4
  "description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,7 +55,9 @@
55
55
  "0.2.1-rc.3": "compatible",
56
56
  "0.2.1-rc.4": "compatible",
57
57
  "0.2.1-rc.5": "compatible",
58
- "0.2.2-alpha.2": "compatible"
58
+ "0.2.2-alpha.2": "compatible",
59
+ "0.2.2-alpha.3": "compatible",
60
+ "0.2.2-rc.1": "compatible"
59
61
  }
60
62
  },
61
63
  "client": {
@@ -92,14 +94,14 @@
92
94
  "test:validate": "tsx --test test/validate.test.ts"
93
95
  },
94
96
  "dependencies": {
95
- "iterate-kernel": "0.1.9",
97
+ "iterate-kernel": "0.1.10",
96
98
  "js-yaml": "4.3.2"
97
99
  },
98
100
  "peerDependencies": {
99
- "@deepseek-ai/cordis": "^4.0.5-alpha.2 || ~4.0.6-alpha.1",
100
- "@deepseek-ai/dsh-session": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2",
101
- "@deepseek-ai/dsh-tools": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2",
102
- "@deepseek-ai/dsh-util-values": "0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2"
101
+ "@deepseek-ai/cordis": "^4.0.5-alpha.2 || ~4.0.6-alpha.1 || ~4.0.6-alpha.2",
102
+ "@deepseek-ai/dsh-session": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2 || 0.2.2-alpha.3 || 0.2.2-rc.1",
103
+ "@deepseek-ai/dsh-tools": "0.1.1-rc.1 || 0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2 || 0.2.2-alpha.3 || 0.2.2-rc.1",
104
+ "@deepseek-ai/dsh-util-values": "0.1.2-alpha.5 || 0.1.2-rc.1 || 0.1.5-alpha.1 || 0.1.5-alpha.2 || 0.1.5-rc.1 || 0.1.5-rc.2 || 0.1.6-alpha.1 || 0.1.6-alpha.2 || 0.1.7-alpha.1 || 0.1.7-alpha.2 || 0.1.7-rc.1 || 0.1.7-rc.2 || 0.2.0-rc.1 || 0.2.0-rc.2 || 0.2.1-alpha.1 || 0.2.1-alpha.2 || 0.2.1-alpha.3 || 0.2.1-alpha.4 || 0.2.1-alpha.5 || 0.2.1-rc.1 || 0.2.1-rc.2 || 0.2.1-rc.3 || 0.2.1-rc.4 || 0.2.1-rc.5 || 0.2.2-alpha.2 || 0.2.2-alpha.3 || 0.2.2-rc.1"
103
105
  },
104
106
  "peerDependenciesMeta": {
105
107
  "@deepseek-ai/cordis": {
@@ -116,20 +118,20 @@
116
118
  }
117
119
  },
118
120
  "devDependencies": {
119
- "@deepseek-ai/cordis": "4.0.6-alpha.1",
120
- "@deepseek-ai/dsh-agent": "0.2.2-alpha.2",
121
- "@deepseek-ai/dsh-jobs": "0.2.2-alpha.2",
122
- "@deepseek-ai/dsh-llm": "0.2.2-alpha.2",
123
- "@deepseek-ai/dsh-ptc-runtime": "0.2.2-alpha.2",
124
- "@deepseek-ai/dsh-sandbox": "0.2.2-alpha.2",
125
- "@deepseek-ai/dsh-sandbox-policy": "0.2.2-alpha.2",
126
- "@deepseek-ai/dsh-scope": "0.2.2-alpha.2",
127
- "@deepseek-ai/dsh-session": "0.2.2-alpha.2",
128
- "@deepseek-ai/dsh-system-prompt": "0.2.2-alpha.2",
129
- "@deepseek-ai/dsh-tools": "0.2.2-alpha.2",
130
- "@deepseek-ai/dsh-user-approval": "0.2.2-alpha.2",
131
- "@deepseek-ai/dsh-util-values": "0.2.2-alpha.2",
132
- "@deepseek-ai/dsh-working-directory": "0.2.2-alpha.2",
121
+ "@deepseek-ai/cordis": "4.1.0",
122
+ "@deepseek-ai/dsh-agent": "0.2.2-rc.1",
123
+ "@deepseek-ai/dsh-jobs": "0.2.2-rc.1",
124
+ "@deepseek-ai/dsh-llm": "0.2.2-rc.1",
125
+ "@deepseek-ai/dsh-ptc-runtime": "0.2.2-rc.1",
126
+ "@deepseek-ai/dsh-sandbox": "0.2.2-rc.1",
127
+ "@deepseek-ai/dsh-sandbox-policy": "0.2.2-rc.1",
128
+ "@deepseek-ai/dsh-scope": "0.2.2-rc.1",
129
+ "@deepseek-ai/dsh-session": "0.2.2-rc.1",
130
+ "@deepseek-ai/dsh-system-prompt": "0.2.2-rc.1",
131
+ "@deepseek-ai/dsh-tools": "0.2.2-rc.1",
132
+ "@deepseek-ai/dsh-user-approval": "0.2.2-rc.1",
133
+ "@deepseek-ai/dsh-util-values": "0.2.2-rc.1",
134
+ "@deepseek-ai/dsh-working-directory": "0.2.2-rc.1",
133
135
  "@types/js-yaml": "4.0.9",
134
136
  "@types/node": "22.15.0",
135
137
  "@types/react": "19.2.2",
@@ -143,5 +145,8 @@
143
145
  "engines": {
144
146
  "node": ">=20",
145
147
  "dsh": ">=0.1.1-rc.1"
148
+ },
149
+ "overrides": {
150
+ "zod": "4.6.7"
146
151
  }
147
152
  }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * src/client/context-menu.ts — iterate-plugin right-click menu contributions.
3
+ *
4
+ * dsh 0.2.2-alpha.3 added the *unified context-menu* capability
5
+ * (`@deepseek-ai/dsh-client-ui-context-menu`): any client plugin may register
6
+ * actions that appear on right-click / Shift+F10 / ContextMenu key over a
7
+ * Session, file, directory, URL, image, tab, or workspace, without importing
8
+ * a React component. The captured target stays fixed even when selection
9
+ * changes, and contributions are withdrawn automatically when the plugin
10
+ * unloads.
11
+ *
12
+ * This module turns that new upstream capability into a *user-perceivable*
13
+ * iterate surface: instead of only being reachable through the docked
14
+ * observatory and the empty-dashboard CTA, the iterate entry points now also
15
+ * hang off the native right-click menus — on a Session (open the quality
16
+ * command center, copy a preflight / dry-run / resume / disk-snapshot
17
+ * instruction), on a file (dry-run review scoped to that file), and on a
18
+ * directory (scoped review + preflight).
19
+ *
20
+ * Design (mirrors the rest of the client half):
21
+ * - **Defensive**: the `contextMenus` service is optional and probed at
22
+ * apply-time; a host that predates 0.2.2-alpha.3 simply has no menu
23
+ * entries (the docked panel remains the entry point). Nothing throws.
24
+ * - **Pure builders**: `buildSessionMenuEntries` / `buildFileMenuEntries` /
25
+ * `buildDirectoryMenuEntries` return *localized descriptors* with no DOM
26
+ * or clipboard side effects, so they are unit-testable on their own
27
+ * (normal + empty + boundary). The imperative wiring (`copy` / `openPanel`
28
+ * / `notify`) is injected by `registerIterateContextMenus`.
29
+ * - **Bilingual**: every label carries zh + en and resolves against the
30
+ * language passed in (the caller reads `getLang()` at menu-build time, so
31
+ * a language switch is picked up on the next open).
32
+ *
33
+ * Event bridge: the observatory is a React panel with its own open/tab state.
34
+ * The browser client cannot call harness tools, so the "open command center"
35
+ * menu action dispatches a window CustomEvent (`OBS_OPEN_EVENT`) that the
36
+ * ObservatoryPanel listens for — the panel expands and switches tab without
37
+ * any cross-plugin import. The event carries an optional `sessionId` so a
38
+ * right-click in the session sidebar targets the right session's panel and is
39
+ * ignored by every other mounted panel.
40
+ */
41
+
42
+ import { getLang, type Lang } from './i18n.ts'
43
+ import { OBSERVATORY_SHORTCUT_ID } from './shortcuts.ts'
44
+
45
+ // ─── Structural service types (defensive; mirrors SlotsService) ──────────────
46
+ //
47
+ // The dsh client runtime is not type-imported (no compile-time coupling to a
48
+ // package a pre-0.2.2-alpha.3 host may not have). The surface below is the
49
+ // subset this plugin consumes; `register` is only ever called when a service
50
+ // with the right shape is present.
51
+
52
+ /** A right-click target this plugin contributes actions to. */
53
+ export type IterateMenuTarget =
54
+ | { kind: 'session'; value: { sessionId: string; displayTitle?: string } }
55
+ | { kind: 'file'; value: { sessionId: string; path: string; line?: number } }
56
+ | { kind: 'directory'; value: { sessionId: string; path: string } }
57
+
58
+ /** One menu action (presentation + optional execution). */
59
+ export interface IterateMenuAction {
60
+ id: string
61
+ label: string
62
+ disabled?: boolean
63
+ danger?: boolean
64
+ separatorBefore?: boolean
65
+ /** Localized operation + recovery guidance when `run` rejects. */
66
+ failureLabel?: string
67
+ /** Live-shortcut display for an action backed by a registered command. */
68
+ commandId?: string
69
+ run?: () => void | Promise<void>
70
+ }
71
+
72
+ /** A namespaced contribution, ordered within its target family. */
73
+ export interface IterateMenuContribution<K extends string> {
74
+ id: string
75
+ target: K
76
+ order: number
77
+ resolve(target: unknown, signal: AbortSignal): readonly IterateMenuAction[]
78
+ }
79
+
80
+ /** Structural view of the dsh `contextMenus` service this plugin consumes. */
81
+ export interface ContextMenusService {
82
+ register<K extends string>(contribution: IterateMenuContribution<K>): () => void
83
+ notify(
84
+ text: string,
85
+ success?: boolean,
86
+ action?: { label: string; run: () => void | Promise<void>; failureLabel: string },
87
+ ): void
88
+ }
89
+
90
+ // ─── Observatory open event bridge ───────────────────────────────────────────
91
+
92
+ /** Window event that asks the (matching) ObservatoryPanel to expand. */
93
+ export const OBS_OPEN_EVENT = 'iterate-plugin:open-observatory'
94
+
95
+ /** Payload carried by {@link OBS_OPEN_EVENT}. */
96
+ export interface ObsOpenDetail {
97
+ /** Optional target session; omitted = open whatever panel is mounted. */
98
+ sessionId?: string
99
+ /** Tab key to switch to (`live` by default). */
100
+ tab?: string
101
+ }
102
+
103
+ /**
104
+ * Dispatch the "open observatory" event. No-op outside a browser (SSR / test
105
+ * environments without `window`). Returns whether the event was dispatched.
106
+ */
107
+ export function openObservatory(detail?: ObsOpenDetail): boolean {
108
+ if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') return false
109
+ try {
110
+ window.dispatchEvent(new CustomEvent<ObsOpenDetail>(OBS_OPEN_EVENT, { detail: detail ?? {} }))
111
+ return true
112
+ } catch {
113
+ return false
114
+ }
115
+ }
116
+
117
+ // ─── Localized descriptors (pure, testable) ──────────────────────────────────
118
+
119
+ /** The action ids this plugin registers, for tests + duplicate guards. */
120
+ export const SESSION_MENU_IDS = ['iterate.open-panel', 'iterate.preflight', 'iterate.dry-run', 'iterate.resume', 'iterate.pull'] as const
121
+ export const FILE_MENU_IDS = ['iterate.review-file', 'iterate.open-panel'] as const
122
+ export const DIRECTORY_MENU_IDS = ['iterate.review-dir', 'iterate.preflight', 'iterate.open-panel'] as const
123
+
124
+ interface MenuEntry {
125
+ id: string
126
+ label: string
127
+ instruction?: string
128
+ /** Tab key to open when this entry is the "open panel" action. */
129
+ openTab?: string
130
+ danger?: boolean
131
+ disabled?: boolean
132
+ separatorBefore?: boolean
133
+ }
134
+
135
+ /** Bilingual labels; resolved by `pick` at build time. */
136
+ const LABELS: Record<string, { zh: string; en: string }> = {
137
+ 'iterate.open-panel': { zh: '打开 iterate 质量指挥中心', en: 'Open iterate command center' },
138
+ 'iterate.preflight': { zh: '复制开跑预检指令', en: 'Copy preflight instruction' },
139
+ 'iterate.dry-run': { zh: '复制 dry-run 审查指令', en: 'Copy dry-run review instruction' },
140
+ 'iterate.resume': { zh: '复制断点恢复指令', en: 'Copy checkpoint-resume instruction' },
141
+ 'iterate.pull': { zh: '复制拉取磁盘快照指令', en: 'Copy disk-snapshot pull instruction' },
142
+ 'iterate.review-file': { zh: '复制「仅审查此文件」指令', en: 'Copy "review this file only" instruction' },
143
+ 'iterate.review-dir': { zh: '复制「仅审查此目录」指令', en: 'Copy "review this directory only" instruction' },
144
+ }
145
+
146
+ /** Pick a localized label; unknown ids degrade to the raw id (never throw). */
147
+ function pick(id: string, lang: Lang): string {
148
+ const entry = LABELS[id]
149
+ if (!entry) return id
150
+ return lang === 'zh' ? entry.zh : entry.en
151
+ }
152
+
153
+ /** Copy-failure guidance (a menu action's `failureLabel`). */
154
+ const COPY_FAILURE: Record<'zh' | 'en', string> = {
155
+ zh: '复制失败:剪贴板被拒绝。请改为在观测台面板里用同一指令的「复制」按钮,或手动选中指令文本复制。',
156
+ en: 'Copy failed: clipboard denied. Use the matching "Copy" button in the observatory panel, or select the instruction text manually.',
157
+ }
158
+
159
+ /** Build the Session target menu entries (pure). */
160
+ export function buildSessionMenuEntries(lang: Lang, opts?: { hasCheckpoint?: boolean }): MenuEntry[] {
161
+ return [
162
+ { id: 'iterate.open-panel', label: pick('iterate.open-panel', lang), openTab: 'live' },
163
+ { id: 'iterate.preflight', label: pick('iterate.preflight', lang), separatorBefore: true },
164
+ { id: 'iterate.dry-run', label: pick('iterate.dry-run', lang) },
165
+ { id: 'iterate.resume', label: pick('iterate.resume', lang), disabled: opts?.hasCheckpoint === false },
166
+ { id: 'iterate.pull', label: pick('iterate.pull', lang), separatorBefore: true },
167
+ ]
168
+ }
169
+
170
+ /** Build the File target menu entries (pure). */
171
+ export function buildFileMenuEntries(lang: Lang): MenuEntry[] {
172
+ return [
173
+ { id: 'iterate.review-file', label: pick('iterate.review-file', lang) },
174
+ { id: 'iterate.open-panel', label: pick('iterate.open-panel', lang), openTab: 'live' },
175
+ ]
176
+ }
177
+
178
+ /** Build the Directory target menu entries (pure). */
179
+ export function buildDirectoryMenuEntries(lang: Lang): MenuEntry[] {
180
+ return [
181
+ { id: 'iterate.review-dir', label: pick('iterate.review-dir', lang) },
182
+ { id: 'iterate.preflight', label: pick('iterate.preflight', lang), separatorBefore: true },
183
+ { id: 'iterate.open-panel', label: pick('iterate.open-panel', lang), openTab: 'live' },
184
+ ]
185
+ }
186
+
187
+ // ─── Imperative wiring (register + run callbacks) ────────────────────────────
188
+
189
+ /** Injection points the menu wiring needs (kept dependency-light for tests). */
190
+ export interface IterateMenuDeps {
191
+ /** Copy an instruction; resolves to whether the clipboard write succeeded. */
192
+ copy: (text: string) => Promise<boolean>
193
+ /** Open the observatory panel (optionally for a session, on a tab). */
194
+ openPanel: (detail?: ObsOpenDetail) => void
195
+ /** Show the shared toast. */
196
+ notify: (text: string, success?: boolean, action?: { label: string; run: () => void | Promise<void>; failureLabel: string }) => void
197
+ }
198
+
199
+ /** Map a pure entry to a runnable menu action, wiring the injected deps. */
200
+ function toAction(entry: MenuEntry, deps: IterateMenuDeps, lang: Lang): IterateMenuAction {
201
+ const action: IterateMenuAction = {
202
+ id: entry.id,
203
+ label: entry.label,
204
+ danger: entry.danger,
205
+ separatorBefore: entry.separatorBefore,
206
+ }
207
+ if (entry.disabled) action.disabled = true
208
+ if (entry.openTab) {
209
+ // The "open command center" action is backed by the registered
210
+ // `iterate.open-observatory` keyboard command, so the context menu shows
211
+ // its live keycap (Cmd/Ctrl+Shift+O) — the shortcut is discoverable in the
212
+ // same place the user would look for it. Display only: selection still
213
+ // runs this action's `run`.
214
+ action.commandId = OBSERVATORY_SHORTCUT_ID
215
+ action.run = () => {
216
+ deps.openPanel({ tab: entry.openTab })
217
+ deps.notify(lang === 'zh' ? '已打开 iterate 观测台。' : 'Iterate observatory opened.', true)
218
+ }
219
+ return action
220
+ }
221
+ if (entry.instruction) {
222
+ const text = entry.instruction
223
+ const failureLabel = COPY_FAILURE[lang]
224
+ action.failureLabel = failureLabel
225
+ action.run = async () => {
226
+ const ok = await deps.copy(text)
227
+ if (!ok) throw new Error(failureLabel)
228
+ deps.notify(lang === 'zh' ? '指令已复制,粘贴给模型即可。' : 'Instruction copied — paste it to the model.', true)
229
+ }
230
+ }
231
+ return action
232
+ }
233
+
234
+ /**
235
+ * Register this plugin's context-menu contributions on the (already-validated)
236
+ * `contextMenus` service. Returns an unregister function; a host without the
237
+ * service is a no-op that returns `() => {}` (callers guard).
238
+ */
239
+ export function registerIterateContextMenus(
240
+ service: ContextMenusService,
241
+ deps: IterateMenuDeps,
242
+ instructionByEntry: (id: string) => string,
243
+ ): () => void {
244
+ const unregister: Array<() => void> = []
245
+
246
+ const register = <K extends string>(target: K, order: number, build: (lang: Lang, target: unknown) => MenuEntry[]) => {
247
+ try {
248
+ const dispose = service.register({
249
+ id: `iterate-plugin.${target}`,
250
+ target,
251
+ order,
252
+ resolve: (t, _signal) => {
253
+ const lang = getLang()
254
+ return build(lang, t).map((entry) => {
255
+ const full: MenuEntry = { ...entry, instruction: entry.instruction ?? instructionByEntry(entry.id) }
256
+ return toAction(full, deps, lang)
257
+ })
258
+ },
259
+ })
260
+ unregister.push(dispose)
261
+ } catch (err) {
262
+ // A duplicate contribution id or an unsupported target family must not
263
+ // take the plugin down — log and continue with the remaining targets.
264
+ if (typeof console !== 'undefined' && typeof console.error === 'function') {
265
+ console.error('[iterate-ui] context-menu registration failed for target', target, err)
266
+ }
267
+ }
268
+ }
269
+
270
+ register('session', 300, (_lang, t) => {
271
+ const target = t as { value?: { sessionId?: string } } | undefined
272
+ return buildSessionMenuEntries(getLang(), { hasCheckpoint: target?.value?.sessionId ? true : undefined })
273
+ })
274
+ register('file', 300, (_lang, t) => {
275
+ const target = t as { value?: { path?: string; line?: number } } | undefined
276
+ return buildFileMenuEntries(getLang()).map((e) => ({
277
+ ...e,
278
+ instruction: e.id === 'iterate.review-file'
279
+ ? instructionByEntry(`file:${target?.value?.path ?? ''}${typeof target?.value?.line === 'number' ? `:${target.value.line}` : ''}`)
280
+ : e.instruction,
281
+ }))
282
+ })
283
+ register('directory', 300, (_lang, t) => {
284
+ const target = t as { value?: { path?: string } } | undefined
285
+ return buildDirectoryMenuEntries(getLang()).map((e) => ({
286
+ ...e,
287
+ instruction: e.id === 'iterate.review-dir'
288
+ ? instructionByEntry(`dir:${target?.value?.path ?? ''}`)
289
+ : e.instruction,
290
+ }))
291
+ })
292
+
293
+ return () => {
294
+ for (const dispose of unregister.splice(0)) {
295
+ try { dispose() } catch { /* noop */ }
296
+ }
297
+ }
298
+ }
@@ -24,7 +24,7 @@
24
24
  * 4. iterate theme skin -> theme.overrideTokens
25
25
  * 5. Progress event linkage -> ctx.on('slots/changed') + round-pulse + shell.overlay capsule
26
26
  * 6. Settings page section -> settings.section
27
- * 7. Runtime observatory -> conversation.input.dock (order 91, F1–F7 board)
27
+ * 7. Runtime observatory -> conversation.input.dock (order 91, F1–F11 board)
28
28
  *
29
29
  * Design notes:
30
30
  * - All styles are injected via one <style> tag; colors use dsh token
@@ -78,6 +78,7 @@ import {
78
78
  scanSessionForPreflight,
79
79
  buildPreflightInstruction,
80
80
  buildValidationApplyInstruction,
81
+ buildScopedReviewInstruction,
81
82
  filterLiveEntries,
82
83
  filterTimelineEntries,
83
84
  serializeObservatoryExport,
@@ -132,6 +133,19 @@ import {
132
133
  type Lang,
133
134
  } from './i18n.ts'
134
135
  import { noteSession, readSession, subscribeSession } from './session-cache.ts'
136
+ // v3.14: right-click menu contributions (dsh 0.2.2-alpha.3 unified context menus).
137
+ import {
138
+ openObservatory,
139
+ OBS_OPEN_EVENT,
140
+ registerIterateContextMenus,
141
+ type ContextMenusService,
142
+ type ObsOpenDetail,
143
+ } from './context-menu.ts'
144
+ // v3.15: keyboard-command contribution (dsh 0.2.2-rc.1 unified shortcuts).
145
+ import {
146
+ registerIterateShortcuts,
147
+ type ShortcutsService,
148
+ } from './shortcuts.ts'
135
149
 
136
150
  // ─── Module contract ─────────────────────────────────────────────────────────
137
151
 
@@ -907,6 +921,62 @@ function readTheme(ctx: ClientContext): ThemeService | undefined {
907
921
  return undefined
908
922
  }
909
923
 
924
+ /**
925
+ * Resolve the dsh 0.2.2-alpha.3 `contextMenus` service. Unlike slots/theme it
926
+ * is NOT in the typed `ClientContext`, so it is probed through `ctx.get` (the
927
+ * cordis service locator) and structurally validated. A host that predates the
928
+ * capability returns `undefined` and the plugin simply has no menu entries.
929
+ */
930
+ function readContextMenus(ctx: ClientContext): ContextMenusService | undefined {
931
+ if (!ctx || typeof ctx.get !== 'function') return undefined
932
+ const raw = ctx.get('contextMenus', false)
933
+ if (!raw || typeof raw !== 'object') return undefined
934
+ const svc = raw as { register?: unknown; notify?: unknown }
935
+ if (typeof svc.register !== 'function') return undefined
936
+ if (typeof svc.notify !== 'function') return undefined
937
+ return svc as unknown as ContextMenusService
938
+ }
939
+
940
+ /**
941
+ * Resolve the dsh 0.2.2-rc.1 `shortcuts` service. Like contextMenus it is NOT
942
+ * in the typed `ClientContext`, so it is probed through `ctx.get` and
943
+ * structurally validated. A host that predates the capability returns
944
+ * `undefined` and the plugin simply has no keyboard command.
945
+ */
946
+ function readShortcuts(ctx: ClientContext): ShortcutsService | undefined {
947
+ if (!ctx || typeof ctx.get !== 'function') return undefined
948
+ const raw = ctx.get('shortcuts', false)
949
+ if (!raw || typeof raw !== 'object') return undefined
950
+ const svc = raw as { register?: unknown }
951
+ if (typeof svc.register !== 'function') return undefined
952
+ return svc as unknown as ShortcutsService
953
+ }
954
+
955
+ /**
956
+ * Extract a session identifier from an opaque host-shaped session object.
957
+ * The slot `props.session` shape is host-defined; this probes the common
958
+ * keys so the observatory open-event can target the right session and be
959
+ * ignored by every other mounted panel. Returns `null` when unknown (the
960
+ * panel then responds to any open event — the single-panel fallback).
961
+ */
962
+ function sessionIdOfSession(session: unknown): string | null {
963
+ if (!session || typeof session !== 'object') return null
964
+ const s = session as Record<string, unknown>
965
+ const candidates = [
966
+ s.sessionId, s.id, s.header, s.session,
967
+ ] as unknown[]
968
+ for (const c of candidates) {
969
+ if (typeof c === 'string' && c) return c
970
+ if (c && typeof c === 'object') {
971
+ const o = c as Record<string, unknown>
972
+ for (const k of ['sessionId', 'id']) {
973
+ if (typeof o[k] === 'string' && o[k]) return o[k] as string
974
+ }
975
+ }
976
+ }
977
+ return null
978
+ }
979
+
910
980
  // ─── Module-level runtime state (shared across slots) ────────────────────────
911
981
 
912
982
  let slotsSvc: SlotsService | undefined = undefined
@@ -915,6 +985,10 @@ let storage: ReturnType<typeof createStorage> | null = null
915
985
  let themeDisposer: (() => void) | null = null
916
986
  let themeListener: (() => void) | null = null
917
987
  let themeEnabled = true
988
+ /** v3.14: right-click menu contribution disposer (re-apply() never stacks). */
989
+ let menuDisposer: (() => void) | null = null
990
+ /** v3.15: keyboard-command contribution disposer (re-apply() never stacks). */
991
+ let shortcutDisposer: (() => void) | null = null
918
992
  const roundPulseListeners: Array<(payload: { round: number; converged: boolean }) => void> = []
919
993
 
920
994
  /**
@@ -2180,7 +2254,7 @@ function SettingsPanel(props: SlotProps) {
2180
2254
  )
2181
2255
  }
2182
2256
 
2183
- // ─── Runtime observatory panel (F1–F7) ──────────────────────────────────────
2257
+ // ─── Runtime observatory panel (F1–F11) ─────────────────────────────────────
2184
2258
 
2185
2259
  /** Tab id → label for the observatory's eleven panels. Live (real-time
2186
2260
  * activity stream) is first so it is the default view; F-panels follow.
@@ -2307,7 +2381,7 @@ function buildObsFixInstruction(f: ObsFinding): string {
2307
2381
  }
2308
2382
 
2309
2383
  /**
2310
- * ObservatoryPanel: an 8-tab runtime observatory that renders the latest
2384
+ * ObservatoryPanel: a 12-tab runtime observatory that renders the latest
2311
2385
  * iterate_transcript manifest found in the dsh session stream. The panel is
2312
2386
  * collapsed by default to a single entry row so it never re-occludes the main
2313
2387
  * convergence dashboard; clicking expands it into tabs. Every field read is
@@ -2392,6 +2466,28 @@ function ObservatoryPanel(props: SlotProps) {
2392
2466
  // eslint-disable-next-line react-hooks/exhaustive-deps
2393
2467
  }, [open])
2394
2468
 
2469
+ // v3.14: the right-click "open command center" menu action dispatches a
2470
+ // window event; this panel expands and switches tab when the event targets
2471
+ // it (no sessionId = single-panel fallback, a different sessionId = ignore).
2472
+ const panelSessionId = sessionIdOfSession(session)
2473
+ React.useEffect(() => {
2474
+ if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return
2475
+ const onOpen = (ev: Event) => {
2476
+ const detail = (ev as CustomEvent<ObsOpenDetail>).detail
2477
+ const detailSession = detail && typeof detail.sessionId === 'string' && detail.sessionId
2478
+ ? detail.sessionId
2479
+ : null
2480
+ if (detailSession && panelSessionId && detailSession !== panelSessionId) return
2481
+ setOpen(true)
2482
+ const target = detail && typeof detail.tab === 'string' ? detail.tab : 'live'
2483
+ const valid = OBS_TABS.some((x) => x.key === target)
2484
+ setTab(valid ? target : 'live')
2485
+ }
2486
+ window.addEventListener(OBS_OPEN_EVENT, onOpen)
2487
+ return () => window.removeEventListener(OBS_OPEN_EVENT, onOpen)
2488
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2489
+ }, [panelSessionId])
2490
+
2395
2491
  /** Copy an instruction, flashing "已复制" on the originating button only when the copy actually succeeded. */
2396
2492
  const copyInstruction = (key: string, text: string) => {
2397
2493
  if (!text) return
@@ -3833,7 +3929,7 @@ export function apply(ctx: ClientContext): void {
3833
3929
  ),
3834
3930
  )
3835
3931
 
3836
- // F1–F7: runtime observatory board (order 91, below the dashboard's 90 so
3932
+ // F1–F11: runtime observatory board (order 91, below the dashboard's 90 so
3837
3933
  // its collapsed header never occludes the main convergence dashboard).
3838
3934
  slotsSvc.inject('conversation.input.dock', () =>
3839
3935
  slotsSvc?.register(
@@ -3867,6 +3963,69 @@ export function apply(ctx: ClientContext): void {
3867
3963
  )
3868
3964
  }
3869
3965
 
3966
+ // 5b. v3.14: right-click menu contributions (dsh 0.2.2-alpha.3 unified
3967
+ // context menus). Optional service: a host that predates the capability has
3968
+ // no menu entries, and the docked observatory stays the entry point.
3969
+ {
3970
+ const menuSvc = readContextMenus(ctx)
3971
+ if (menuSvc) {
3972
+ // Resolve a paste-able instruction for a menu action id. Session-level
3973
+ // ids map to the shared builders (the SAME single source of truth the
3974
+ // observatory copy buttons use); file:/dir: ids are the scoped review.
3975
+ const instructionByEntry = (id: string): string => {
3976
+ if (id === 'iterate.preflight') return buildPreflightInstruction()
3977
+ if (id === 'iterate.dry-run') return START_INSTRUCTIONS.reviewOnly
3978
+ if (id === 'iterate.resume') return buildCheckpointResumeInstruction()
3979
+ if (id === 'iterate.pull') return buildDiskSnapshotInstruction()
3980
+ if (id.startsWith('file:')) {
3981
+ const rest = id.slice('file:'.length)
3982
+ const colon = rest.lastIndexOf(':')
3983
+ const path = colon > 0 ? rest.slice(0, colon) : rest
3984
+ const line = colon > 0 ? Number(rest.slice(colon + 1)) : undefined
3985
+ return buildScopedReviewInstruction({ file: path, ...(Number.isInteger(line) && line! > 0 ? { line: line! } : {}) })
3986
+ }
3987
+ if (id.startsWith('dir:')) return buildScopedReviewInstruction({ dir: id.slice('dir:'.length) })
3988
+ return ''
3989
+ }
3990
+ if (menuDisposer) {
3991
+ try { menuDisposer() } catch { /* noop */ }
3992
+ menuDisposer = null
3993
+ }
3994
+ menuDisposer = registerIterateContextMenus(
3995
+ menuSvc,
3996
+ {
3997
+ copy: (text) => copyText(text),
3998
+ openPanel: (detail) => { openObservatory(detail) },
3999
+ notify: (text, success, action) => { menuSvc.notify(text, success, action) },
4000
+ },
4001
+ instructionByEntry,
4002
+ )
4003
+ if (typeof ctx.effect === 'function') {
4004
+ ctx.effect(() => { if (menuDisposer) { menuDisposer(); menuDisposer = null } })
4005
+ }
4006
+ }
4007
+ }
4008
+
4009
+ // 5c. v3.15: keyboard-command contribution (dsh 0.2.2-rc.1 unified
4010
+ // shortcuts). Optional service: a host that predates the capability has no
4011
+ // keyboard command, and the docked observatory stays the entry point.
4012
+ {
4013
+ const shortcutSvc = readShortcuts(ctx)
4014
+ if (shortcutSvc) {
4015
+ if (shortcutDisposer) {
4016
+ try { shortcutDisposer() } catch { /* noop */ }
4017
+ shortcutDisposer = null
4018
+ }
4019
+ shortcutDisposer = registerIterateShortcuts(shortcutSvc, {
4020
+ openObservatory: (detail) => { openObservatory(detail) },
4021
+ lang: () => getLang(),
4022
+ })
4023
+ if (typeof ctx.effect === 'function') {
4024
+ ctx.effect(() => { if (shortcutDisposer) { shortcutDisposer(); shortcutDisposer = null } })
4025
+ }
4026
+ }
4027
+ }
4028
+
3870
4029
  // 6: keep theme toggle state consistent across theme/change events.
3871
4030
  // Registered through registerThemeListener so repeated apply() and client
3872
4031
  // unmounts never leak a second listener.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * src/client/shortcuts.ts — iterate-plugin keyboard-command contributions.
3
+ *
4
+ * dsh 0.2.2-rc.1 added the *unified keyboard-command* capability
5
+ * (`@deepseek-ai/dsh-client-shortcuts`): any client plugin may register a
6
+ * command with a default key binding and a target resolver, and dsh's own
7
+ * keyboard adapter dispatches the gesture to it. The same capability also lets
8
+ * a context-menu action carry a `commandId` so the menu shows the live
9
+ * keycap next to the action (selection still invokes the action's `run`).
10
+ *
11
+ * This module turns that new upstream capability into a *user-perceivable*
12
+ * iterate surface: instead of hunting for the docked observatory (or
13
+ * right-clicking a session), the user can now press **Cmd/Ctrl+Shift+O** to
14
+ * open the iterate quality command center from anywhere in the session. The
15
+ * matching context-menu action carries the same `commandId`, so the shortcut
16
+ * is discoverable in the right-click menu ("open command center" shows the
17
+ * keycap), and dsh's own shortcut settings page lists it as an editable
18
+ * binding.
19
+ *
20
+ * Design (mirrors `context-menu.ts` and the rest of the client half):
21
+ * - **Defensive**: the `shortcuts` service is optional and probed at
22
+ * apply-time (see `readShortcuts` in `index.ts`); a host that predates
23
+ * 0.2.2-rc.1 simply has no keyboard command (the docked panel stays the
24
+ * entry point). Nothing throws.
25
+ * - **Pure builder**: `buildObservatoryShortcutCommand` returns the command
26
+ * shape with no `ctx`/DOM side effects, so it is unit-testable on its own
27
+ * (label resolution, binding defaults, resolve→run). The imperative
28
+ * wiring (`openObservatory` / `lang`) is injected by
29
+ * `registerIterateShortcuts`.
30
+ * - **Bilingual**: the label resolves against the language passed in at
31
+ * registration (a language switch is picked up by dsh's own command
32
+ * catalog on the next render).
33
+ * - **No external import**: like the rest of the client half, the dsh
34
+ * `@deepseek-ai/dsh-client-shortcuts` types are NOT imported (no
35
+ * compile-time coupling to a package a pre-0.2.2-rc.1 host may not have);
36
+ * the surface below is the structural subset this plugin consumes.
37
+ */
38
+
39
+ import type { Lang } from './i18n.ts'
40
+
41
+ // ─── Structural service types (defensive; mirrors ContextMenusService) ───────
42
+
43
+ /** Stable command identity owned by this plugin (a branded string upstream). */
44
+ export const OBSERVATORY_SHORTCUT_ID = 'iterate.open-observatory'
45
+
46
+ /** One physical key + modifiers; `primary` expands to Meta on macOS. */
47
+ export interface ShortcutBinding {
48
+ code: string
49
+ secondCode?: string
50
+ modifiers: readonly string[]
51
+ }
52
+
53
+ /** The dsh command catalog shape this plugin registers. */
54
+ export interface ShortcutCommand {
55
+ id: string
56
+ label: () => string
57
+ aliases: readonly string[]
58
+ defaults: Readonly<Record<string, ShortcutBinding>>
59
+ regions: readonly string[]
60
+ modals: readonly string[]
61
+ resolve(context: { region?: string; modal?: string | null; target?: Element | null }): ShortcutResolution
62
+ }
63
+
64
+ /** A resolved command either runs, is blocked with a reason, or passes. */
65
+ export type ShortcutResolution =
66
+ | { status: 'handled'; run: () => void }
67
+ | { status: 'blocked'; reason: string }
68
+ | { status: 'pass' }
69
+
70
+ /** Structural view of the dsh `shortcuts` service this plugin consumes. */
71
+ export interface ShortcutsService {
72
+ register(command: ShortcutCommand): () => void
73
+ }
74
+
75
+ /** Injection points the shortcut wiring needs (kept dependency-light for tests). */
76
+ export interface ShortcutDeps {
77
+ /** Open the observatory panel (optionally for a session, on a tab). */
78
+ openObservatory: (detail?: { sessionId?: string; tab?: string }) => void
79
+ /** Resolve the current UI language at label-build time. */
80
+ lang: () => Lang
81
+ }
82
+
83
+ /**
84
+ * Default key binding for the observatory command: Cmd/Ctrl+Shift+O
85
+ * ("O" = Open), applied uniformly across every runtime/platform profile so the
86
+ * feature is on by default on desktop and web. If an existing dsh command
87
+ * already owns this combination, `register` throws and the plugin degrades
88
+ * gracefully (no keyboard command; the observatory stays mouse-reachable).
89
+ */
90
+ const DEFAULT_BINDING: ShortcutBinding = { code: 'KeyO', modifiers: ['primary', 'shift'] }
91
+
92
+ const DEFAULT_PROFILES = [
93
+ 'desktop:macos',
94
+ 'desktop:windows',
95
+ 'desktop:linux',
96
+ 'web:macos',
97
+ 'web:windows',
98
+ 'web:linux',
99
+ ] as const
100
+
101
+ /** Every runtime/platform profile maps to the same default binding. */
102
+ export const OBSERVATORY_DEFAULT_BINDINGS: Readonly<Record<string, ShortcutBinding>> = Object.fromEntries(
103
+ DEFAULT_PROFILES.map((profile) => [profile, DEFAULT_BINDING]),
104
+ ) as Readonly<Record<string, ShortcutBinding>>
105
+
106
+ /**
107
+ * Build the pure command descriptor for "open iterate observatory". The
108
+ * resolver always handles the gesture and runs the injected opener; it never
109
+ * blocks (opening the observatory is safe from any region and no modal).
110
+ */
111
+ export function buildObservatoryShortcutCommand(deps: ShortcutDeps): ShortcutCommand {
112
+ const label = (): string =>
113
+ deps.lang() === 'zh' ? '打开 iterate 观测台' : 'Open iterate observatory'
114
+ return {
115
+ id: OBSERVATORY_SHORTCUT_ID,
116
+ label,
117
+ aliases: [],
118
+ defaults: OBSERVATORY_DEFAULT_BINDINGS,
119
+ regions: ['page', 'editable', 'terminal'],
120
+ modals: [],
121
+ resolve: () => ({ status: 'handled', run: () => deps.openObservatory({}) }),
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Register the observatory keyboard command on the (already-validated)
127
+ * `shortcuts` service. Returns an unregister function; a host without the
128
+ * service is a no-op that returns `() => {}` (callers guard).
129
+ */
130
+ export function registerIterateShortcuts(service: ShortcutsService, deps: ShortcutDeps): () => void {
131
+ try {
132
+ const dispose = service.register(buildObservatoryShortcutCommand(deps))
133
+ let done = false
134
+ return () => {
135
+ if (done) return
136
+ done = true
137
+ try {
138
+ dispose()
139
+ } catch {
140
+ /* noop */
141
+ }
142
+ }
143
+ } catch (err) {
144
+ // A duplicate id or an overlapping default binding must not take the
145
+ // plugin down — log and continue with no keyboard command (the docked
146
+ // panel and the context-menu "open" action remain the entry points).
147
+ if (typeof console !== 'undefined' && typeof console.error === 'function') {
148
+ console.error('[iterate-ui] keyboard-command registration failed; continuing without it.', err)
149
+ }
150
+ return () => {}
151
+ }
152
+ }