@openvcs/sdk 0.2.10 → 0.2.11

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.
@@ -1,8 +1,12 @@
1
1
  import type { MenubarItem } from '../types/menubar.js';
2
+ import type { PluginDelegates } from '../types/plugin.js';
3
+ import type { PluginRuntimeContext } from './contracts.js';
2
4
  type MenubarMenuOptions = {
3
5
  before?: string;
4
6
  after?: string;
5
7
  };
8
+ /** Runs one registered action handler by id. */
9
+ export declare function runRegisteredAction(actionId: string, ...args: unknown[]): Promise<boolean>;
6
10
  export interface MenuHandle {
7
11
  id: string;
8
12
  addItem(item: MenubarItem): void;
@@ -11,15 +15,28 @@ export interface MenuHandle {
11
15
  hideItem(actionId: string): void;
12
16
  showItem(actionId: string): void;
13
17
  }
18
+ /** Returns a menu by id, or null when it does not exist. */
14
19
  export declare function getMenu(menuId: string): MenuHandle | null;
20
+ /** Returns a menu by id, creating it when necessary. */
15
21
  export declare function getOrCreateMenu(menuId: string, label: string): MenuHandle | null;
22
+ /** Creates a menu at a specific insertion point. */
16
23
  export declare function createMenu(menuId: string, label: string, options?: MenubarMenuOptions): MenuHandle | null;
24
+ /** Adds one action item to a menu. */
17
25
  export declare function addMenuItem(menuId: string, item: MenubarItem): void;
26
+ /** Adds one separator item to a menu. */
18
27
  export declare function addMenuSeparator(menuId: string, beforeAction?: string): void;
28
+ /** Removes one menu entirely. */
19
29
  export declare function removeMenu(menuId: string): void;
30
+ /** Hides one menu from the host UI. */
20
31
  export declare function hideMenu(menuId: string): void;
32
+ /** Shows one previously hidden menu. */
21
33
  export declare function showMenu(menuId: string): void;
34
+ /** Registers an action handler by id. */
22
35
  export declare function registerAction(id: string, handler: (...args: unknown[]) => unknown): void;
36
+ /** Invokes a host command when the host exposes a direct invoke helper. */
23
37
  export declare function invoke<T = unknown>(cmd: string, args?: unknown): Promise<T>;
38
+ /** Emits a user notification when the host exposes one. */
24
39
  export declare function notify(msg: string): void;
40
+ /** Builds the SDK plugin delegates contributed by the menu registry. */
41
+ export declare function createMenuPluginDelegates(): PluginDelegates<PluginRuntimeContext>;
25
42
  export {};
@@ -2,6 +2,7 @@
2
2
  // Copyright © 2025-2026 OpenVCS Contributors
3
3
  // SPDX-License-Identifier: GPL-3.0-or-later
4
4
  Object.defineProperty(exports, "__esModule", { value: true });
5
+ exports.runRegisteredAction = runRegisteredAction;
5
6
  exports.getMenu = getMenu;
6
7
  exports.getOrCreateMenu = getOrCreateMenu;
7
8
  exports.createMenu = createMenu;
@@ -13,56 +14,298 @@ exports.showMenu = showMenu;
13
14
  exports.registerAction = registerAction;
14
15
  exports.invoke = invoke;
15
16
  exports.notify = notify;
17
+ exports.createMenuPluginDelegates = createMenuPluginDelegates;
18
+ const menus = new Map();
19
+ const menuOrder = [];
20
+ const actionHandlers = new Map();
21
+ let nextSyntheticId = 0;
22
+ /** Returns the OpenVCS global, when the host exposes one. */
16
23
  function getOpenVCS() {
17
24
  return globalThis.OpenVCS;
18
25
  }
19
- function castMenuHandle(menu) {
20
- if (!menu)
21
- return null;
26
+ /** Normalizes menu ids for stable map lookups. */
27
+ function normalizeMenuId(menuId) {
28
+ return String(menuId || '').trim();
29
+ }
30
+ /** Allocates a stable synthetic id for a generated menu item. */
31
+ function allocateSyntheticId(prefix) {
32
+ nextSyntheticId += 1;
33
+ return `${prefix}-${nextSyntheticId}`;
34
+ }
35
+ /** Returns a stored menu state or null if it does not exist. */
36
+ function getStoredMenu(menuId) {
37
+ return menus.get(normalizeMenuId(menuId)) || null;
38
+ }
39
+ /** Inserts a menu id into the ordering list. */
40
+ function placeMenuId(menuId, options) {
41
+ const id = normalizeMenuId(menuId);
42
+ const existingIndex = menuOrder.indexOf(id);
43
+ if (existingIndex >= 0) {
44
+ menuOrder.splice(existingIndex, 1);
45
+ }
46
+ const beforeId = normalizeMenuId(options?.before || '');
47
+ const afterId = normalizeMenuId(options?.after || '');
48
+ if (afterId) {
49
+ const afterIndex = menuOrder.indexOf(afterId);
50
+ if (afterIndex >= 0) {
51
+ menuOrder.splice(afterIndex + 1, 0, id);
52
+ return;
53
+ }
54
+ }
55
+ if (beforeId) {
56
+ const beforeIndex = menuOrder.indexOf(beforeId);
57
+ if (beforeIndex >= 0) {
58
+ menuOrder.splice(beforeIndex, 0, id);
59
+ return;
60
+ }
61
+ }
62
+ menuOrder.push(id);
63
+ }
64
+ /** Ensures a menu record exists for the provided id. */
65
+ function ensureStoredMenu(menuId, label, options) {
66
+ const id = normalizeMenuId(menuId);
67
+ const safeLabel = String(label || '').trim() || id;
68
+ let menu = menus.get(id);
69
+ if (!menu) {
70
+ menu = { id, label: safeLabel, items: [] };
71
+ menus.set(id, menu);
72
+ placeMenuId(id, options);
73
+ return menu;
74
+ }
75
+ menu.label = safeLabel;
76
+ placeMenuId(id, options);
22
77
  return menu;
23
78
  }
79
+ /** Finds one menu item by action id. */
80
+ function findMenuItem(menu, actionId) {
81
+ const id = normalizeMenuId(actionId);
82
+ return menu.items.find((item) => item.action === id) || null;
83
+ }
84
+ /** Inserts a menu item relative to before/after anchors when provided. */
85
+ function insertMenuItem(menu, item, before, after) {
86
+ const beforeId = normalizeMenuId(before || '');
87
+ const afterId = normalizeMenuId(after || '');
88
+ const insertAt = (index) => {
89
+ const existingIndex = menu.items.findIndex((entry) => entry.id === item.id || entry.action === item.action);
90
+ if (existingIndex >= 0) {
91
+ menu.items.splice(existingIndex, 1);
92
+ }
93
+ menu.items.splice(index, 0, item);
94
+ };
95
+ if (beforeId) {
96
+ const beforeIndex = menu.items.findIndex((entry) => entry.action === beforeId);
97
+ if (beforeIndex >= 0) {
98
+ insertAt(beforeIndex);
99
+ return;
100
+ }
101
+ }
102
+ if (afterId) {
103
+ const afterIndex = menu.items.findIndex((entry) => entry.action === afterId);
104
+ if (afterIndex >= 0) {
105
+ insertAt(afterIndex + 1);
106
+ return;
107
+ }
108
+ }
109
+ insertAt(menu.items.length);
110
+ }
111
+ /** Converts one stored menu item into a serializable plugin payload element. */
112
+ function serializeMenuItem(item) {
113
+ if (item.hidden)
114
+ return null;
115
+ if (item.kind === 'separator') {
116
+ return {
117
+ type: 'text',
118
+ id: item.id,
119
+ content: item.content || '—',
120
+ };
121
+ }
122
+ if (item.kind === 'text') {
123
+ return {
124
+ type: 'text',
125
+ id: item.id,
126
+ content: item.content || item.label,
127
+ };
128
+ }
129
+ return {
130
+ type: 'button',
131
+ id: item.action || item.id,
132
+ label: item.label,
133
+ };
134
+ }
135
+ /** Serializes the current menu registry for the host runtime. */
136
+ function serializeMenus() {
137
+ return menuOrder
138
+ .map((menuId, index) => {
139
+ const menu = menus.get(menuId);
140
+ if (!menu || menu.hidden)
141
+ return null;
142
+ return {
143
+ id: menu.id,
144
+ label: menu.label,
145
+ order: index + 1,
146
+ elements: menu.items
147
+ .map((item) => serializeMenuItem(item))
148
+ .filter((item) => Boolean(item)),
149
+ };
150
+ })
151
+ .filter((menu) => Boolean(menu));
152
+ }
153
+ /** Runs one registered action handler by id. */
154
+ async function runRegisteredAction(actionId, ...args) {
155
+ const id = String(actionId || '').trim();
156
+ if (!id)
157
+ return false;
158
+ const handler = actionHandlers.get(id);
159
+ if (!handler)
160
+ return false;
161
+ await handler(...args);
162
+ return true;
163
+ }
164
+ /** Creates a stable, mutation-based handle for one menu id. */
165
+ function createMenuHandle(menuId) {
166
+ const id = normalizeMenuId(menuId);
167
+ return {
168
+ id,
169
+ addItem(item) {
170
+ const menu = getStoredMenu(this.id) || ensureStoredMenu(this.id, this.id);
171
+ const label = String(item?.label || '').trim();
172
+ const action = String(item?.action || '').trim();
173
+ if (!label || !action)
174
+ return;
175
+ const entry = {
176
+ kind: 'button',
177
+ id: action,
178
+ label,
179
+ title: item.title,
180
+ action,
181
+ };
182
+ insertMenuItem(menu, entry, item.before, item.after);
183
+ const openvcs = getOpenVCS();
184
+ if (!openvcs?.menus)
185
+ return;
186
+ openvcs.menus.getOrCreate(menu.id, menu.label);
187
+ openvcs.menus.addMenuItem(menu.id, item);
188
+ },
189
+ addSeparator(beforeAction) {
190
+ const menu = getStoredMenu(this.id) || ensureStoredMenu(this.id, this.id);
191
+ const entry = {
192
+ kind: 'separator',
193
+ id: allocateSyntheticId(`${menu.id}-separator`),
194
+ label: 'Separator',
195
+ content: '—',
196
+ };
197
+ insertMenuItem(menu, entry, beforeAction);
198
+ const openvcs = getOpenVCS();
199
+ if (!openvcs?.menus)
200
+ return;
201
+ openvcs.menus.getOrCreate(menu.id, menu.label);
202
+ openvcs.menus.addMenuSeparator(menu.id, beforeAction);
203
+ },
204
+ removeItem(actionId) {
205
+ const menu = getStoredMenu(this.id);
206
+ if (!menu)
207
+ return;
208
+ const idToRemove = normalizeMenuId(actionId);
209
+ menu.items = menu.items.filter((item) => item.action !== idToRemove);
210
+ const openvcs = getOpenVCS();
211
+ if (openvcs?.menus) {
212
+ const handle = openvcs.menus.get(this.id);
213
+ handle?.removeItem(idToRemove);
214
+ }
215
+ },
216
+ hideItem(actionId) {
217
+ const menu = getStoredMenu(this.id);
218
+ if (!menu)
219
+ return;
220
+ const item = findMenuItem(menu, actionId);
221
+ if (item)
222
+ item.hidden = true;
223
+ const openvcs = getOpenVCS();
224
+ if (!openvcs?.menus)
225
+ return;
226
+ const handle = openvcs.menus.get(this.id);
227
+ handle?.hideItem(actionId);
228
+ },
229
+ showItem(actionId) {
230
+ const menu = getStoredMenu(this.id);
231
+ if (!menu)
232
+ return;
233
+ const item = findMenuItem(menu, actionId);
234
+ if (item)
235
+ item.hidden = false;
236
+ const openvcs = getOpenVCS();
237
+ if (!openvcs?.menus)
238
+ return;
239
+ const handle = openvcs.menus.get(this.id);
240
+ handle?.showItem(actionId);
241
+ },
242
+ };
243
+ }
244
+ /** Returns a menu by id, or null when it does not exist. */
24
245
  function getMenu(menuId) {
25
- const openvcs = getOpenVCS();
26
- if (!openvcs?.menus)
246
+ const stored = getStoredMenu(menuId);
247
+ if (!stored)
27
248
  return null;
28
- return castMenuHandle(openvcs.menus.get(menuId));
249
+ return createMenuHandle(stored.id);
29
250
  }
251
+ /** Returns a menu by id, creating it when necessary. */
30
252
  function getOrCreateMenu(menuId, label) {
31
- const openvcs = getOpenVCS();
32
- if (!openvcs?.menus)
33
- return null;
34
- return castMenuHandle(openvcs.menus.getOrCreate(menuId, label));
253
+ const stored = ensureStoredMenu(menuId, label);
254
+ return createMenuHandle(stored.id);
35
255
  }
256
+ /** Creates a menu at a specific insertion point. */
36
257
  function createMenu(menuId, label, options) {
37
- const openvcs = getOpenVCS();
38
- if (!openvcs?.menus)
39
- return null;
40
- return castMenuHandle(openvcs.menus.create(menuId, label, options));
258
+ const stored = ensureStoredMenu(menuId, label, options);
259
+ return createMenuHandle(stored.id);
41
260
  }
261
+ /** Adds one action item to a menu. */
42
262
  function addMenuItem(menuId, item) {
43
- const openvcs = getOpenVCS();
44
- openvcs?.menus?.addMenuItem(menuId, item);
263
+ const menu = getStoredMenu(menuId) || ensureStoredMenu(menuId, menuId);
264
+ const handle = createMenuHandle(menu.id);
265
+ handle.addItem(item);
45
266
  }
267
+ /** Adds one separator item to a menu. */
46
268
  function addMenuSeparator(menuId, beforeAction) {
47
- const openvcs = getOpenVCS();
48
- openvcs?.menus?.addMenuSeparator(menuId, beforeAction);
269
+ const menu = getStoredMenu(menuId) || ensureStoredMenu(menuId, menuId);
270
+ const handle = createMenuHandle(menu.id);
271
+ handle.addSeparator(beforeAction);
49
272
  }
273
+ /** Removes one menu entirely. */
50
274
  function removeMenu(menuId) {
275
+ const id = normalizeMenuId(menuId);
276
+ menus.delete(id);
277
+ const index = menuOrder.indexOf(id);
278
+ if (index >= 0)
279
+ menuOrder.splice(index, 1);
51
280
  const openvcs = getOpenVCS();
52
- openvcs?.menus?.remove(menuId);
281
+ openvcs?.menus?.remove(id);
53
282
  }
283
+ /** Hides one menu from the host UI. */
54
284
  function hideMenu(menuId) {
285
+ const menu = getStoredMenu(menuId);
286
+ if (menu)
287
+ menu.hidden = true;
55
288
  const openvcs = getOpenVCS();
56
- openvcs?.menus?.hide(menuId);
289
+ openvcs?.menus?.hide(normalizeMenuId(menuId));
57
290
  }
291
+ /** Shows one previously hidden menu. */
58
292
  function showMenu(menuId) {
293
+ const menu = getStoredMenu(menuId);
294
+ if (menu)
295
+ menu.hidden = false;
59
296
  const openvcs = getOpenVCS();
60
- openvcs?.menus?.show(menuId);
297
+ openvcs?.menus?.show(normalizeMenuId(menuId));
61
298
  }
299
+ /** Registers an action handler by id. */
62
300
  function registerAction(id, handler) {
301
+ const key = String(id || '').trim();
302
+ if (!key)
303
+ return;
304
+ actionHandlers.set(key, handler);
63
305
  const openvcs = getOpenVCS();
64
- openvcs?.registerAction(id, handler);
306
+ openvcs?.registerAction(key, handler);
65
307
  }
308
+ /** Invokes a host command when the host exposes a direct invoke helper. */
66
309
  function invoke(cmd, args) {
67
310
  const openvcs = getOpenVCS();
68
311
  if (!openvcs) {
@@ -70,7 +313,23 @@ function invoke(cmd, args) {
70
313
  }
71
314
  return openvcs.invoke(cmd, args);
72
315
  }
316
+ /** Emits a user notification when the host exposes one. */
73
317
  function notify(msg) {
74
318
  const openvcs = getOpenVCS();
75
319
  openvcs?.notify(msg);
76
320
  }
321
+ /** Builds the SDK plugin delegates contributed by the menu registry. */
322
+ function createMenuPluginDelegates() {
323
+ return {
324
+ async 'plugin.get_menus'() {
325
+ return serializeMenus();
326
+ },
327
+ async 'plugin.handle_action'(params) {
328
+ const actionId = String(params?.action_id || '').trim();
329
+ if (!actionId)
330
+ return null;
331
+ await runRegisteredAction(actionId);
332
+ return null;
333
+ },
334
+ };
335
+ }
@@ -5,10 +5,37 @@ Object.defineProperty(exports, "__esModule", { value: true });
5
5
  exports.createRegisteredPluginRuntime = createRegisteredPluginRuntime;
6
6
  exports.bootstrapPluginModule = bootstrapPluginModule;
7
7
  const factory_1 = require("./factory");
8
+ const menu_1 = require("./menu");
8
9
  /** Creates a runtime from one declarative plugin module definition. */
9
10
  function createRegisteredPluginRuntime(definition = {}) {
11
+ const menuDelegates = (0, menu_1.createMenuPluginDelegates)();
12
+ const explicitPluginDelegates = definition.plugin ?? {};
13
+ const explicitGetMenus = explicitPluginDelegates['plugin.get_menus'];
14
+ const explicitHandleAction = explicitPluginDelegates['plugin.handle_action'];
10
15
  const options = {
11
- plugin: definition.plugin,
16
+ plugin: {
17
+ ...explicitPluginDelegates,
18
+ 'plugin.get_menus': async (params, ctx) => {
19
+ const menus = await menuDelegates['plugin.get_menus']?.(params, ctx);
20
+ const explicitMenus = explicitGetMenus
21
+ ? await explicitGetMenus(params, ctx)
22
+ : [];
23
+ return [
24
+ ...(Array.isArray(explicitMenus) ? explicitMenus : []),
25
+ ...(Array.isArray(menus) ? menus : []),
26
+ ];
27
+ },
28
+ 'plugin.handle_action': async (params, ctx) => {
29
+ const actionId = String(params?.action_id || '').trim();
30
+ if (actionId && (await (0, menu_1.runRegisteredAction)(actionId))) {
31
+ return null;
32
+ }
33
+ if (explicitHandleAction) {
34
+ return explicitHandleAction(params, ctx);
35
+ }
36
+ return null;
37
+ },
38
+ },
12
39
  vcs: definition.vcs,
13
40
  implements: definition.implements,
14
41
  logTarget: definition.logTarget,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openvcs/sdk",
3
- "version": "0.2.10",
3
+ "version": "0.2.11",
4
4
  "description": "OpenVCS SDK CLI for plugin scaffolding and .ovcsp tar.gz packaging",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "homepage": "https://openvcs.app/",
@@ -2,9 +2,18 @@
2
2
  // SPDX-License-Identifier: GPL-3.0-or-later
3
3
 
4
4
  import type { MenubarItem } from '../types/menubar.js';
5
+ import type {
6
+ PluginDelegates,
7
+ PluginHandleActionParams,
8
+ PluginMenuDefinition,
9
+ } from '../types/plugin.js';
10
+
11
+ import type { PluginRuntimeContext } from './contracts.js';
5
12
 
6
13
  type MenubarMenuOptions = { before?: string; after?: string };
7
14
 
15
+ type MenuEntryKind = 'button' | 'text' | 'separator';
16
+
8
17
  type OpenVCSGlobal = typeof globalThis & {
9
18
  OpenVCS?: {
10
19
  menus?: {
@@ -23,10 +32,206 @@ type OpenVCSGlobal = typeof globalThis & {
23
32
  };
24
33
  };
25
34
 
35
+ interface StoredMenuItem {
36
+ kind: MenuEntryKind;
37
+ id: string;
38
+ label: string;
39
+ title?: string;
40
+ content?: string;
41
+ action?: string;
42
+ hidden?: boolean;
43
+ }
44
+
45
+ interface StoredMenuState {
46
+ id: string;
47
+ label: string;
48
+ hidden?: boolean;
49
+ items: StoredMenuItem[];
50
+ }
51
+
52
+ interface SerializedMenuItem {
53
+ type: 'button' | 'text';
54
+ id: string;
55
+ label?: string;
56
+ content?: string;
57
+ }
58
+
59
+ interface SerializedMenuDefinition {
60
+ id: string;
61
+ label: string;
62
+ order: number;
63
+ elements: SerializedMenuItem[];
64
+ }
65
+
66
+ const menus = new Map<string, StoredMenuState>();
67
+ const menuOrder: string[] = [];
68
+ const actionHandlers = new Map<string, (...args: unknown[]) => unknown>();
69
+ let nextSyntheticId = 0;
70
+
71
+ /** Returns the OpenVCS global, when the host exposes one. */
26
72
  function getOpenVCS() {
27
73
  return (globalThis as OpenVCSGlobal).OpenVCS;
28
74
  }
29
75
 
76
+ /** Normalizes menu ids for stable map lookups. */
77
+ function normalizeMenuId(menuId: string): string {
78
+ return String(menuId || '').trim();
79
+ }
80
+
81
+ /** Allocates a stable synthetic id for a generated menu item. */
82
+ function allocateSyntheticId(prefix: string): string {
83
+ nextSyntheticId += 1;
84
+ return `${prefix}-${nextSyntheticId}`;
85
+ }
86
+
87
+ /** Returns a stored menu state or null if it does not exist. */
88
+ function getStoredMenu(menuId: string): StoredMenuState | null {
89
+ return menus.get(normalizeMenuId(menuId)) || null;
90
+ }
91
+
92
+ /** Inserts a menu id into the ordering list. */
93
+ function placeMenuId(menuId: string, options?: MenubarMenuOptions): void {
94
+ const id = normalizeMenuId(menuId);
95
+ const existingIndex = menuOrder.indexOf(id);
96
+ if (existingIndex >= 0) {
97
+ menuOrder.splice(existingIndex, 1);
98
+ }
99
+
100
+ const beforeId = normalizeMenuId(options?.before || '');
101
+ const afterId = normalizeMenuId(options?.after || '');
102
+
103
+ if (afterId) {
104
+ const afterIndex = menuOrder.indexOf(afterId);
105
+ if (afterIndex >= 0) {
106
+ menuOrder.splice(afterIndex + 1, 0, id);
107
+ return;
108
+ }
109
+ }
110
+
111
+ if (beforeId) {
112
+ const beforeIndex = menuOrder.indexOf(beforeId);
113
+ if (beforeIndex >= 0) {
114
+ menuOrder.splice(beforeIndex, 0, id);
115
+ return;
116
+ }
117
+ }
118
+
119
+ menuOrder.push(id);
120
+ }
121
+
122
+ /** Ensures a menu record exists for the provided id. */
123
+ function ensureStoredMenu(menuId: string, label: string, options?: MenubarMenuOptions): StoredMenuState {
124
+ const id = normalizeMenuId(menuId);
125
+ const safeLabel = String(label || '').trim() || id;
126
+ let menu = menus.get(id);
127
+
128
+ if (!menu) {
129
+ menu = { id, label: safeLabel, items: [] };
130
+ menus.set(id, menu);
131
+ placeMenuId(id, options);
132
+ return menu;
133
+ }
134
+
135
+ menu.label = safeLabel;
136
+ placeMenuId(id, options);
137
+ return menu;
138
+ }
139
+
140
+ /** Finds one menu item by action id. */
141
+ function findMenuItem(menu: StoredMenuState, actionId: string): StoredMenuItem | null {
142
+ const id = normalizeMenuId(actionId);
143
+ return menu.items.find((item) => item.action === id) || null;
144
+ }
145
+
146
+ /** Inserts a menu item relative to before/after anchors when provided. */
147
+ function insertMenuItem(menu: StoredMenuState, item: StoredMenuItem, before?: string, after?: string): void {
148
+ const beforeId = normalizeMenuId(before || '');
149
+ const afterId = normalizeMenuId(after || '');
150
+
151
+ const insertAt = (index: number) => {
152
+ const existingIndex = menu.items.findIndex((entry) => entry.id === item.id || entry.action === item.action);
153
+ if (existingIndex >= 0) {
154
+ menu.items.splice(existingIndex, 1);
155
+ }
156
+ menu.items.splice(index, 0, item);
157
+ };
158
+
159
+ if (beforeId) {
160
+ const beforeIndex = menu.items.findIndex((entry) => entry.action === beforeId);
161
+ if (beforeIndex >= 0) {
162
+ insertAt(beforeIndex);
163
+ return;
164
+ }
165
+ }
166
+
167
+ if (afterId) {
168
+ const afterIndex = menu.items.findIndex((entry) => entry.action === afterId);
169
+ if (afterIndex >= 0) {
170
+ insertAt(afterIndex + 1);
171
+ return;
172
+ }
173
+ }
174
+
175
+ insertAt(menu.items.length);
176
+ }
177
+
178
+ /** Converts one stored menu item into a serializable plugin payload element. */
179
+ function serializeMenuItem(item: StoredMenuItem): SerializedMenuItem | null {
180
+ if (item.hidden) return null;
181
+
182
+ if (item.kind === 'separator') {
183
+ return {
184
+ type: 'text',
185
+ id: item.id,
186
+ content: item.content || '—',
187
+ };
188
+ }
189
+
190
+ if (item.kind === 'text') {
191
+ return {
192
+ type: 'text',
193
+ id: item.id,
194
+ content: item.content || item.label,
195
+ };
196
+ }
197
+
198
+ return {
199
+ type: 'button',
200
+ id: item.action || item.id,
201
+ label: item.label,
202
+ };
203
+ }
204
+
205
+ /** Serializes the current menu registry for the host runtime. */
206
+ function serializeMenus(): SerializedMenuDefinition[] {
207
+ return menuOrder
208
+ .map((menuId, index) => {
209
+ const menu = menus.get(menuId);
210
+ if (!menu || menu.hidden) return null;
211
+ return {
212
+ id: menu.id,
213
+ label: menu.label,
214
+ order: index + 1,
215
+ elements: menu.items
216
+ .map((item) => serializeMenuItem(item))
217
+ .filter((item): item is SerializedMenuItem => Boolean(item)),
218
+ };
219
+ })
220
+ .filter((menu): menu is SerializedMenuDefinition => Boolean(menu));
221
+ }
222
+
223
+ /** Runs one registered action handler by id. */
224
+ export async function runRegisteredAction(actionId: string, ...args: unknown[]): Promise<boolean> {
225
+ const id = String(actionId || '').trim();
226
+ if (!id) return false;
227
+
228
+ const handler = actionHandlers.get(id);
229
+ if (!handler) return false;
230
+
231
+ await handler(...args);
232
+ return true;
233
+ }
234
+
30
235
  export interface MenuHandle {
31
236
  id: string;
32
237
  addItem(item: MenubarItem): void;
@@ -36,59 +241,157 @@ export interface MenuHandle {
36
241
  showItem(actionId: string): void;
37
242
  }
38
243
 
39
- function castMenuHandle(menu: unknown): MenuHandle | null {
40
- if (!menu) return null;
41
- return menu as MenuHandle;
244
+ /** Creates a stable, mutation-based handle for one menu id. */
245
+ function createMenuHandle(menuId: string): MenuHandle {
246
+ const id = normalizeMenuId(menuId);
247
+
248
+ return {
249
+ id,
250
+ addItem(item: MenubarItem) {
251
+ const menu = getStoredMenu(this.id) || ensureStoredMenu(this.id, this.id);
252
+ const label = String(item?.label || '').trim();
253
+ const action = String(item?.action || '').trim();
254
+ if (!label || !action) return;
255
+
256
+ const entry: StoredMenuItem = {
257
+ kind: 'button',
258
+ id: action,
259
+ label,
260
+ title: item.title,
261
+ action,
262
+ };
263
+ insertMenuItem(menu, entry, item.before, item.after);
264
+
265
+ const openvcs = getOpenVCS();
266
+ if (!openvcs?.menus) return;
267
+ openvcs.menus.getOrCreate(menu.id, menu.label);
268
+ openvcs.menus.addMenuItem(menu.id, item);
269
+ },
270
+ addSeparator(beforeAction?: string) {
271
+ const menu = getStoredMenu(this.id) || ensureStoredMenu(this.id, this.id);
272
+ const entry: StoredMenuItem = {
273
+ kind: 'separator',
274
+ id: allocateSyntheticId(`${menu.id}-separator`),
275
+ label: 'Separator',
276
+ content: '—',
277
+ };
278
+ insertMenuItem(menu, entry, beforeAction);
279
+
280
+ const openvcs = getOpenVCS();
281
+ if (!openvcs?.menus) return;
282
+ openvcs.menus.getOrCreate(menu.id, menu.label);
283
+ openvcs.menus.addMenuSeparator(menu.id, beforeAction);
284
+ },
285
+ removeItem(actionId: string) {
286
+ const menu = getStoredMenu(this.id);
287
+ if (!menu) return;
288
+ const idToRemove = normalizeMenuId(actionId);
289
+ menu.items = menu.items.filter((item) => item.action !== idToRemove);
290
+
291
+ const openvcs = getOpenVCS();
292
+ if (openvcs?.menus) {
293
+ const handle = openvcs.menus.get(this.id) as MenuHandle | null;
294
+ handle?.removeItem(idToRemove);
295
+ }
296
+ },
297
+ hideItem(actionId: string) {
298
+ const menu = getStoredMenu(this.id);
299
+ if (!menu) return;
300
+ const item = findMenuItem(menu, actionId);
301
+ if (item) item.hidden = true;
302
+
303
+ const openvcs = getOpenVCS();
304
+ if (!openvcs?.menus) return;
305
+ const handle = openvcs.menus.get(this.id) as MenuHandle | null;
306
+ handle?.hideItem(actionId);
307
+ },
308
+ showItem(actionId: string) {
309
+ const menu = getStoredMenu(this.id);
310
+ if (!menu) return;
311
+ const item = findMenuItem(menu, actionId);
312
+ if (item) item.hidden = false;
313
+
314
+ const openvcs = getOpenVCS();
315
+ if (!openvcs?.menus) return;
316
+ const handle = openvcs.menus.get(this.id) as MenuHandle | null;
317
+ handle?.showItem(actionId);
318
+ },
319
+ };
42
320
  }
43
321
 
322
+ /** Returns a menu by id, or null when it does not exist. */
44
323
  export function getMenu(menuId: string): MenuHandle | null {
45
- const openvcs = getOpenVCS();
46
- if (!openvcs?.menus) return null;
47
- return castMenuHandle(openvcs.menus.get(menuId));
324
+ const stored = getStoredMenu(menuId);
325
+ if (!stored) return null;
326
+ return createMenuHandle(stored.id);
48
327
  }
49
328
 
329
+ /** Returns a menu by id, creating it when necessary. */
50
330
  export function getOrCreateMenu(menuId: string, label: string): MenuHandle | null {
51
- const openvcs = getOpenVCS();
52
- if (!openvcs?.menus) return null;
53
- return castMenuHandle(openvcs.menus.getOrCreate(menuId, label));
331
+ const stored = ensureStoredMenu(menuId, label);
332
+ return createMenuHandle(stored.id);
54
333
  }
55
334
 
335
+ /** Creates a menu at a specific insertion point. */
56
336
  export function createMenu(menuId: string, label: string, options?: MenubarMenuOptions): MenuHandle | null {
57
- const openvcs = getOpenVCS();
58
- if (!openvcs?.menus) return null;
59
- return castMenuHandle(openvcs.menus.create(menuId, label, options));
337
+ const stored = ensureStoredMenu(menuId, label, options);
338
+ return createMenuHandle(stored.id);
60
339
  }
61
340
 
341
+ /** Adds one action item to a menu. */
62
342
  export function addMenuItem(menuId: string, item: MenubarItem): void {
63
- const openvcs = getOpenVCS();
64
- openvcs?.menus?.addMenuItem(menuId, item);
343
+ const menu = getStoredMenu(menuId) || ensureStoredMenu(menuId, menuId);
344
+ const handle = createMenuHandle(menu.id);
345
+ handle.addItem(item);
65
346
  }
66
347
 
348
+ /** Adds one separator item to a menu. */
67
349
  export function addMenuSeparator(menuId: string, beforeAction?: string): void {
68
- const openvcs = getOpenVCS();
69
- openvcs?.menus?.addMenuSeparator(menuId, beforeAction);
350
+ const menu = getStoredMenu(menuId) || ensureStoredMenu(menuId, menuId);
351
+ const handle = createMenuHandle(menu.id);
352
+ handle.addSeparator(beforeAction);
70
353
  }
71
354
 
355
+ /** Removes one menu entirely. */
72
356
  export function removeMenu(menuId: string): void {
357
+ const id = normalizeMenuId(menuId);
358
+ menus.delete(id);
359
+ const index = menuOrder.indexOf(id);
360
+ if (index >= 0) menuOrder.splice(index, 1);
361
+
73
362
  const openvcs = getOpenVCS();
74
- openvcs?.menus?.remove(menuId);
363
+ openvcs?.menus?.remove(id);
75
364
  }
76
365
 
366
+ /** Hides one menu from the host UI. */
77
367
  export function hideMenu(menuId: string): void {
368
+ const menu = getStoredMenu(menuId);
369
+ if (menu) menu.hidden = true;
370
+
78
371
  const openvcs = getOpenVCS();
79
- openvcs?.menus?.hide(menuId);
372
+ openvcs?.menus?.hide(normalizeMenuId(menuId));
80
373
  }
81
374
 
375
+ /** Shows one previously hidden menu. */
82
376
  export function showMenu(menuId: string): void {
377
+ const menu = getStoredMenu(menuId);
378
+ if (menu) menu.hidden = false;
379
+
83
380
  const openvcs = getOpenVCS();
84
- openvcs?.menus?.show(menuId);
381
+ openvcs?.menus?.show(normalizeMenuId(menuId));
85
382
  }
86
383
 
384
+ /** Registers an action handler by id. */
87
385
  export function registerAction(id: string, handler: (...args: unknown[]) => unknown): void {
386
+ const key = String(id || '').trim();
387
+ if (!key) return;
388
+ actionHandlers.set(key, handler);
389
+
88
390
  const openvcs = getOpenVCS();
89
- openvcs?.registerAction(id, handler);
391
+ openvcs?.registerAction(key, handler);
90
392
  }
91
393
 
394
+ /** Invokes a host command when the host exposes a direct invoke helper. */
92
395
  export function invoke<T = unknown>(cmd: string, args?: unknown): Promise<T> {
93
396
  const openvcs = getOpenVCS();
94
397
  if (!openvcs) {
@@ -97,7 +400,23 @@ export function invoke<T = unknown>(cmd: string, args?: unknown): Promise<T> {
97
400
  return openvcs.invoke(cmd, args);
98
401
  }
99
402
 
403
+ /** Emits a user notification when the host exposes one. */
100
404
  export function notify(msg: string): void {
101
405
  const openvcs = getOpenVCS();
102
406
  openvcs?.notify(msg);
103
407
  }
408
+
409
+ /** Builds the SDK plugin delegates contributed by the menu registry. */
410
+ export function createMenuPluginDelegates(): PluginDelegates<PluginRuntimeContext> {
411
+ return {
412
+ async 'plugin.get_menus'(): Promise<PluginMenuDefinition[]> {
413
+ return serializeMenus() as unknown as PluginMenuDefinition[];
414
+ },
415
+ async 'plugin.handle_action'(params: PluginHandleActionParams): Promise<null> {
416
+ const actionId = String(params?.action_id || '').trim();
417
+ if (!actionId) return null;
418
+ await runRegisteredAction(actionId);
419
+ return null;
420
+ },
421
+ };
422
+ }
@@ -14,6 +14,10 @@ import type {
14
14
  PluginRuntimeTransport,
15
15
  } from './contracts';
16
16
  import { createPluginRuntime } from './factory';
17
+ import {
18
+ createMenuPluginDelegates,
19
+ runRegisteredAction,
20
+ } from './menu';
17
21
 
18
22
  /** Describes the plugin module startup hook invoked by the generated bootstrap. */
19
23
  export type OnPluginStartHandler = () => void | Promise<void>;
@@ -56,8 +60,35 @@ export interface BootstrapPluginModuleOptions {
56
60
  export function createRegisteredPluginRuntime(
57
61
  definition: PluginModuleDefinition = {},
58
62
  ): PluginRuntime {
63
+ const menuDelegates = createMenuPluginDelegates();
64
+ const explicitPluginDelegates = definition.plugin ?? {};
65
+ const explicitGetMenus = explicitPluginDelegates['plugin.get_menus'];
66
+ const explicitHandleAction = explicitPluginDelegates['plugin.handle_action'];
67
+
59
68
  const options: CreatePluginRuntimeOptions = {
60
- plugin: definition.plugin,
69
+ plugin: {
70
+ ...explicitPluginDelegates,
71
+ 'plugin.get_menus': async (params, ctx) => {
72
+ const menus = await menuDelegates['plugin.get_menus']?.(params, ctx);
73
+ const explicitMenus = explicitGetMenus
74
+ ? await explicitGetMenus(params, ctx)
75
+ : [];
76
+ return [
77
+ ...(Array.isArray(explicitMenus) ? explicitMenus : []),
78
+ ...(Array.isArray(menus) ? menus : []),
79
+ ];
80
+ },
81
+ 'plugin.handle_action': async (params, ctx) => {
82
+ const actionId = String(params?.action_id || '').trim();
83
+ if (actionId && (await runRegisteredAction(actionId))) {
84
+ return null;
85
+ }
86
+ if (explicitHandleAction) {
87
+ return explicitHandleAction(params, ctx);
88
+ }
89
+ return null;
90
+ },
91
+ },
61
92
  vcs: definition.vcs,
62
93
  implements: definition.implements,
63
94
  logTarget: definition.logTarget,