dsh-shortcut-extensions 1.0.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/client.js ADDED
@@ -0,0 +1,2332 @@
1
+ window.__ModuleLoader__.load({
2
+ id: 'dsh-shortcut-extensions',
3
+ factory(require) {
4
+ const React = require('react');
5
+ const h = React.createElement;
6
+ const { useState, useEffect, useMemo, useRef, useReducer, useCallback, useSyncExternalStore } = React;
7
+
8
+ const NS = 'shortcutExt';
9
+ const PANEL_ID = 'shortcut-ext';
10
+ const PIN_KEY = 'dsh.quick-switch.pinned.v1';
11
+ const TOGGLE_KEY = 'dsh.shortcut-ext.enabled.v1';
12
+
13
+ /* ------------------------------------------------------------------ */
14
+ /* Locale dictionaries */
15
+ /* ------------------------------------------------------------------ */
16
+
17
+ const zh = {
18
+ 'panel.title': '快捷键拓展',
19
+ 'panel.subtitle': '已注册的快捷指令 · 可开关 · 键位跟随 设置 → 快捷键',
20
+ 'panel.commands': '指令',
21
+ 'panel.search': '搜索指令',
22
+ 'panel.empty': '没有匹配的指令',
23
+ 'panel.allOn': '全部开启',
24
+ 'panel.allOff': '全部关闭',
25
+ 'panel.enabled': '已启用',
26
+ 'panel.disabled': '已停用',
27
+ 'filter.all': '全部',
28
+ 'filter.on': '已启用',
29
+ 'filter.off': '已停用',
30
+ 'tab.commands': '快捷键拓展',
31
+ 'tab.system': '快捷键速查',
32
+ 'system.reset': '恢复默认',
33
+ 'system.fixed': '固定',
34
+ 'system.saveFailed': '保存失败,请重试',
35
+ 'system.hint': '改键与恢复仅作用于本设备;「固定」项由宿主保留,不可改键。',
36
+ 'custom.add': '添加指令',
37
+ 'custom.title': '新增自定义指令',
38
+ 'custom.action': '动作',
39
+ 'custom.param': '参数',
40
+ 'custom.key': '快捷键',
41
+ 'custom.record': '按下快捷键…',
42
+ 'custom.recordHelp': '松开即确认;Esc 取消录制',
43
+ 'custom.save': '保存',
44
+ 'custom.cancel': '取消',
45
+ 'custom.remove': '删除',
46
+ 'custom.needKey': '请先录制一个快捷键',
47
+ 'custom.keyIssue': '该组合不可用(系统保留或不受支持)',
48
+ 'custom.modifierRequired': '请同时按下 Command、Ctrl 或 Alt 修饰键',
49
+ 'custom.conflict': '该组合已被其它命令占用',
50
+ 'preset.readOnly': '仅可查看',
51
+ 'preset.workspaceWrite': '工作区内修改',
52
+ 'preset.full': '完全权限',
53
+ 'action.openModelPalette': '打开模型列表',
54
+ 'action.openSessionPalette': '打开会话列表',
55
+ 'action.jumpCompleted': '查找下一个已回答的会话',
56
+ 'action.focusComposer': '聚焦聊天输入框',
57
+ 'action.togglePermission': '权限切换',
58
+ 'action.setPermission': '设置权限为指定档位',
59
+ 'action.nextSession': '下一个会话',
60
+ 'action.prevSession': '上一个会话',
61
+ 'action.toggleSidebar': '显示 / 隐藏侧栏',
62
+ 'action.showConversation': '回到对话视图',
63
+ 'action.showDeck': '打开快捷键拓展面板',
64
+ 'action.newSession': '新建会话',
65
+ 'palette.title': '切换模型',
66
+ 'palette.placeholder': '输入以搜索模型(置顶模型排在最前)',
67
+ 'palette.loading': '模型目录加载中…',
68
+ 'palette.loadError': '模型目录加载失败',
69
+ 'palette.empty': '暂无可用模型',
70
+ 'palette.noMatch': '无匹配模型',
71
+ 'palette.noSession': '当前没有打开的会话,先打开或新建一个会话',
72
+ 'palette.switching': '切换中…',
73
+ 'palette.selectFailed': '切换失败',
74
+ 'palette.pin': '置顶',
75
+ 'palette.providerFailures': '部分模型提供方暂不可用',
76
+ 'palette.hint.nav': '选择',
77
+ 'palette.hint.confirm': '切换',
78
+ 'palette.hint.close': '关闭',
79
+ 'palette.hint.pin': '置顶/取消置顶',
80
+ 'palette.hint.tag': '点标签过滤同族',
81
+ 'tag.reasoning': '推理',
82
+ 'shortcut.modelPalette': '打开模型快速切换',
83
+ 'shortcut.permToggle': '权限切换',
84
+ 'shortcut.sessionPalette': '打开会话列表',
85
+ 'shortcut.nextSession': '下一个会话',
86
+ 'shortcut.prevSession': '上一个会话',
87
+ 'shortcut.focusComposer': '聚焦聊天输入框',
88
+ 'shortcut.jumpCompleted': '查找下一个已回答的会话',
89
+ 'shortcut.unbound': '未绑定',
90
+ 'session.none': '未打开会话',
91
+ 'session.noneOther': '没有其它会话可切换',
92
+ 'session.noneCompleted': '暂时没有刚回答完的会话',
93
+ 'session.paletteTitle': '切换会话',
94
+ 'session.placeholder': '搜索会话(含已归档)',
95
+ 'session.badgeArchived': '已归档',
96
+ 'session.newSession': '新会话',
97
+ 'session.empty': '没有匹配的会话',
98
+ 'session.noneYet': '暂无会话',
99
+ 'session.busy': '处理中…',
100
+ 'session.restoreFailed': '恢复失败',
101
+ 'session.openFailed': '打开失败',
102
+ 'session.hint.nav': '选择',
103
+ 'session.hint.open': '打开(归档自动恢复)',
104
+ 'session.hint.close': '关闭',
105
+ 'session.previewTitle': '我的提问',
106
+ 'session.previewEmpty': '暂无提问记录',
107
+ 'session.previewUnavailable': '该部署未提供会话大纲,无法预览',
108
+ 'session.previewLoading': '预览加载中…',
109
+ 'perm.noSession': '打开一个会话后即可切换权限',
110
+ 'perm.unavailable': '当前部署未提供权限预设',
111
+ 'perm.section': '权限循环',
112
+ 'perm.current': '当前',
113
+ 'perm.cycleHint': '点击档位加入或移出循环(至少两个);切换键按此顺序循环。',
114
+ 'perm.needTwo': '至少保留两个权限档位',
115
+ 'perm.autoExcluded': '自动审查为会话级临时档,不参与循环。',
116
+ 'perm.catalogLoading': '权限目录加载中…',
117
+ 'time.now': '刚刚',
118
+ 'time.minutes': '{n} 分钟前',
119
+ 'time.hours': '{n} 小时前',
120
+ 'time.days': '{n} 天前'
121
+ };
122
+
123
+ const en = {
124
+ 'panel.title': 'Shortcut Extensions',
125
+ 'panel.subtitle': 'Registered shortcut commands · toggleable · keys follow Settings → Shortcuts',
126
+ 'panel.commands': 'Commands',
127
+ 'panel.search': 'Search commands',
128
+ 'panel.empty': 'No matching commands',
129
+ 'panel.allOn': 'Enable all',
130
+ 'panel.allOff': 'Disable all',
131
+ 'panel.enabled': 'On',
132
+ 'panel.disabled': 'Off',
133
+ 'filter.all': 'All',
134
+ 'filter.on': 'Enabled',
135
+ 'filter.off': 'Disabled',
136
+ 'tab.commands': 'Shortcut Extensions',
137
+ 'tab.system': 'Shortcut reference',
138
+ 'system.reset': 'Reset',
139
+ 'system.fixed': 'Fixed',
140
+ 'system.saveFailed': 'Save failed; try again',
141
+ 'system.hint': 'Rebinds and resets are device-local; "Fixed" rows are host-reserved and cannot be rebound.',
142
+ 'custom.add': 'Add command',
143
+ 'custom.title': 'New custom command',
144
+ 'custom.action': 'Action',
145
+ 'custom.param': 'Parameter',
146
+ 'custom.key': 'Shortcut',
147
+ 'custom.record': 'Press shortcut…',
148
+ 'custom.recordHelp': 'Release to confirm; Esc cancels recording',
149
+ 'custom.save': 'Save',
150
+ 'custom.cancel': 'Cancel',
151
+ 'custom.remove': 'Remove',
152
+ 'custom.needKey': 'Record a shortcut first',
153
+ 'custom.keyIssue': 'This combination is unusable (reserved or unsupported)',
154
+ 'custom.modifierRequired': 'Include Command, Ctrl, or Alt in the combination',
155
+ 'custom.conflict': 'This combination is already used by another command',
156
+ 'preset.readOnly': 'Read Only',
157
+ 'preset.workspaceWrite': 'Workspace Write',
158
+ 'preset.full': 'Full access',
159
+ 'action.openModelPalette': 'Open the model palette',
160
+ 'action.openSessionPalette': 'Open the session palette',
161
+ 'action.jumpCompleted': 'Find next answered session',
162
+ 'action.focusComposer': 'Focus the chat input',
163
+ 'action.togglePermission': 'Toggle permissions',
164
+ 'action.setPermission': 'Set permissions to a preset',
165
+ 'action.nextSession': 'Next session',
166
+ 'action.prevSession': 'Previous session',
167
+ 'action.toggleSidebar': 'Show / hide the sidebar',
168
+ 'action.showConversation': 'Return to the conversation view',
169
+ 'action.showDeck': 'Open the Shortcut Extensions panel',
170
+ 'action.newSession': 'Start a new session',
171
+ 'palette.title': 'Switch model',
172
+ 'palette.placeholder': 'Type to search models (pinned models come first)',
173
+ 'palette.loading': 'Loading model catalog…',
174
+ 'palette.loadError': 'Failed to load the model catalog',
175
+ 'palette.empty': 'No models available',
176
+ 'palette.noMatch': 'No matching model',
177
+ 'palette.noSession': 'No open session; open or create one first',
178
+ 'palette.switching': 'Switching…',
179
+ 'palette.selectFailed': 'Switch failed',
180
+ 'palette.pin': 'Pin',
181
+ 'palette.providerFailures': 'Some model providers are currently unavailable',
182
+ 'palette.hint.nav': 'navigate',
183
+ 'palette.hint.confirm': 'switch',
184
+ 'palette.hint.close': 'close',
185
+ 'palette.hint.pin': 'pin/unpin',
186
+ 'palette.hint.tag': 'click a tag to filter',
187
+ 'tag.reasoning': 'Reasoning',
188
+ 'shortcut.modelPalette': 'Open the model quick switcher',
189
+ 'shortcut.permToggle': 'Toggle permissions',
190
+ 'shortcut.sessionPalette': 'Open the session list',
191
+ 'shortcut.nextSession': 'Next session',
192
+ 'shortcut.prevSession': 'Previous session',
193
+ 'shortcut.focusComposer': 'Focus the chat input',
194
+ 'shortcut.jumpCompleted': 'Find next answered session',
195
+ 'shortcut.unbound': 'Unbound',
196
+ 'session.none': 'No open session',
197
+ 'session.noneOther': 'No other session to switch to',
198
+ 'session.noneCompleted': 'No session has just answered',
199
+ 'session.paletteTitle': 'Switch session',
200
+ 'session.placeholder': 'Search sessions (including archived)',
201
+ 'session.badgeArchived': 'Archived',
202
+ 'session.newSession': 'New session',
203
+ 'session.empty': 'No matching session',
204
+ 'session.noneYet': 'No sessions yet',
205
+ 'session.busy': 'Working…',
206
+ 'session.restoreFailed': 'Restore failed',
207
+ 'session.openFailed': 'Open failed',
208
+ 'session.hint.nav': 'navigate',
209
+ 'session.hint.open': 'open (restores archived)',
210
+ 'session.hint.close': 'close',
211
+ 'session.previewTitle': 'My prompts',
212
+ 'session.previewEmpty': 'No prompts recorded yet',
213
+ 'session.previewUnavailable': 'This deployment exposes no session outline; preview unavailable',
214
+ 'session.previewLoading': 'Loading preview…',
215
+ 'perm.noSession': 'Open a session to switch permissions',
216
+ 'perm.unavailable': 'This deployment offers no permission presets',
217
+ 'perm.section': 'Permission cycle',
218
+ 'perm.current': 'Current',
219
+ 'perm.cycleHint': 'Click presets to include or exclude them (at least two); the toggle key cycles in this order.',
220
+ 'perm.needTwo': 'Keep at least two permission presets',
221
+ 'perm.autoExcluded': 'Auto review is a session-only temporary preset and never joins the cycle.',
222
+ 'perm.catalogLoading': 'Loading permission catalog…',
223
+ 'time.now': 'now',
224
+ 'time.minutes': '{n}m ago',
225
+ 'time.hours': '{n}h ago',
226
+ 'time.days': '{n}d ago'
227
+ };
228
+
229
+ /* ------------------------------------------------------------------ */
230
+ /* Command table: labels, implementation terms, default bindings */
231
+ /* ------------------------------------------------------------------ */
232
+
233
+ /* ------------------------------------------------------------------ */
234
+ /* Action catalog (safe, verified operations) and command table */
235
+ /* ------------------------------------------------------------------ */
236
+
237
+ const PRESETS = [
238
+ { value: 'read-only', labelKey: 'preset.readOnly' },
239
+ { value: 'workspace-write', labelKey: 'preset.workspaceWrite' },
240
+ { value: 'danger-full-access', labelKey: 'preset.full' }
241
+ ];
242
+
243
+ const PRESET_LABEL_KEY = {
244
+ 'read-only': 'preset.readOnly',
245
+ 'workspace-write': 'preset.workspaceWrite',
246
+ 'danger-full-access': 'preset.full'
247
+ };
248
+
249
+ function cycleTarget(ctx, direction, t) {
250
+ let sessionsSnap = EMPTY_SESSIONS_SNAP;
251
+ let workspacesSnap = EMPTY_WORKSPACES_SNAP;
252
+ try { sessionsSnap = ctx.sessions.list.getSnapshot(); } catch { /* keep empty */ }
253
+ try { workspacesSnap = ctx.workspaces?.list?.getSnapshot?.() ?? EMPTY_WORKSPACES_SNAP; } catch { /* keep empty */ }
254
+ const currentId = mainSessionId(ctx);
255
+ const order = visibleSessionOrder(workspacesSnap, sessionsSnap, currentId);
256
+ if (order.length === 0) return { blocked: t('session.none') };
257
+ if (order.length === 1 && order[0] === currentId) return { blocked: t('session.noneOther') };
258
+ const index = currentId === undefined ? -1 : order.indexOf(currentId);
259
+ const target = index === -1
260
+ ? (direction > 0 ? order[0] : order[order.length - 1])
261
+ : order[(index + direction + order.length) % order.length];
262
+ return { target };
263
+ }
264
+
265
+ function runPermissionToggle(d) {
266
+ const live = liveSession(d.ctx, mainSessionId(d.ctx));
267
+ if (!live) return d.hint(d.t('perm.noSession'));
268
+ let current;
269
+ try { current = live.projections?.faceOf?.('permissions')?.getSnapshot()?.currentValue; }
270
+ catch { current = undefined; }
271
+ if (current === undefined) return d.hint(d.t('perm.unavailable'));
272
+ const cycle = d.permCycle.getSnapshot();
273
+ if (cycle.length < 2) return d.hint(d.t('perm.needTwo'));
274
+ const target = cycle[(cycle.indexOf(current) + 1) % cycle.length];
275
+ switchPermission(d.ctx, target).catch((error) => {
276
+ d.hint(`${d.t('perm.failed')}: ${error?.message ?? error}`);
277
+ });
278
+ return undefined;
279
+ }
280
+
281
+ const ACTIONS = [
282
+ {
283
+ id: 'open-model-palette',
284
+ labelKey: 'action.openModelPalette',
285
+ impl: 'palette.open(model)',
286
+ run: (d) => { d.paletteOpen.set(true); }
287
+ },
288
+ {
289
+ id: 'open-session-palette',
290
+ labelKey: 'action.openSessionPalette',
291
+ impl: 'palette.open(session)',
292
+ run: (d) => { d.sessionPaletteOpen.set(true); }
293
+ },
294
+ {
295
+ id: 'jump-completed',
296
+ labelKey: 'action.jumpCompleted',
297
+ impl: 'sessions.list: updatedAt > viewedAt',
298
+ run: (d) => {
299
+ if (d.jumpTracker.jump() === 'none') d.hint(d.t('session.noneCompleted'));
300
+ }
301
+ },
302
+ {
303
+ id: 'focus-composer',
304
+ labelKey: 'action.focusComposer',
305
+ impl: 'SessionInput.focus()',
306
+ run: (d) => {
307
+ const id = mainSessionId(d.ctx);
308
+ if (id === undefined) return d.hint(d.t('session.none'));
309
+ focusComposer(d.ctx, id);
310
+ return undefined;
311
+ }
312
+ },
313
+ {
314
+ id: 'toggle-permission',
315
+ labelKey: 'action.togglePermission',
316
+ impl: 'cycle \u2192 live.command("/permission <next>")',
317
+ run: runPermissionToggle
318
+ },
319
+ {
320
+ id: 'set-permission',
321
+ labelKey: 'action.setPermission',
322
+ impl: 'live.command("/permission <preset>")',
323
+ param: 'preset',
324
+ run: (d, param) => {
325
+ switchPermission(d.ctx, param).catch((error) => {
326
+ const reason = error?.message === 'NO_SESSION' ? d.t('perm.noSession')
327
+ : error?.message === 'NO_COMMAND' ? d.t('perm.unavailable')
328
+ : `${d.t('perm.failed')}: ${error?.message ?? error}`;
329
+ d.hint(reason);
330
+ });
331
+ }
332
+ },
333
+ {
334
+ id: 'next-session',
335
+ labelKey: 'action.nextSession',
336
+ impl: 'uiWorkspace.openSession()',
337
+ run: (d) => {
338
+ const result = cycleTarget(d.ctx, 1, d.t);
339
+ if (result.blocked) return d.hint(result.blocked);
340
+ try { openSessionId(d.ctx, result.target); } catch (error) { console.error('[shortcut-ext] cycle failed', error); }
341
+ return undefined;
342
+ }
343
+ },
344
+ {
345
+ id: 'prev-session',
346
+ labelKey: 'action.prevSession',
347
+ impl: 'uiWorkspace.openSession()',
348
+ run: (d) => {
349
+ const result = cycleTarget(d.ctx, -1, d.t);
350
+ if (result.blocked) return d.hint(result.blocked);
351
+ try { openSessionId(d.ctx, result.target); } catch (error) { console.error('[shortcut-ext] cycle failed', error); }
352
+ return undefined;
353
+ }
354
+ },
355
+ {
356
+ id: 'toggle-sidebar',
357
+ labelKey: 'action.toggleSidebar',
358
+ impl: 'layout.toggleSidebar()',
359
+ run: (d) => { try { d.ctx.layout.toggleSidebar(); } catch (error) { console.error('[shortcut-ext] sidebar toggle failed', error); } }
360
+ },
361
+ {
362
+ id: 'show-conversation',
363
+ labelKey: 'action.showConversation',
364
+ impl: 'layout.selectPanel(null)',
365
+ run: (d) => { try { d.ctx.layout.selectPanel(null); } catch (error) { console.error('[shortcut-ext] selectPanel failed', error); } }
366
+ },
367
+ {
368
+ id: 'show-deck',
369
+ labelKey: 'action.showDeck',
370
+ impl: "layout.selectPanel('shortcut-ext')",
371
+ run: (d) => { try { d.ctx.layout.selectPanel(PANEL_ID); } catch (error) { console.error('[shortcut-ext] selectPanel failed', error); } }
372
+ },
373
+ {
374
+ id: 'new-session',
375
+ labelKey: 'action.newSession',
376
+ impl: 'uiWorkspace.startSession()',
377
+ run: (d) => { try { d.ctx.uiWorkspace.startSession(); } catch (error) { console.error('[shortcut-ext] startSession failed', error); } }
378
+ }
379
+ ];
380
+
381
+ const ACTION_BY_ID = new Map(ACTIONS.map((action) => [action.id, action]));
382
+
383
+ /** Built-in commands: fixed ids (rebindable in Settings) over the action catalog. */
384
+ const COMMANDS = [
385
+ {
386
+ id: 'shortcut-ext.model-palette',
387
+ action: 'open-model-palette',
388
+ attempts: [
389
+ {
390
+ 'desktop:macos': { code: 'KeyM', modifiers: ['primary', 'shift'] },
391
+ 'desktop:windows': { code: 'KeyM', modifiers: ['primary', 'shift'] },
392
+ 'desktop:linux': { code: 'KeyM', modifiers: ['primary', 'shift'] },
393
+ 'web:macos': { code: 'KeyM', modifiers: ['primary', 'shift'] },
394
+ 'web:windows': { code: 'KeyM', modifiers: ['primary', 'shift'] }
395
+ },
396
+ {
397
+ 'desktop:macos': { code: 'KeyM', modifiers: ['primary', 'alt'] },
398
+ 'desktop:windows': { code: 'KeyM', modifiers: ['primary', 'alt'] },
399
+ 'desktop:linux': { code: 'KeyM', modifiers: ['primary', 'alt'] },
400
+ 'web:macos': { code: 'KeyM', modifiers: ['primary', 'alt'] },
401
+ 'web:windows': { code: 'KeyM', modifiers: ['primary', 'alt'] }
402
+ },
403
+ {}
404
+ ]
405
+ },
406
+ {
407
+ id: 'shortcut-ext.session-palette',
408
+ action: 'open-session-palette',
409
+ attempts: [
410
+ {
411
+ 'desktop:macos': { code: 'KeyS', modifiers: ['primary', 'shift'] },
412
+ 'desktop:windows': { code: 'KeyS', modifiers: ['primary', 'shift'] },
413
+ 'desktop:linux': { code: 'KeyS', modifiers: ['primary', 'shift'] },
414
+ 'web:macos': { code: 'KeyS', modifiers: ['primary', 'shift'] },
415
+ 'web:windows': { code: 'KeyS', modifiers: ['primary', 'shift'] }
416
+ },
417
+ {
418
+ 'desktop:macos': { code: 'KeyS', modifiers: ['primary', 'shift'] },
419
+ 'desktop:windows': { code: 'KeyS', modifiers: ['primary', 'shift'] },
420
+ 'desktop:linux': { code: 'KeyS', modifiers: ['primary', 'shift'] }
421
+ },
422
+ {}
423
+ ]
424
+ },
425
+ {
426
+ id: 'shortcut-ext.session-next',
427
+ action: 'next-session',
428
+ attempts: [
429
+ {
430
+ 'desktop:macos': { code: 'Tab', modifiers: ['control'] },
431
+ 'desktop:windows': { code: 'Tab', modifiers: ['control'] }
432
+ },
433
+ {}
434
+ ]
435
+ },
436
+ {
437
+ id: 'shortcut-ext.session-prev',
438
+ action: 'prev-session',
439
+ attempts: [
440
+ {
441
+ 'desktop:macos': { code: 'Tab', modifiers: ['control', 'shift'] },
442
+ 'desktop:windows': { code: 'Tab', modifiers: ['control', 'shift'] }
443
+ },
444
+ {}
445
+ ]
446
+ },
447
+ {
448
+ id: 'shortcut-ext.jump-completed',
449
+ action: 'jump-completed',
450
+ attempts: [
451
+ {
452
+ 'desktop:macos': { code: 'KeyG', modifiers: ['primary'] },
453
+ 'desktop:windows': { code: 'KeyG', modifiers: ['primary'] },
454
+ 'desktop:linux': { code: 'KeyG', modifiers: ['primary'] },
455
+ 'web:macos': { code: 'KeyG', modifiers: ['primary', 'shift'] },
456
+ 'web:windows': { code: 'KeyG', modifiers: ['primary', 'shift'] }
457
+ },
458
+ {
459
+ 'desktop:macos': { code: 'KeyG', modifiers: ['primary'] },
460
+ 'desktop:windows': { code: 'KeyG', modifiers: ['primary'] },
461
+ 'desktop:linux': { code: 'KeyG', modifiers: ['primary'] }
462
+ },
463
+ {}
464
+ ]
465
+ },
466
+ {
467
+ id: 'shortcut-ext.focus-composer',
468
+ action: 'focus-composer',
469
+ attempts: [
470
+ {
471
+ 'desktop:macos': { code: 'KeyI', modifiers: ['primary'] },
472
+ 'desktop:windows': { code: 'KeyI', modifiers: ['primary'] },
473
+ 'desktop:linux': { code: 'KeyI', modifiers: ['primary'] },
474
+ 'web:macos': { code: 'KeyI', modifiers: ['primary', 'alt'] },
475
+ 'web:windows': { code: 'KeyI', modifiers: ['primary', 'alt'] }
476
+ },
477
+ {}
478
+ ]
479
+ },
480
+ {
481
+ id: 'shortcut-ext.permission-toggle',
482
+ action: 'toggle-permission',
483
+ attempts: [
484
+ {
485
+ 'desktop:macos': { code: 'KeyP', modifiers: ['primary', 'shift'] },
486
+ 'desktop:windows': { code: 'KeyP', modifiers: ['primary', 'shift'] },
487
+ 'desktop:linux': { code: 'KeyP', modifiers: ['primary', 'shift'] },
488
+ 'web:macos': { code: 'KeyP', modifiers: ['primary', 'shift'] },
489
+ 'web:windows': { code: 'KeyP', modifiers: ['primary', 'shift'] }
490
+ },
491
+ {
492
+ 'desktop:macos': { code: 'KeyP', modifiers: ['primary', 'alt'] },
493
+ 'desktop:windows': { code: 'KeyP', modifiers: ['primary', 'alt'] },
494
+ 'desktop:linux': { code: 'KeyP', modifiers: ['primary', 'alt'] },
495
+ 'web:macos': { code: 'KeyP', modifiers: ['primary', 'alt'] },
496
+ 'web:windows': { code: 'KeyP', modifiers: ['primary', 'alt'] }
497
+ },
498
+ {}
499
+ ]
500
+ }
501
+ ];
502
+
503
+ const COMMAND_IDS = COMMANDS.map((command) => command.id);
504
+
505
+ /* ------------------------------------------------------------------ */
506
+ /* Styles (theme tokens only; injected once with effect cleanup) */
507
+ /* ------------------------------------------------------------------ */
508
+
509
+ const CSS = [
510
+ /* Palettes: same material as the host's native popup (menu surface, centered) */
511
+ '.dqs-backdrop{position:fixed;inset:0;z-index:1200;display:flex;align-items:center;justify-content:center;',
512
+ 'background:rgba(0,0,0,.32);}',
513
+ '.dqs-palette{width:min(720px,calc(100vw - 48px));max-height:min(70vh,660px);display:flex;flex-direction:column;',
514
+ 'overflow:hidden;background:var(--dsw-specific-menu,var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-layer-1)));',
515
+ 'backdrop-filter:var(--dsw-menu-backdrop-filter,blur(20px));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(20px));',
516
+ 'border:.5px solid var(--dsw-alias-border-l1);border-radius:var(--dsw-radius-lg,14px);',
517
+ 'box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgba(0,0,0,.28));color:var(--dsw-alias-label-primary);}',
518
+ '.dqs-paletteHead{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 18px 10px;}',
519
+ '.dqs-paletteTitle{font-size:15px;font-weight:600;}',
520
+ '.dqs-closeBtn{border:none;background:none;cursor:pointer;color:var(--dsw-alias-label-secondary);',
521
+ 'font-size:13px;line-height:1;padding:5px;border-radius:6px;display:flex;}',
522
+ '.dqs-closeBtn:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2));',
523
+ 'color:var(--dsw-alias-label-primary);}',
524
+ '.dqs-input{width:100%;box-sizing:border-box;height:40px;padding:0 12px 0 34px;',
525
+ 'border:none;border-radius:10px;background:var(--dsw-alias-bg-layer-2);',
526
+ 'color:var(--dsw-alias-label-primary);font-size:13px;outline:none;}',
527
+ '.dqs-input::placeholder{color:var(--dsw-alias-label-secondary);opacity:.8;}',
528
+ '.dqs-input:focus{box-shadow:0 0 0 2px var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary));}',
529
+ '.dqs-searchWrap{position:relative;margin:0 14px 10px;}',
530
+ '.dqs-searchIcon{position:absolute;left:11px;top:50%;transform:translateY(-50%);',
531
+ 'color:var(--dsw-alias-label-secondary);display:flex;pointer-events:none;}',
532
+ '.dqs-list{height:340px;overflow-y:auto;padding:2px 8px 8px;}',
533
+ '.dqs-columns{display:flex;height:340px;min-height:0;}',
534
+ '.dqs-columns .dqs-list{flex:1 1 auto;height:100%;min-width:0;}',
535
+ '.dqs-paletteWide{width:min(880px,calc(100vw - 48px));}',
536
+ '.dqs-row{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:8px;cursor:pointer;font-size:14px;}',
537
+ '.dqs-row[data-active="true"]{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2));}',
538
+ '.dqs-rowName{flex:0 1 auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
539
+ '.dqs-rowProvider{flex:1 1 auto;min-width:0;color:var(--dsw-alias-label-secondary);font-size:12px;',
540
+ 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
541
+ '.dqs-rowBusy{flex:none;font-size:12px;color:var(--dsw-alias-label-secondary);}',
542
+ '.dqs-rowEnd{margin-left:auto;flex:none;display:flex;align-items:center;gap:6px;}',
543
+ '.dqs-tag{flex:none;font-size:10.5px;line-height:16px;padding:0 6px;border-radius:4px;',
544
+ 'border:.5px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));',
545
+ 'color:var(--dsw-alias-label-secondary);background:transparent;white-space:nowrap;',
546
+ 'overflow:hidden;text-overflow:ellipsis;}',
547
+ 'button.dqs-tag{cursor:pointer;}',
548
+ 'button.dqs-tag:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2));',
549
+ 'color:var(--dsw-alias-label-primary);}',
550
+ '.dqs-tagProvider{max-width:130px;min-width:64px;text-align:center;',
551
+ 'color:var(--dsw-alias-label-secondary);',
552
+ 'border:.5px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));background:transparent;}',
553
+ '.dqs-tagProvider{color:hsl(var(--dqs-hue,210) 70% 50%);',
554
+ 'border-color:color-mix(in srgb, currentColor 45%, transparent);',
555
+ 'background:color-mix(in srgb, currentColor 12%, transparent);}',
556
+ '.dqs-tagReasoning{color:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);}',
557
+ '.dqs-check{flex:none;color:var(--dsw-alias-brand-primary);display:flex;}',
558
+ '.dqs-pinBtn{flex:none;border:none;background:none;cursor:pointer;padding:2px;border-radius:4px;display:flex;',
559
+ 'color:var(--dsw-alias-label-secondary);}',
560
+ '.dqs-pinBtn:hover{color:var(--dsw-alias-brand-primary);}',
561
+ '.dqs-pinBtn[data-pinned="true"]{color:var(--dsw-alias-brand-primary);}',
562
+ '.dqs-status{padding:10px 16px;font-size:12px;color:var(--dsw-alias-label-secondary);}',
563
+ '.dqs-statusError{color:var(--dsw-alias-state-error-primary);}',
564
+ '.dqs-footer{padding:8px 16px;font-size:11px;color:var(--dsw-alias-label-secondary);',
565
+ 'border-top:.5px solid var(--dsw-alias-border-l1);display:flex;gap:14px;flex-wrap:wrap;}',
566
+ '.dqs-kbd{border:none;background:var(--dsw-alias-bg-layer-2);border-radius:4px;',
567
+ 'padding:1px 5px;font-size:10px;margin-right:4px;color:var(--dsw-alias-label-secondary);}',
568
+ '.dqs-rowDot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--dsw-alias-state-idle-primary);opacity:.45;}',
569
+ '.dqs-rowDot[data-running="true"]{background:var(--dsw-alias-state-success-primary);opacity:1;}',
570
+ '.dqs-rowPin{flex:none;color:var(--dsw-alias-brand-primary);display:flex;}',
571
+ '.dqs-badge{flex:none;font-size:11px;color:var(--dsw-alias-state-warn-primary);border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:0 8px;line-height:16px;}',
572
+ /* Panel (mirrors the host Plugins page: centered 960px column) */
573
+ '.dqs-page{box-sizing:border-box;height:100%;color:var(--dsw-alias-label-primary);display:flex;',
574
+ 'flex-direction:column;align-items:center;gap:26px;padding:0 clamp(24px,4vw,48px) 48px;overflow:auto;}',
575
+ '.dqs-page>*{width:100%;max-width:960px;}',
576
+ '.dqs-pageHead{padding-top:36px;margin-bottom:-6px;}',
577
+ '.dqs-pageTitle{font-size:26px;font-weight:600;margin:0 0 6px;}',
578
+ '.dqs-pageSub{font-size:13px;color:var(--dsw-alias-label-secondary);margin:0;}',
579
+ '.dqs-toolbar{display:flex;align-items:center;gap:16px;flex-wrap:wrap;}',
580
+ '.dqs-tabs{display:flex;gap:2px;padding:3px;border-radius:9px;background:var(--dsw-alias-bg-layer-2);}',
581
+ '.dqs-tab{border:none;background:transparent;color:var(--dsw-alias-label-secondary);font-size:13px;',
582
+ 'padding:7px 20px;border-radius:7px;cursor:pointer;}',
583
+ '.dqs-tab:hover{color:var(--dsw-alias-label-primary);}',
584
+ '.dqs-tab[aria-selected="true"]{background:var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-layer-1));',
585
+ 'color:var(--dsw-alias-label-primary);font-weight:500;}',
586
+ '.dqs-toolbarRight{margin-left:auto;display:flex;align-items:center;gap:12px;}',
587
+ '.dqs-count{font-size:12px;color:var(--dsw-alias-label-secondary);}',
588
+ '.dqs-linkBtn{border:none;background:none;color:var(--dsw-alias-brand-primary);cursor:pointer;font-size:12px;padding:0;}',
589
+ '.dqs-linkBtn:hover{text-decoration:underline;}',
590
+ '.dqs-search{width:220px;height:32px;padding:0 12px;box-sizing:border-box;border-radius:8px;',
591
+ 'border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2);',
592
+ 'color:var(--dsw-alias-label-primary);font-size:13px;outline:none;}',
593
+ '.dqs-search::placeholder{color:var(--dsw-alias-label-secondary);opacity:.7;}',
594
+ '.dqs-search:focus{border-color:var(--dsw-alias-brand-primary);}',
595
+ '.dqs-rows{display:flex;flex-direction:column;}',
596
+ '.dqs-cmdRow{display:flex;align-items:center;gap:16px;padding:14px 2px;',
597
+ 'border-bottom:.5px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));transition:opacity .15s;}',
598
+ '.dqs-cmdRow[data-enabled="false"]{opacity:.5;}',
599
+ '.dqs-tile{flex:none;width:48px;height:48px;border-radius:12px;display:flex;align-items:center;justify-content:center;',
600
+ 'background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-brand-primary);border:1px solid var(--dsw-alias-border-l1);}',
601
+ '.dqs-cmdInfo{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;gap:3px;}',
602
+ '.dqs-cmdName{font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
603
+ '.dqs-cmdImpl{font-size:11.5px;color:var(--dsw-alias-label-secondary);',
604
+ 'font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
605
+ '.dqs-cmdRight{flex:none;display:flex;align-items:center;gap:14px;}',
606
+ '.dqs-cmdKeys{display:flex;align-items:center;gap:3px;}',
607
+ '.dqs-keycap{min-width:20px;height:20px;padding:0 5px;box-sizing:border-box;display:inline-flex;align-items:center;',
608
+ 'justify-content:center;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));border-radius:5px;',
609
+ 'font-size:11px;line-height:1;color:var(--dsw-alias-label-secondary);',
610
+ 'background:var(--dsw-alias-bg-layer-2);white-space:nowrap;}',
611
+ '.dqs-keycapNone{border:none;background:none;font-size:11px;color:var(--dsw-alias-label-secondary);opacity:.7;}',
612
+ '.dqs-switch{flex:none;position:relative;width:34px;height:20px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));',
613
+ 'background:var(--dsw-alias-bg-layer-2);cursor:pointer;padding:0;transition:background .15s,border-color .15s;}',
614
+ '.dqs-switch[aria-checked="true"]{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);}',
615
+ '.dqs-switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;',
616
+ 'background:var(--dsw-alias-bg-overlay,#fff);box-shadow:0 1px 2px rgba(0,0,0,.3);transition:transform .15s;}',
617
+ '.dqs-switch[aria-checked="true"]::after{transform:translateX(14px);}',
618
+ '.dqs-emptyState{padding:56px 0;text-align:center;color:var(--dsw-alias-label-secondary);font-size:13px;}',
619
+ /* Custom command editor */
620
+ '.dqs-editor{border:1px solid var(--dsw-alias-border-l1);border-radius:12px;',
621
+ 'background:var(--dsw-alias-bg-layer-1);padding:16px 18px;}',
622
+ '.dqs-editorTitle{font-size:13px;font-weight:600;margin-bottom:14px;}',
623
+ '.dqs-editorRow{display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start;}',
624
+ '.dqs-editorLabel{display:flex;flex-direction:column;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);}',
625
+ '.dqs-select{height:32px;border-radius:8px;border:1px solid var(--dsw-alias-border-l1);',
626
+ 'background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:13px;',
627
+ 'padding:0 8px;outline:none;max-width:320px;}',
628
+ '.dqs-recordBtn{height:32px;min-width:150px;padding:0 12px;border-radius:8px;',
629
+ 'border:1px dashed var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));background:transparent;',
630
+ 'color:var(--dsw-alias-label-primary);font-size:12px;cursor:pointer;display:inline-flex;',
631
+ 'align-items:center;justify-content:center;gap:4px;outline:none;}',
632
+ '.dqs-recordBtn[data-recording="true"]{border-style:solid;border-color:var(--dsw-alias-brand-primary);',
633
+ 'color:var(--dsw-alias-brand-primary);}',
634
+ '.dqs-editorHelp{font-size:11px;color:var(--dsw-alias-label-secondary);}',
635
+ '.dqs-editorError{font-size:12px;color:var(--dsw-alias-state-error-primary);}',
636
+ '.dqs-editorActions{display:flex;gap:8px;justify-content:flex-end;margin-top:16px;}',
637
+ '.dqs-saveBtn{height:30px;padding:0 16px;border-radius:8px;border:1px solid var(--dsw-alias-brand-primary);',
638
+ 'background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-bg-overlay,#fff);font-size:13px;cursor:pointer;}',
639
+ '.dqs-ghostBtn{height:30px;padding:0 16px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));',
640
+ 'background:transparent;color:var(--dsw-alias-label-primary);font-size:13px;cursor:pointer;}',
641
+ '.dqs-ghostBtn:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2));}',
642
+ '.dqs-removeBtn{flex:none;border:none;background:none;cursor:pointer;color:var(--dsw-alias-label-secondary);',
643
+ 'display:flex;padding:4px;border-radius:6px;}',
644
+ '.dqs-removeBtn:hover{color:var(--dsw-alias-state-error-primary);',
645
+ 'background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2));}',
646
+ '.dqs-hintbar{position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:1300;',
647
+ 'background:var(--dsw-specific-menu,var(--dsw-alias-bg-overlay,var(--dsw-alias-bg-layer-1)));',
648
+ 'backdrop-filter:var(--dsw-menu-backdrop-filter,blur(20px));-webkit-backdrop-filter:var(--dsw-menu-backdrop-filter,blur(20px));',
649
+ 'border:.5px solid var(--dsw-alias-border-l1);',
650
+ 'border-radius:999px;padding:8px 18px;font-size:13px;color:var(--dsw-alias-label-primary);',
651
+ 'box-shadow:var(--dsw-elevation-prominent,0 12px 32px rgba(0,0,0,.28));pointer-events:none;}',
652
+ /* Session palette: right-hand preview pane (always visible) */
653
+ '.dqs-previewPane{flex:0 0 42%;border-left:.5px solid var(--dsw-alias-border-l1);',
654
+ 'display:flex;flex-direction:column;min-width:0;height:100%;}',
655
+ '.dqs-previewHead{display:flex;gap:10px;align-items:baseline;padding:10px 16px 4px;font-size:12px;font-weight:600;}',
656
+ '.dqs-previewSession{flex:1 1 auto;min-width:0;color:var(--dsw-alias-label-secondary);font-weight:400;',
657
+ 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
658
+ '.dqs-previewMeta{padding:0 16px 6px;font-size:11px;color:var(--dsw-alias-label-secondary);',
659
+ 'display:flex;gap:8px;align-items:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
660
+ '.dqs-previewScroll{flex:1 1 auto;overflow-y:auto;min-height:0;padding:2px 0 8px;}',
661
+ '.dqs-previewLine{padding:2px 16px;font-size:12px;color:var(--dsw-alias-label-secondary);',
662
+ 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
663
+ /* Permission cycle card */
664
+ '.dqs-permCard{border:1px solid var(--dsw-alias-border-l1);border-radius:12px;',
665
+ 'background:var(--dsw-alias-bg-layer-1);padding:16px 18px;}',
666
+ '.dqs-sectionHead{display:flex;align-items:baseline;justify-content:space-between;gap:12px;margin-bottom:10px;}',
667
+ '.dqs-sectionTitle{font-size:13px;font-weight:600;}',
668
+ '.dqs-chips{display:flex;gap:8px;flex-wrap:wrap;}',
669
+ '.dqs-permChip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));',
670
+ 'border-radius:999px;padding:5px 14px;font-size:12px;cursor:pointer;background:transparent;color:var(--dsw-alias-label-secondary);}',
671
+ '.dqs-permChip:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2));}',
672
+ '.dqs-permChip[data-included="true"]{border-color:var(--dsw-alias-brand-primary);color:var(--dsw-alias-brand-primary);font-weight:500;}',
673
+ '.dqs-hint{font-size:12px;color:var(--dsw-alias-label-secondary);margin-top:8px;line-height:1.6;}',
674
+ /* Expandable permission config + system shortcut rows */
675
+ '.dqs-expandBtn{flex:none;border:none;background:none;cursor:pointer;color:var(--dsw-alias-label-secondary);',
676
+ 'display:flex;padding:4px;border-radius:6px;}',
677
+ '.dqs-expandBtn:hover{background:var(--dsw-alias-interactive-bg-hover,var(--dsw-alias-bg-layer-2));',
678
+ 'color:var(--dsw-alias-label-primary);}',
679
+ '.dqs-expand{padding:2px 2px 18px 64px;}',
680
+ '.dqs-sysRow{display:flex;align-items:center;gap:12px;padding:12px 2px;',
681
+ 'border-bottom:.5px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-l1));}',
682
+ '.dqs-sysLabel{flex:1 1 auto;min-width:0;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
683
+ '.dqs-sysFixed{flex:none;font-size:11px;color:var(--dsw-alias-label-secondary);',
684
+ 'border:1px solid var(--dsw-alias-border-l1);border-radius:999px;padding:1px 8px;}'
685
+ ].join('');
686
+
687
+ /* ------------------------------------------------------------------ */
688
+ /* Small stores */
689
+ /* ------------------------------------------------------------------ */
690
+
691
+ function createStore(initial) {
692
+ let value = initial;
693
+ const listeners = new Set();
694
+ const emit = () => { for (const fn of [...listeners]) fn(); };
695
+ return {
696
+ getSnapshot: () => value,
697
+ subscribe(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; },
698
+ set(next) { if (Object.is(next, value)) return; value = next; emit(); },
699
+ update(fn) { this.set(fn(value)); }
700
+ };
701
+ }
702
+
703
+ function readJsonArray(key) {
704
+ try {
705
+ const parsed = JSON.parse(localStorage.getItem(key));
706
+ return Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : [];
707
+ } catch { return []; }
708
+ }
709
+
710
+ function createPinStore() {
711
+ const base = createStore(readJsonArray(PIN_KEY));
712
+ const persist = () => {
713
+ try { localStorage.setItem(PIN_KEY, JSON.stringify(base.getSnapshot())); } catch { /* private mode */ }
714
+ };
715
+ return {
716
+ getSnapshot: base.getSnapshot,
717
+ subscribe: base.subscribe,
718
+ toggle(key) {
719
+ const value = base.getSnapshot();
720
+ base.set(value.includes(key) ? value.filter((k) => k !== key) : [...value, key]);
721
+ persist();
722
+ }
723
+ };
724
+ }
725
+
726
+ function readDisabled() {
727
+ try {
728
+ const parsed = JSON.parse(localStorage.getItem(TOGGLE_KEY));
729
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : [];
730
+ } catch { return []; }
731
+ }
732
+
733
+ function createEnabledStore() {
734
+ const base = createStore(readDisabled());
735
+ const persist = () => {
736
+ try { localStorage.setItem(TOGGLE_KEY, JSON.stringify(base.getSnapshot())); } catch { /* private mode */ }
737
+ };
738
+ return {
739
+ getSnapshot: base.getSnapshot,
740
+ isEnabled: (id) => !base.getSnapshot().includes(id),
741
+ subscribe: base.subscribe,
742
+ toggle(id) {
743
+ const disabled = base.getSnapshot();
744
+ base.set(disabled.includes(id) ? disabled.filter((x) => x !== id) : [...disabled, id]);
745
+ persist();
746
+ },
747
+ setAll(enabled, ids) {
748
+ base.set(enabled ? [] : [...(ids ?? COMMAND_IDS)]);
749
+ persist();
750
+ }
751
+ };
752
+ }
753
+
754
+ function useStore(store) {
755
+ return useSyncExternalStore(store.subscribe, store.getSnapshot);
756
+ }
757
+
758
+ /* ------------------------------------------------------------------ */
759
+ /* Session / permission / model data access */
760
+ /* ------------------------------------------------------------------ */
761
+
762
+ function mainSessionId(ctx) {
763
+ try {
764
+ const snap = ctx.sessions.list.getSnapshot();
765
+ const row = Object.values(snap?.byId ?? {}).find((r) => (r?.retainedBy?.mainView ?? 0) > 0);
766
+ return row?.id;
767
+ } catch { return undefined; }
768
+ }
769
+
770
+ function useSessionId(ctx) {
771
+ const list = ctx.sessions?.list;
772
+ return useSyncExternalStore(
773
+ (cb) => (list && typeof list.subscribe === 'function') ? list.subscribe(cb) : () => {},
774
+ () => {
775
+ try {
776
+ const snap = list.getSnapshot();
777
+ return Object.values(snap?.byId ?? {}).find((r) => (r?.retainedBy?.mainView ?? 0) > 0)?.id;
778
+ } catch { return undefined; }
779
+ }
780
+ );
781
+ }
782
+
783
+ function liveSession(ctx, sessionId) {
784
+ if (sessionId === undefined) return undefined;
785
+ try { return ctx.sessions.binding(sessionId)?.session; } catch { return undefined; }
786
+ }
787
+
788
+ const EMPTY_SESSIONS_SNAP = Object.freeze({ byId: {}, ids: [] });
789
+ const EMPTY_WORKSPACES_SNAP = Object.freeze({ items: [], archivedSessionIds: [], pinnedSessionIds: [] });
790
+ const EMPTY_SHORTCUT_ROWS = Object.freeze([]);
791
+
792
+ function useSessionsList(ctx) {
793
+ const list = ctx.sessions?.list;
794
+ return useSyncExternalStore(
795
+ (cb) => (list?.subscribe ? list.subscribe(cb) : () => {}),
796
+ () => { try { return list ? list.getSnapshot() : EMPTY_SESSIONS_SNAP; } catch { return EMPTY_SESSIONS_SNAP; } }
797
+ );
798
+ }
799
+
800
+ function useWorkspacesList(ctx) {
801
+ const list = ctx.workspaces?.list;
802
+ return useSyncExternalStore(
803
+ (cb) => (list?.subscribe ? list.subscribe(cb) : () => {}),
804
+ () => { try { return list ? list.getSnapshot() : EMPTY_WORKSPACES_SNAP; } catch { return EMPTY_WORKSPACES_SNAP; } }
805
+ );
806
+ }
807
+
808
+ function useShortcutRows(ctx) {
809
+ const catalog = ctx.shortcuts?.catalog;
810
+ return useSyncExternalStore(
811
+ (cb) => (catalog?.subscribe ? catalog.subscribe(cb) : () => {}),
812
+ () => { try { return catalog?.getSnapshot ? catalog.getSnapshot() : EMPTY_SHORTCUT_ROWS; } catch { return EMPTY_SHORTCUT_ROWS; } }
813
+ );
814
+ }
815
+
816
+ /** Mirrors ui-workspace visibility: no subagent children; blank only when current. */
817
+ function isListableSession(summary, id, currentId) {
818
+ if (!summary) return false;
819
+ if (summary.origin === 'subagent') return false;
820
+ if (summary.blank && id !== currentId) return false;
821
+ return true;
822
+ }
823
+
824
+ /**
825
+ * Cycle order mirroring the sidebar's default recency view (newest first):
826
+ * "next" walks down the visible list (older), "previous" walks up (newer).
827
+ */
828
+ function visibleSessionOrder(workspacesSnap, sessionsSnap, currentId) {
829
+ const archived = new Set(workspacesSnap?.archivedSessionIds ?? []);
830
+ const byId = sessionsSnap?.byId ?? {};
831
+ const rows = [];
832
+ for (const [id, summary] of Object.entries(byId)) {
833
+ if (archived.has(id) || !isListableSession(summary, id, currentId)) continue;
834
+ rows.push({ id, at: summary?.updatedAt ?? 0 });
835
+ }
836
+ rows.sort((a, b) => (b.at - a.at) || (a.id < b.id ? -1 : 1));
837
+ return rows.map((row) => row.id);
838
+ }
839
+
840
+ function openSessionId(ctx, sessionId) {
841
+ ctx.uiWorkspace.openSession(sessionId);
842
+ try {
843
+ if (ctx.layout?.panelInfo?.getSnapshot?.().activePanelId != null) ctx.layout.selectPanel(null);
844
+ } catch { /* navigation already restored the conversation */ }
845
+ }
846
+
847
+ function focusComposer(ctx, sessionId) {
848
+ try {
849
+ if (ctx.layout?.panelInfo?.getSnapshot?.().activePanelId != null) ctx.layout.selectPanel(null);
850
+ } catch { /* noop */ }
851
+ const attempt = () => {
852
+ try {
853
+ const conversation = ctx.conversation ?? ctx.get?.('conversation');
854
+ const scope = ctx.sessions.scope(sessionId);
855
+ const input = scope && conversation?.input?.for ? conversation.input.for(scope) : undefined;
856
+ input?.focus?.();
857
+ } catch (error) {
858
+ console.warn('[shortcut-ext] focus composer failed', error);
859
+ }
860
+ };
861
+ setTimeout(attempt, 0);
862
+ }
863
+
864
+ /**
865
+ * Jump target selection with the host's own "answered while away" semantics:
866
+ * a session counts as completed-unvisited when its activity time is newer
867
+ * than the last moment the user viewed it. Tiers: completed first, then
868
+ * still-answering sessions, then nothing.
869
+ */
870
+ function createJumpTracker(ctx) {
871
+ const visited = new Map();
872
+ let seeded = false;
873
+ let lastMain = mainSessionId(ctx);
874
+ const readSessions = () => {
875
+ try { return ctx.sessions.list.getSnapshot(); } catch { return EMPTY_SESSIONS_SNAP; }
876
+ };
877
+ const readArchived = () => {
878
+ try { return new Set(ctx.workspaces?.list?.getSnapshot?.().archivedSessionIds ?? []); }
879
+ catch { return new Set(); }
880
+ };
881
+ const seed = (byId) => {
882
+ for (const [id, summary] of Object.entries(byId)) visited.set(id, summary?.updatedAt ?? 0);
883
+ seeded = true;
884
+ };
885
+ const syncView = () => {
886
+ const byId = readSessions()?.byId ?? {};
887
+ if (!seeded && Object.keys(byId).length > 0) seed(byId);
888
+ const current = mainSessionId(ctx);
889
+ if (current !== lastMain) {
890
+ lastMain = current;
891
+ if (current) visited.set(current, Date.now());
892
+ }
893
+ };
894
+ syncView();
895
+ const off = (() => {
896
+ try {
897
+ const list = ctx.sessions?.list;
898
+ return list?.subscribe ? list.subscribe(syncView) : undefined;
899
+ } catch { return undefined; }
900
+ })();
901
+ return {
902
+ /** @returns 'done' | 'running' | 'none' */
903
+ jump() {
904
+ syncView();
905
+ const byId = readSessions()?.byId ?? {};
906
+ const archived = readArchived();
907
+ const done = [];
908
+ const running = [];
909
+ for (const [id, summary] of Object.entries(byId)) {
910
+ if (archived.has(id) || !isListableSession(summary, id, lastMain)) continue;
911
+ const at = summary?.updatedAt ?? 0;
912
+ if (summary?.running === true) running.push({ id, at });
913
+ else if (at > (visited.get(id) ?? 0)) done.push({ id, at });
914
+ }
915
+ done.sort((a, b) => b.at - a.at);
916
+ running.sort((a, b) => a.at - b.at);
917
+ const target = done[0] ?? running[0];
918
+ if (!target) return 'none';
919
+ try {
920
+ openSessionId(ctx, target.id);
921
+ visited.set(target.id, Date.now());
922
+ lastMain = target.id;
923
+ } catch (error) {
924
+ console.error('[shortcut-ext] jump failed', error);
925
+ }
926
+ return done[0] ? 'done' : 'running';
927
+ },
928
+ dispose() { if (typeof off === 'function') off(); }
929
+ };
930
+ }
931
+
932
+ function buildSessionRows(sessionsSnap, workspacesSnap, query, currentId, t) {
933
+ const archived = new Set(workspacesSnap?.archivedSessionIds ?? []);
934
+ const pinned = new Set(workspacesSnap?.pinnedSessionIds ?? []);
935
+ const workspaceOf = new Map();
936
+ for (const workspace of workspacesSnap?.items ?? []) {
937
+ for (const id of workspace?.sessionIds ?? []) workspaceOf.set(id, workspace?.title ?? '');
938
+ }
939
+ const rows = [];
940
+ for (const [id, summary] of Object.entries(sessionsSnap?.byId ?? {})) {
941
+ if (!isListableSession(summary, id, currentId)) continue;
942
+ rows.push({
943
+ id,
944
+ title: summary?.displayTitle || (summary?.blank ? t('session.newSession') : String(id).slice(0, 8)),
945
+ archived: archived.has(id),
946
+ pinned: pinned.has(id),
947
+ running: summary?.running === true,
948
+ updatedAt: summary?.updatedAt ?? 0,
949
+ workspace: workspaceOf.get(id) ?? ''
950
+ });
951
+ }
952
+ const q = query.trim().toLowerCase();
953
+ let list = rows;
954
+ if (q !== '') {
955
+ list = rows
956
+ .map((row, index) => {
957
+ const title = row.title.toLowerCase();
958
+ const score = title.startsWith(q) ? 3
959
+ : title.includes(q) ? 2
960
+ : subsequence(q, title) ? 1
961
+ : subsequence(q, row.workspace.toLowerCase()) ? 1
962
+ : 0;
963
+ return { row, index, score };
964
+ })
965
+ .filter((entry) => entry.score > 0)
966
+ .sort((a, b) => (b.score - a.score) || (a.index - b.index))
967
+ .map((entry) => entry.row);
968
+ }
969
+ list.sort((a, b) => (Number(a.archived) - Number(b.archived)) ||
970
+ (q !== '' ? 0 : ((Number(b.pinned) - Number(a.pinned)) || (b.updatedAt - a.updatedAt))));
971
+ return list;
972
+ }
973
+
974
+ function relativeTimeLabel(updatedAt, now, t) {
975
+ if (!updatedAt) return '';
976
+ const diff = Math.max(0, now - updatedAt);
977
+ const minutes = Math.floor(diff / 60000);
978
+ if (minutes < 1) return t('time.now');
979
+ if (minutes < 60) return t('time.minutes').replace('{n}', String(minutes));
980
+ const hours = Math.floor(minutes / 60);
981
+ if (hours < 24) return t('time.hours').replace('{n}', String(hours));
982
+ const days = Math.floor(hours / 24);
983
+ if (days < 30) return t('time.days').replace('{n}', String(days));
984
+ const date = new Date(updatedAt);
985
+ return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
986
+ }
987
+
988
+ function useLocaleTick(ctx) {
989
+ const [, tick] = useReducer((n) => n + 1, 0);
990
+ useEffect(() => {
991
+ try { return ctx.locale.subscribe(tick); } catch { return undefined; }
992
+ }, [ctx]);
993
+ }
994
+
995
+ async function switchPermission(ctx, preset) {
996
+ const sessionId = mainSessionId(ctx);
997
+ const live = liveSession(ctx, sessionId);
998
+ if (!live || typeof live.command !== 'function') throw new Error('NO_SESSION');
999
+ const result = await live.command(`/permission ${preset}`);
1000
+ if (!result?.ok) throw new Error(`${result?.error?.code ?? 'error'}: ${result?.error?.message ?? ''}`);
1001
+ if (!result.value?.matched) throw new Error('NO_COMMAND');
1002
+ return true;
1003
+ }
1004
+
1005
+ const EMPTY_MODEL_STATE = Object.freeze({
1006
+ current: null, routable: null, groups: [], failures: [], status: 'idle', pending: null, error: null
1007
+ });
1008
+
1009
+ function useModelDirectory(ctx, sessionId) {
1010
+ const [dir, setDir] = useState(undefined);
1011
+ useEffect(() => {
1012
+ if (sessionId === undefined || !ctx.modelDirectories?.directoryFor) { setDir(undefined); return; }
1013
+ let next;
1014
+ try { next = ctx.modelDirectories.directoryFor(sessionId); } catch { next = undefined; }
1015
+ setDir(next);
1016
+ if (next) next.load().catch(() => {});
1017
+ }, [ctx, sessionId]);
1018
+ const snap = useSyncExternalStore(
1019
+ (cb) => (dir?.store && typeof dir.store.subscribe === 'function') ? dir.store.subscribe(cb) : () => {},
1020
+ () => (dir?.store ? dir.store.getSnapshot() : EMPTY_MODEL_STATE)
1021
+ );
1022
+ return { dir, snap };
1023
+ }
1024
+
1025
+ /* ------------------------------------------------------------------ */
1026
+ /* Session outline preview + permission cycle */
1027
+ /* ------------------------------------------------------------------ */
1028
+
1029
+ /** Read the host `turnOutline` projection: one 50-char prompt line per turn. */
1030
+ async function loadOutline(ctx, sessionId) {
1031
+ const read = (session) => {
1032
+ let snap;
1033
+ try { snap = session?.projections?.faceOf?.('turnOutline')?.getSnapshot?.(); }
1034
+ catch { return undefined; }
1035
+ if (snap === undefined || snap === null) return undefined;
1036
+ if (Array.isArray(snap)) return snap;
1037
+ if (Array.isArray(snap.entries)) return snap.entries;
1038
+ return undefined;
1039
+ };
1040
+ const binding = ctx.sessions.binding(sessionId);
1041
+ if (binding?.session) return read(binding.session);
1042
+ return await ctx.sessions.using(
1043
+ sessionId,
1044
+ { source: 'shortcutExtPreview' },
1045
+ (reference) => read(reference.binding.session)
1046
+ );
1047
+ }
1048
+
1049
+ function usePermissionValue(ctx, sessionId) {
1050
+ let face;
1051
+ try {
1052
+ face = sessionId === undefined ? undefined
1053
+ : ctx.sessions.binding(sessionId)?.session?.projections?.faceOf?.('permissions');
1054
+ } catch { face = undefined; }
1055
+ return useSyncExternalStore(
1056
+ (cb) => (face && typeof face.subscribe === 'function') ? face.subscribe(cb) : () => {},
1057
+ () => { try { return face ? face.getSnapshot() : undefined; } catch { return undefined; } }
1058
+ );
1059
+ }
1060
+
1061
+ function usePermissionCatalog(ctx) {
1062
+ const store = useMemo(() => createStore({ status: 'idle', options: [], error: null }), [ctx]);
1063
+ useEffect(() => {
1064
+ let disposed = false;
1065
+ const load = () => {
1066
+ const ns = ctx.remote?.permissionPresets;
1067
+ if (!ns || typeof ns.catalog !== 'function') {
1068
+ store.set({ status: 'unavailable', options: [], error: null });
1069
+ return;
1070
+ }
1071
+ store.set({ ...store.getSnapshot(), status: 'loading' });
1072
+ ns.catalog().then((result) => {
1073
+ if (disposed) return;
1074
+ if (!result?.ok) throw new Error(`${result?.error?.code ?? 'error'}: ${result?.error?.message ?? ''}`);
1075
+ store.set({ status: 'ready', options: result.value?.options ?? [], error: null });
1076
+ }).catch((error) => {
1077
+ if (!disposed) store.set({ status: 'error', options: [], error: error?.message ?? String(error) });
1078
+ });
1079
+ };
1080
+ load();
1081
+ let off;
1082
+ try { off = ctx.remote?.$on?.('permission-presets/catalog-changed', load); } catch { off = undefined; }
1083
+ return () => { disposed = true; if (typeof off === 'function') off(); };
1084
+ }, [ctx, store]);
1085
+ return useStore(store);
1086
+ }
1087
+
1088
+ const PERM_CYCLE_KEY = 'dsh.shortcut-ext.perm-cycle.v1';
1089
+ const CANONICAL_PRESETS = ['read-only', 'workspace-write', 'danger-full-access'];
1090
+
1091
+ function presetOrder(value) {
1092
+ const index = CANONICAL_PRESETS.indexOf(value);
1093
+ return index === -1 ? 99 : index;
1094
+ }
1095
+
1096
+ function readPermCycle() {
1097
+ try {
1098
+ const parsed = JSON.parse(localStorage.getItem(PERM_CYCLE_KEY));
1099
+ if (Array.isArray(parsed)) {
1100
+ const clean = [...new Set(parsed.filter((v) => typeof v === 'string' && v !== 'auto' && v !== 'custom'))];
1101
+ if (clean.length >= 2) return clean.sort((a, b) => presetOrder(a) - presetOrder(b));
1102
+ }
1103
+ } catch { /* fall through to default */ }
1104
+ return ['read-only', 'danger-full-access'];
1105
+ }
1106
+
1107
+ function createPermCycleStore() {
1108
+ const base = createStore(readPermCycle());
1109
+ const persist = () => {
1110
+ try { localStorage.setItem(PERM_CYCLE_KEY, JSON.stringify(base.getSnapshot())); } catch { /* private mode */ }
1111
+ };
1112
+ return {
1113
+ getSnapshot: base.getSnapshot,
1114
+ subscribe: base.subscribe,
1115
+ /** @returns false when the removal would leave fewer than two presets */
1116
+ toggle(value) {
1117
+ const list = base.getSnapshot();
1118
+ if (list.includes(value)) {
1119
+ if (list.length <= 2) return false;
1120
+ base.set(list.filter((v) => v !== value));
1121
+ } else {
1122
+ base.set([...list, value].sort((a, b) => presetOrder(a) - presetOrder(b)));
1123
+ }
1124
+ persist();
1125
+ return true;
1126
+ }
1127
+ };
1128
+ }
1129
+
1130
+ /* ------------------------------------------------------------------ */
1131
+ /* Row building and matching */
1132
+ /* ------------------------------------------------------------------ */
1133
+
1134
+ function subsequence(query, text) {
1135
+ let i = 0;
1136
+ for (let j = 0; j < text.length && i < query.length; j++) {
1137
+ if (text[j] === query[i]) i++;
1138
+ }
1139
+ return i === query.length;
1140
+ }
1141
+
1142
+ function matchScore(query, row) {
1143
+ const name = row.name.toLowerCase();
1144
+ const provider = row.providerName.toLowerCase();
1145
+ if (name.startsWith(query)) return 3;
1146
+ if (name.includes(query) || provider.includes(query)) return 2;
1147
+ if (subsequence(query, name)) return 1;
1148
+ return 0;
1149
+ }
1150
+
1151
+ /** AntD-style preset hues, assigned to providers by stable catalog order. */
1152
+ const PROVIDER_HUES = [210, 102, 24, 262, 188, 334, 74, 14, 230, 50];
1153
+
1154
+ function buildRows(snap, pinnedKeys, query) {
1155
+ const all = [];
1156
+ const groups = snap?.groups ?? [];
1157
+ for (let gi = 0; gi < groups.length; gi++) {
1158
+ const group = groups[gi];
1159
+ for (const model of group?.models ?? []) {
1160
+ const fullName = model.name ?? model.id;
1161
+ const dot = fullName.lastIndexOf('\u00B7');
1162
+ const base = dot > 0 ? fullName.slice(0, dot).trim() : '';
1163
+ const suffix = dot > 0 && dot < fullName.length - 1 ? fullName.slice(dot + 1).trim() : '';
1164
+ all.push({
1165
+ provider: group.id,
1166
+ providerName: group.name ?? group.id,
1167
+ hue: PROVIDER_HUES[gi % PROVIDER_HUES.length],
1168
+ model: model.id,
1169
+ name: fullName,
1170
+ baseName: base || fullName,
1171
+ suffix,
1172
+ reasoning: model.reasoning,
1173
+ description: model.description,
1174
+ key: `${group.id}/${model.id}`
1175
+ });
1176
+ }
1177
+ }
1178
+ const q = query.trim().toLowerCase();
1179
+ let list = all;
1180
+ if (q !== '') {
1181
+ list = all
1182
+ .map((row, index) => ({ row, index, score: matchScore(q, row) }))
1183
+ .filter((entry) => entry.score > 0)
1184
+ .sort((a, b) => (b.score - a.score) || (a.index - b.index))
1185
+ .map((entry) => entry.row);
1186
+ }
1187
+ const pinOrder = new Map(pinnedKeys.map((key, index) => [key, index]));
1188
+ const pinned = list.filter((row) => pinOrder.has(row.key))
1189
+ .sort((a, b) => pinOrder.get(a.key) - pinOrder.get(b.key));
1190
+ const rest = list.filter((row) => !pinOrder.has(row.key));
1191
+ return [...pinned, ...rest];
1192
+ }
1193
+
1194
+ /* ------------------------------------------------------------------ */
1195
+ /* Icons */
1196
+ /* ------------------------------------------------------------------ */
1197
+
1198
+ function StarIcon({ pinned }) {
1199
+ return h('svg', {
1200
+ width: 14, height: 14, viewBox: '0 0 16 16', 'aria-hidden': true, style: { display: 'block' }
1201
+ }, h('path', {
1202
+ d: 'M8 1.6l1.85 3.95 4.35.52-3.2 2.95.86 4.28L8 11.2l-3.86 2.1.86-4.28-3.2-2.95 4.35-.52z',
1203
+ fill: pinned ? 'currentColor' : 'none',
1204
+ stroke: 'currentColor',
1205
+ strokeWidth: 1.2,
1206
+ strokeLinejoin: 'round'
1207
+ }));
1208
+ }
1209
+
1210
+ function CheckIcon() {
1211
+ return h('svg', {
1212
+ width: 14, height: 14, viewBox: '0 0 16 16', 'aria-hidden': true, style: { display: 'block' }
1213
+ }, h('path', {
1214
+ d: 'M3 8.6 6.4 12 13 4.6', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8,
1215
+ strokeLinecap: 'round', strokeLinejoin: 'round'
1216
+ }));
1217
+ }
1218
+
1219
+ function PanelIcon({ size, active }) {
1220
+ const px = size ?? 16;
1221
+ const bar = (y, width) => h('rect', { x: 2, y, width, height: 2.4, rx: 1.2, fill: 'currentColor' });
1222
+ return h('svg', {
1223
+ width: px, height: px, viewBox: '0 0 16 16', 'aria-hidden': true,
1224
+ style: { display: 'block', color: active ? 'var(--dsw-alias-brand-primary)' : undefined }
1225
+ },
1226
+ bar(2.6, 12),
1227
+ bar(6.8, 8),
1228
+ bar(11, 10));
1229
+ }
1230
+
1231
+ /* ------------------------------------------------------------------ */
1232
+ /* Model palette */
1233
+ /* ------------------------------------------------------------------ */
1234
+
1235
+ function PaletteLayer(props) {
1236
+ const open = useStore(props.openStore);
1237
+ if (!open) return null;
1238
+ return h(PaletteInner, props);
1239
+ }
1240
+
1241
+ function PaletteInner({ ctx, t, openStore, pins }) {
1242
+ useLocaleTick(ctx);
1243
+ const sessionId = useSessionId(ctx);
1244
+ const { dir, snap } = useModelDirectory(ctx, sessionId);
1245
+ const pinnedKeys = useStore(pins);
1246
+ const [query, setQuery] = useState('');
1247
+ const [highlight, setHighlight] = useState(0);
1248
+ const [busyKey, setBusyKey] = useState(null);
1249
+ const [error, setError] = useState(null);
1250
+ const inputRef = useRef(null);
1251
+ const rowRefs = useRef([]);
1252
+
1253
+ useEffect(() => {
1254
+ const id = setTimeout(() => { try { inputRef.current?.focus(); } catch { /* noop */ } }, 0);
1255
+ return () => clearTimeout(id);
1256
+ }, []);
1257
+
1258
+ useEffect(() => {
1259
+ const onDocKey = (event) => {
1260
+ if (event.isComposing || event.keyCode === 229) return;
1261
+ if (event.key === 'Escape') { event.preventDefault(); openStore.set(false); }
1262
+ };
1263
+ document.addEventListener('keydown', onDocKey, true);
1264
+ return () => document.removeEventListener('keydown', onDocKey, true);
1265
+ }, [openStore]);
1266
+
1267
+ const rows = useMemo(() => buildRows(snap, pinnedKeys, query), [snap, pinnedKeys, query]);
1268
+ const currentKey = snap?.current ? `${snap.current.provider}/${snap.current.model}` : null;
1269
+
1270
+ useEffect(() => {
1271
+ setHighlight((prev) => Math.min(prev, Math.max(0, rows.length - 1)));
1272
+ }, [rows.length]);
1273
+
1274
+ useEffect(() => {
1275
+ try { rowRefs.current[highlight]?.scrollIntoView({ block: 'nearest' }); } catch { /* noop */ }
1276
+ }, [highlight]);
1277
+
1278
+ const select = useCallback(async (row) => {
1279
+ if (busyKey !== null || !dir) return;
1280
+ setBusyKey(row.key);
1281
+ setError(null);
1282
+ try {
1283
+ const result = await dir.select({ provider: row.provider, model: row.model });
1284
+ if (result?.ok) {
1285
+ openStore.set(false);
1286
+ return;
1287
+ }
1288
+ setError(`${t('palette.selectFailed')}: ${result?.error?.message ?? result?.error?.code ?? ''}`);
1289
+ } catch (e) {
1290
+ setError(`${t('palette.selectFailed')}: ${e?.message ?? e}`);
1291
+ } finally {
1292
+ setBusyKey(null);
1293
+ }
1294
+ }, [busyKey, dir, openStore, t]);
1295
+
1296
+ const onKeyDown = (event) => {
1297
+ if (event.isComposing || event.keyCode === 229) return;
1298
+ if (event.key === 'ArrowDown') {
1299
+ event.preventDefault();
1300
+ setHighlight((prev) => (rows.length === 0 ? 0 : (prev + 1) % rows.length));
1301
+ } else if (event.key === 'ArrowUp') {
1302
+ event.preventDefault();
1303
+ setHighlight((prev) => (rows.length === 0 ? 0 : (prev - 1 + rows.length) % rows.length));
1304
+ } else if (event.key === 'Enter') {
1305
+ event.preventDefault();
1306
+ const row = rows[highlight];
1307
+ if (row) select(row);
1308
+ } else if (event.key === 'Escape') {
1309
+ event.preventDefault();
1310
+ openStore.set(false);
1311
+ }
1312
+ };
1313
+
1314
+ let status = null;
1315
+ if (sessionId === undefined) status = t('palette.noSession');
1316
+ else if (snap.status === 'loading' || snap.status === 'idle') status = t('palette.loading');
1317
+ else if (snap.status === 'error') status = `${t('palette.loadError')}${snap.error ? `: ${snap.error}` : ''}`;
1318
+ else if (rows.length === 0) status = query.trim() === '' ? t('palette.empty') : t('palette.noMatch');
1319
+
1320
+ const failures = snap?.failures ?? [];
1321
+
1322
+ return h('div', {
1323
+ className: 'dqs-backdrop',
1324
+ onMouseDown: () => openStore.set(false)
1325
+ },
1326
+ h('div', {
1327
+ className: 'dqs-palette',
1328
+ role: 'dialog',
1329
+ 'aria-label': t('palette.title'),
1330
+ onMouseDown: (event) => event.stopPropagation()
1331
+ },
1332
+ h('div', { className: 'dqs-paletteHead' },
1333
+ h('span', { className: 'dqs-paletteTitle' }, t('palette.title')),
1334
+ h('button', {
1335
+ type: 'button',
1336
+ className: 'dqs-closeBtn',
1337
+ 'aria-label': t('palette.hint.close'),
1338
+ onClick: () => openStore.set(false)
1339
+ }, '\u2715')),
1340
+ h('div', { className: 'dqs-searchWrap' },
1341
+ h('span', { className: 'dqs-searchIcon' }, h(SearchIcon)),
1342
+ h('input', {
1343
+ className: 'dqs-input',
1344
+ ref: inputRef,
1345
+ value: query,
1346
+ placeholder: t('palette.placeholder'),
1347
+ 'aria-label': t('palette.title'),
1348
+ autoComplete: 'off',
1349
+ spellCheck: false,
1350
+ onChange: (event) => { setQuery(event.target.value); setHighlight(0); },
1351
+ onKeyDown
1352
+ })),
1353
+ h('div', { className: 'dqs-list', role: 'listbox', 'aria-label': t('palette.title') },
1354
+ rows.map((row, index) => {
1355
+ const pinned = pinnedKeys.includes(row.key);
1356
+ const isCurrent = row.key === currentKey;
1357
+ return h('div', {
1358
+ key: row.key,
1359
+ ref: (el) => { rowRefs.current[index] = el; },
1360
+ className: 'dqs-row',
1361
+ role: 'option',
1362
+ 'aria-selected': index === highlight,
1363
+ 'data-active': index === highlight ? 'true' : 'false',
1364
+ onMouseEnter: () => setHighlight(index),
1365
+ onClick: () => select(row)
1366
+ },
1367
+ h('button', {
1368
+ type: 'button',
1369
+ className: 'dqs-tag dqs-tagProvider',
1370
+ style: { '--dqs-hue': String(row.hue ?? 210) },
1371
+ title: row.providerName,
1372
+ onClick: (event) => { event.stopPropagation(); setQuery(row.providerName); setHighlight(0); }
1373
+ }, row.providerName),
1374
+ h('button', {
1375
+ type: 'button',
1376
+ className: 'dqs-pinBtn',
1377
+ 'data-pinned': pinned ? 'true' : 'false',
1378
+ title: t('palette.pin'),
1379
+ 'aria-label': `${t('palette.pin')}: ${row.name}`,
1380
+ 'aria-pressed': pinned,
1381
+ onClick: (event) => { event.stopPropagation(); pins.toggle(row.key); }
1382
+ }, h(StarIcon, { pinned })),
1383
+ h('span', { className: 'dqs-rowName', title: row.description ?? undefined }, row.baseName),
1384
+ row.suffix
1385
+ ? h('button', {
1386
+ type: 'button',
1387
+ className: 'dqs-tag',
1388
+ title: row.suffix,
1389
+ onClick: (event) => { event.stopPropagation(); setQuery(row.suffix); setHighlight(0); }
1390
+ }, row.suffix)
1391
+ : null,
1392
+ row.reasoning
1393
+ ? h('span', { className: 'dqs-tag dqs-tagReasoning' },
1394
+ `${t('tag.reasoning')}\u00B7${row.reasoning?.efforts?.length ?? 0}`)
1395
+ : null,
1396
+ h('span', { className: 'dqs-rowEnd' },
1397
+ busyKey === row.key
1398
+ ? h('span', { className: 'dqs-rowBusy' }, t('palette.switching'))
1399
+ : (isCurrent ? h('span', { className: 'dqs-check' }, h(CheckIcon)) : null))
1400
+ );
1401
+ })),
1402
+ status ? h('div', {
1403
+ className: `dqs-status${snap.status === 'error' ? ' dqs-statusError' : ''}`
1404
+ }, status) : null,
1405
+ error ? h('div', { className: 'dqs-status dqs-statusError' }, error) : null,
1406
+ !status && failures.length > 0
1407
+ ? h('div', { className: 'dqs-status' }, t('palette.providerFailures'))
1408
+ : null,
1409
+ h('div', { className: 'dqs-footer' },
1410
+ h('span', null, h('span', { className: 'dqs-kbd' }, '\u2191\u2193'), t('palette.hint.nav')),
1411
+ h('span', null, h('span', { className: 'dqs-kbd' }, 'Enter'), t('palette.hint.confirm')),
1412
+ h('span', null, h('span', { className: 'dqs-kbd' }, 'Esc'), t('palette.hint.close')),
1413
+ h('span', null, h('span', { className: 'dqs-kbd' }, '\u2605'), t('palette.hint.pin')),
1414
+ h('span', null, t('palette.hint.tag')))
1415
+ ));
1416
+ }
1417
+
1418
+ /* ------------------------------------------------------------------ */
1419
+ /* Session palette (quick open for sessions, archived included) */
1420
+ /* ------------------------------------------------------------------ */
1421
+
1422
+ function SessionPaletteLayer(props) {
1423
+ const open = useStore(props.openStore);
1424
+ if (!open) return null;
1425
+ return h(SessionPaletteInner, props);
1426
+ }
1427
+
1428
+ function SessionPaletteInner({ ctx, t, openStore }) {
1429
+ useLocaleTick(ctx);
1430
+ const sessionsSnap = useSessionsList(ctx);
1431
+ const workspacesSnap = useWorkspacesList(ctx);
1432
+ const currentId = useSessionId(ctx);
1433
+ const [query, setQuery] = useState('');
1434
+ const [highlight, setHighlight] = useState(0);
1435
+ const [busyId, setBusyId] = useState(null);
1436
+ const [error, setError] = useState(null);
1437
+ const [preview, setPreview] = useState(null);
1438
+ const outlineCache = useRef(new Map());
1439
+ const inputRef = useRef(null);
1440
+ const rowRefs = useRef([]);
1441
+ const now = Date.now();
1442
+
1443
+ useEffect(() => {
1444
+ const id = setTimeout(() => { try { inputRef.current?.focus(); } catch { /* noop */ } }, 0);
1445
+ return () => clearTimeout(id);
1446
+ }, []);
1447
+
1448
+ useEffect(() => {
1449
+ const onDocKey = (event) => {
1450
+ if (event.isComposing || event.keyCode === 229) return;
1451
+ if (event.key === 'Escape') { event.preventDefault(); openStore.set(false); }
1452
+ };
1453
+ document.addEventListener('keydown', onDocKey, true);
1454
+ return () => document.removeEventListener('keydown', onDocKey, true);
1455
+ }, [openStore]);
1456
+
1457
+ const rows = useMemo(
1458
+ () => buildSessionRows(sessionsSnap, workspacesSnap, query, currentId, t),
1459
+ [sessionsSnap, workspacesSnap, query, currentId, t]
1460
+ );
1461
+
1462
+ useEffect(() => {
1463
+ setHighlight((prev) => Math.min(prev, Math.max(0, rows.length - 1)));
1464
+ }, [rows.length]);
1465
+
1466
+ useEffect(() => {
1467
+ try { rowRefs.current[highlight]?.scrollIntoView({ block: 'nearest' }); } catch { /* noop */ }
1468
+ }, [highlight]);
1469
+
1470
+ // Right-hand preview follows the highlight; debounced so fast arrow
1471
+ // sweeps do not retain cold sessions one by one.
1472
+ const previewId = rows[highlight]?.id ?? null;
1473
+ useEffect(() => {
1474
+ if (previewId === null) { setPreview(null); return undefined; }
1475
+ const cached = outlineCache.current.get(previewId);
1476
+ if (cached) { setPreview({ id: previewId, status: 'ready', lines: cached }); return undefined; }
1477
+ let cancelled = false;
1478
+ const timer = setTimeout(() => {
1479
+ setPreview({ id: previewId, status: 'loading', lines: [] });
1480
+ loadOutline(ctx, previewId).then((outline) => {
1481
+ if (cancelled) return;
1482
+ if (outline === undefined) { setPreview({ id: previewId, status: 'unavailable', lines: [] }); return; }
1483
+ const lines = outline.map((entry) => entry?.prompt ?? '').filter((text) => text !== '');
1484
+ outlineCache.current.set(previewId, lines);
1485
+ setPreview({ id: previewId, status: 'ready', lines });
1486
+ }).catch(() => {
1487
+ if (!cancelled) setPreview({ id: previewId, status: 'unavailable', lines: [] });
1488
+ });
1489
+ }, 150);
1490
+ return () => { cancelled = true; clearTimeout(timer); };
1491
+ }, [previewId, ctx]);
1492
+
1493
+ const openRow = useCallback(async (row) => {
1494
+ if (busyId !== null) return;
1495
+ setBusyId(row.id);
1496
+ setError(null);
1497
+ try {
1498
+ if (row.archived) await ctx.uiWorkspace.unarchiveSession(row.id);
1499
+ openSessionId(ctx, row.id);
1500
+ openStore.set(false);
1501
+ } catch (e) {
1502
+ setError(`${t('session.openFailed')}: ${e?.message ?? e}`);
1503
+ } finally {
1504
+ setBusyId(null);
1505
+ }
1506
+ }, [busyId, ctx, openStore, t]);
1507
+
1508
+ const onKeyDown = (event) => {
1509
+ if (event.isComposing || event.keyCode === 229) return;
1510
+ if (event.key === 'ArrowDown') {
1511
+ event.preventDefault();
1512
+ setHighlight((prev) => (rows.length === 0 ? 0 : (prev + 1) % rows.length));
1513
+ } else if (event.key === 'ArrowUp') {
1514
+ event.preventDefault();
1515
+ setHighlight((prev) => (rows.length === 0 ? 0 : (prev - 1 + rows.length) % rows.length));
1516
+ } else if (event.key === 'Enter') {
1517
+ event.preventDefault();
1518
+ const row = rows[highlight];
1519
+ if (row) openRow(row);
1520
+ } else if (event.key === 'Escape') {
1521
+ event.preventDefault();
1522
+ openStore.set(false);
1523
+ }
1524
+ };
1525
+
1526
+ let status = null;
1527
+ if (rows.length === 0) {
1528
+ status = Object.keys(sessionsSnap?.byId ?? {}).length === 0
1529
+ ? t('session.noneYet')
1530
+ : (query.trim() === '' ? t('session.noneYet') : t('session.empty'));
1531
+ }
1532
+
1533
+ const currentRow = rows[highlight];
1534
+ const previewMeta = currentRow
1535
+ ? [
1536
+ currentRow.archived ? t('session.badgeArchived') : null,
1537
+ currentRow.workspace || null,
1538
+ relativeTimeLabel(currentRow.updatedAt, now, t) || null
1539
+ ].filter(Boolean).join(' \u00B7 ')
1540
+ : '\u00A0';
1541
+
1542
+ return h('div', {
1543
+ className: 'dqs-backdrop',
1544
+ onMouseDown: () => openStore.set(false)
1545
+ },
1546
+ h('div', {
1547
+ className: 'dqs-palette dqs-paletteWide',
1548
+ role: 'dialog',
1549
+ 'aria-label': t('session.paletteTitle'),
1550
+ onMouseDown: (event) => event.stopPropagation()
1551
+ },
1552
+ h('div', { className: 'dqs-paletteHead' },
1553
+ h('span', { className: 'dqs-paletteTitle' }, t('session.paletteTitle')),
1554
+ h('button', {
1555
+ type: 'button',
1556
+ className: 'dqs-closeBtn',
1557
+ 'aria-label': t('session.hint.close'),
1558
+ onClick: () => openStore.set(false)
1559
+ }, '\u2715')),
1560
+ h('div', { className: 'dqs-searchWrap' },
1561
+ h('span', { className: 'dqs-searchIcon' }, h(SearchIcon)),
1562
+ h('input', {
1563
+ className: 'dqs-input',
1564
+ ref: inputRef,
1565
+ value: query,
1566
+ placeholder: t('session.placeholder'),
1567
+ 'aria-label': t('session.paletteTitle'),
1568
+ autoComplete: 'off',
1569
+ spellCheck: false,
1570
+ onChange: (event) => { setQuery(event.target.value); setHighlight(0); },
1571
+ onKeyDown
1572
+ })),
1573
+ h('div', { className: 'dqs-columns' },
1574
+ h('div', { className: 'dqs-list', role: 'listbox', 'aria-label': t('session.paletteTitle') },
1575
+ status
1576
+ ? h('div', { className: 'dqs-status' }, status)
1577
+ : rows.map((row, index) => h('div', {
1578
+ key: row.id,
1579
+ ref: (el) => { rowRefs.current[index] = el; },
1580
+ className: 'dqs-row',
1581
+ role: 'option',
1582
+ 'aria-selected': index === highlight,
1583
+ 'data-active': index === highlight ? 'true' : 'false',
1584
+ onMouseEnter: () => setHighlight(index),
1585
+ onClick: () => openRow(row)
1586
+ },
1587
+ h('span', {
1588
+ className: 'dqs-rowDot',
1589
+ 'data-running': row.running ? 'true' : 'false',
1590
+ 'aria-hidden': true
1591
+ }),
1592
+ h('span', { className: 'dqs-rowName' }, row.title),
1593
+ row.pinned ? h('span', { className: 'dqs-rowPin', 'aria-hidden': true }, h(StarIcon, { pinned: true })) : null,
1594
+ row.archived ? h('span', { className: 'dqs-badge' }, t('session.badgeArchived')) : null,
1595
+ h('span', { className: 'dqs-rowProvider' },
1596
+ [row.workspace, relativeTimeLabel(row.updatedAt, now, t)].filter(Boolean).join(' \u00B7 ')),
1597
+ busyId === row.id
1598
+ ? h('span', { className: 'dqs-rowBusy' }, t('session.busy'))
1599
+ : (row.id === currentId && !row.archived
1600
+ ? h('span', { className: 'dqs-check' }, h(CheckIcon))
1601
+ : null)))),
1602
+ h('div', { className: 'dqs-previewPane' },
1603
+ h('div', { className: 'dqs-previewHead' },
1604
+ h('span', null, t('session.previewTitle')),
1605
+ h('span', { className: 'dqs-previewSession' }, currentRow?.title ?? '')),
1606
+ h('div', { className: 'dqs-previewMeta' }, previewMeta),
1607
+ h('div', { className: 'dqs-previewScroll' },
1608
+ preview === null
1609
+ ? null
1610
+ : preview.status === 'loading'
1611
+ ? h('div', { className: 'dqs-previewLine' }, t('session.previewLoading'))
1612
+ : preview.status === 'unavailable'
1613
+ ? h('div', { className: 'dqs-previewLine' }, t('session.previewUnavailable'))
1614
+ : preview.lines.length === 0
1615
+ ? h('div', { className: 'dqs-previewLine' }, t('session.previewEmpty'))
1616
+ : preview.lines.map((line, index) => {
1617
+ return h('div', { key: index, className: 'dqs-previewLine' }, `${index + 1}. ${line}`);
1618
+ })))),
1619
+ error ? h('div', { className: 'dqs-status dqs-statusError' }, error) : null,
1620
+ h('div', { className: 'dqs-footer' },
1621
+ h('span', null, h('span', { className: 'dqs-kbd' }, '\u2191\u2193'), t('session.hint.nav')),
1622
+ h('span', null, h('span', { className: 'dqs-kbd' }, 'Enter'), t('session.hint.open')),
1623
+ h('span', null, h('span', { className: 'dqs-kbd' }, 'Esc'), t('session.hint.close')))));
1624
+ }
1625
+
1626
+ /* ------------------------------------------------------------------ */
1627
+ /* Transient hint banner (visible feedback for blocked commands) */
1628
+ /* ------------------------------------------------------------------ */
1629
+
1630
+ function HintBanner({ store }) {
1631
+ const state = useStore(store);
1632
+ useEffect(() => {
1633
+ if (!state) return undefined;
1634
+ const id = setTimeout(() => store.set(null), 2600);
1635
+ return () => clearTimeout(id);
1636
+ }, [state, store]);
1637
+ if (!state) return null;
1638
+ return h('div', { className: 'dqs-hintbar', role: 'status' }, state.text);
1639
+ }
1640
+
1641
+ /* ------------------------------------------------------------------ */
1642
+ /* Sidebar panel: command deck */
1643
+ /* ------------------------------------------------------------------ */
1644
+
1645
+ function KeyCaps({ row, t }) {
1646
+ if (!row?.keys?.length) return h('span', { className: 'dqs-keycapNone' }, t('shortcut.unbound'));
1647
+ return h('span', { className: 'dqs-cmdKeys' },
1648
+ row.keys.map((cap, index) => h('span', { key: index, className: 'dqs-keycap' }, cap)));
1649
+ }
1650
+
1651
+ function CommandGlyph({ id }) {
1652
+ const common = {
1653
+ width: 20, height: 20, viewBox: '0 0 16 16', 'aria-hidden': true, style: { display: 'block' }
1654
+ };
1655
+ switch (id) {
1656
+ case 'open-model-palette':
1657
+ return h('svg', common, h('path', {
1658
+ d: 'M9.2 1.5 4 9.2h3L6.2 14.5 12 6.6H8.7l.5-5.1z', fill: 'currentColor'
1659
+ }));
1660
+ case 'open-session-palette':
1661
+ return h('svg', common, h('path', {
1662
+ d: 'M2.5 4h11M2.5 8h7M2.5 12h9', stroke: 'currentColor', strokeWidth: 1.6,
1663
+ strokeLinecap: 'round', fill: 'none'
1664
+ }));
1665
+ case 'next-session':
1666
+ return h('svg', common, h('path', {
1667
+ d: 'M3 8h9M8.5 4 12.5 8l-4 4', stroke: 'currentColor', strokeWidth: 1.6,
1668
+ strokeLinecap: 'round', strokeLinejoin: 'round', fill: 'none'
1669
+ }));
1670
+ case 'prev-session':
1671
+ return h('svg', common, h('path', {
1672
+ d: 'M13 8H4M7.5 4 3.5 8l4 4', stroke: 'currentColor', strokeWidth: 1.6,
1673
+ strokeLinecap: 'round', strokeLinejoin: 'round', fill: 'none'
1674
+ }));
1675
+ case 'jump-completed':
1676
+ return h('svg', common,
1677
+ h('circle', { cx: 8, cy: 8, r: 5.2, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 }),
1678
+ h('circle', { cx: 8, cy: 8, r: 1.8, fill: 'currentColor' }));
1679
+ case 'focus-composer':
1680
+ return h('svg', common, h('path', {
1681
+ d: 'M5 2.5h6M8 2.5v11M5 13.5h6', stroke: 'currentColor', strokeWidth: 1.6,
1682
+ strokeLinecap: 'round', fill: 'none'
1683
+ }));
1684
+ case 'toggle-sidebar':
1685
+ return h('svg', common,
1686
+ h('rect', { x: 2, y: 2.5, width: 12, height: 11, rx: 2, fill: 'none', stroke: 'currentColor', strokeWidth: 1.4 }),
1687
+ h('path', { d: 'M6 2.5v11', stroke: 'currentColor', strokeWidth: 1.4 }));
1688
+ case 'show-conversation':
1689
+ return h('svg', common, h('path', {
1690
+ d: 'M2.5 3.5h11v7h-6l-3 2.5v-2.5h-2z', fill: 'none', stroke: 'currentColor',
1691
+ strokeWidth: 1.4, strokeLinejoin: 'round'
1692
+ }));
1693
+ case 'show-deck':
1694
+ return h('svg', common, h('path', {
1695
+ d: 'M2.5 4h11M2.5 8h11M2.5 12h11', stroke: 'currentColor', strokeWidth: 1.6,
1696
+ strokeLinecap: 'round', fill: 'none'
1697
+ }));
1698
+ case 'new-session':
1699
+ return h('svg', common, h('path', {
1700
+ d: 'M8 3v10M3 8h10', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round', fill: 'none'
1701
+ }));
1702
+ default:
1703
+ return h('svg', common, h('path', {
1704
+ d: 'M8 1.6 12.6 3.4v4.2c0 3.2-2.2 5.2-4.6 6.8-2.4-1.6-4.6-3.6-4.6-6.8V3.4z',
1705
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.5, strokeLinejoin: 'round'
1706
+ }));
1707
+ }
1708
+ }
1709
+
1710
+ function SearchIcon() {
1711
+ return h('svg', {
1712
+ width: 14, height: 14, viewBox: '0 0 16 16', 'aria-hidden': true, style: { display: 'block' }
1713
+ },
1714
+ h('circle', { cx: 7, cy: 7, r: 4.2, fill: 'none', stroke: 'currentColor', strokeWidth: 1.5 }),
1715
+ h('path', { d: 'M10.4 10.4 14 14', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }));
1716
+ }
1717
+
1718
+ function ChevronIcon({ open }) {
1719
+ return h('svg', {
1720
+ width: 12, height: 12, viewBox: '0 0 16 16', 'aria-hidden': true,
1721
+ style: { display: 'block', transform: open ? 'rotate(180deg)' : undefined, transition: 'transform .15s' }
1722
+ }, h('path', {
1723
+ d: 'M3 5.5 8 10.5 13 5.5', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6,
1724
+ strokeLinecap: 'round', strokeLinejoin: 'round'
1725
+ }));
1726
+ }
1727
+
1728
+ const RECORDER_ISSUE_KEY = {
1729
+ 'modifier-required': 'custom.modifierRequired'
1730
+ };
1731
+
1732
+ function KeyRecorder({ ctx, t, value, onChange }) {
1733
+ const [recording, setRecording] = useState(false);
1734
+ const [issue, setIssue] = useState(null);
1735
+ const ref = useRef(null);
1736
+ useEffect(() => {
1737
+ if (recording) { try { ref.current?.focus(); } catch { /* noop */ } }
1738
+ }, [recording]);
1739
+ // Desktop recording protection: suspend native menu accelerators while
1740
+ // capturing, exactly like the host shortcut settings recorder.
1741
+ useEffect(() => {
1742
+ if (!recording) return undefined;
1743
+ try { ctx.shortcuts?.recording?.(true)?.catch?.(() => {}); } catch { /* noop */ }
1744
+ return () => {
1745
+ try { ctx.shortcuts?.recording?.(false)?.catch?.(() => {}); } catch { /* noop */ }
1746
+ };
1747
+ }, [recording, ctx]);
1748
+ const describe = (binding) => {
1749
+ try { return ctx.shortcuts.describeBinding(binding); } catch { return undefined; }
1750
+ };
1751
+ const shown = value ? describe(value) : undefined;
1752
+ const onKeyDown = (event) => {
1753
+ event.preventDefault();
1754
+ event.stopPropagation();
1755
+ if (event.key === 'Escape') { setRecording(false); return; }
1756
+ if (/^(Control|Alt|Shift|Meta)(Left|Right)$/.test(event.code)) return;
1757
+ const isMac = /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || '');
1758
+ const primary = isMac ? 'meta' : 'control';
1759
+ const modifiers = ['control', 'alt', 'shift', 'meta']
1760
+ .filter((name) => event[`${name}Key`])
1761
+ .map((name) => (name === primary ? 'primary' : name));
1762
+ if (modifiers.length === 0) { setIssue(t('custom.modifierRequired')); return; }
1763
+ const described = describe({ code: event.code, modifiers });
1764
+ if (!described?.binding) { setIssue(t('custom.keyIssue')); return; }
1765
+ if (described.issue) { setIssue(t(RECORDER_ISSUE_KEY[described.issue] ?? 'custom.keyIssue')); return; }
1766
+ if (described.conflicts?.length) { setIssue(t('custom.conflict')); return; }
1767
+ setIssue(null);
1768
+ onChange({ code: event.code, modifiers });
1769
+ setRecording(false);
1770
+ };
1771
+ return h('span', { style: { display: 'inline-flex', flexDirection: 'column', gap: 4 } },
1772
+ h('button', {
1773
+ ref,
1774
+ type: 'button',
1775
+ className: 'dqs-recordBtn',
1776
+ 'data-recording': recording ? 'true' : 'false',
1777
+ onFocus: () => setRecording(true),
1778
+ onBlur: () => setRecording(false),
1779
+ onKeyDown: recording
1780
+ ? onKeyDown
1781
+ : (event) => {
1782
+ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); setRecording(true); }
1783
+ }
1784
+ }, recording
1785
+ ? t('custom.record')
1786
+ : (shown?.keys?.length ? shown.keys.join('') : t('custom.record'))),
1787
+ issue
1788
+ ? h('span', { className: 'dqs-editorError' }, issue)
1789
+ : (recording ? h('span', { className: 'dqs-editorHelp' }, t('custom.recordHelp')) : null));
1790
+ }
1791
+
1792
+ /**
1793
+ * Reserved: custom-command editor card. Its toolbar entry point is hidden
1794
+ * for now; re-add a "+ Add" button that sets `editing` to re-enable.
1795
+ */
1796
+ function CustomEditorCard({ ctx, t, editing, setEditing, onSave }) {
1797
+ return h('div', { className: 'dqs-editor' },
1798
+ h('div', { className: 'dqs-editorTitle' }, t('custom.title')),
1799
+ h('div', { className: 'dqs-editorRow' },
1800
+ h('label', { className: 'dqs-editorLabel' }, t('custom.action'),
1801
+ h('select', {
1802
+ className: 'dqs-select',
1803
+ value: editing.action,
1804
+ onChange: (event) => setEditing({ ...editing, action: event.target.value })
1805
+ }, ACTIONS.map((action) => {
1806
+ return h('option', { key: action.id, value: action.id }, t(action.labelKey));
1807
+ }))),
1808
+ ACTION_BY_ID.get(editing.action)?.param === 'preset'
1809
+ ? h('label', { className: 'dqs-editorLabel' }, t('custom.param'),
1810
+ h('select', {
1811
+ className: 'dqs-select',
1812
+ value: editing.param,
1813
+ onChange: (event) => setEditing({ ...editing, param: event.target.value })
1814
+ }, PRESETS.map((preset) => {
1815
+ return h('option', { key: preset.value, value: preset.value }, t(preset.labelKey));
1816
+ })))
1817
+ : null,
1818
+ h('label', { className: 'dqs-editorLabel' }, t('custom.key'),
1819
+ h(KeyRecorder, {
1820
+ ctx,
1821
+ t,
1822
+ value: editing.binding,
1823
+ onChange: (binding) => setEditing({ ...editing, binding, issue: null })
1824
+ }))),
1825
+ editing.issue
1826
+ ? h('div', { className: 'dqs-editorError', style: { marginTop: 10 } }, editing.issue)
1827
+ : null,
1828
+ h('div', { className: 'dqs-editorActions' },
1829
+ h('button', { type: 'button', className: 'dqs-saveBtn', onClick: onSave }, t('custom.save')),
1830
+ h('button', {
1831
+ type: 'button',
1832
+ className: 'dqs-ghostBtn',
1833
+ onClick: () => setEditing(null)
1834
+ }, t('custom.cancel'))));
1835
+ }
1836
+
1837
+ function PermExpand(props) {
1838
+ const { t, catalog, catalogOptions, cycle, permCurrent, permIssue, permLabel, permCycle, setPermIssue } = props;
1839
+ return h('div', { className: 'dqs-expand' },
1840
+ catalogOptions.length === 0
1841
+ ? h('div', { className: 'dqs-hint', style: { marginTop: 0 } },
1842
+ catalog.status === 'unavailable' || catalog.status === 'error'
1843
+ ? t('perm.unavailable')
1844
+ : t('perm.catalogLoading'))
1845
+ : h('div', { className: 'dqs-chips' },
1846
+ catalogOptions.map((option) => {
1847
+ return h('button', {
1848
+ key: option.value,
1849
+ type: 'button',
1850
+ className: 'dqs-permChip',
1851
+ 'data-included': cycle.includes(option.value) ? 'true' : 'false',
1852
+ 'aria-pressed': cycle.includes(option.value) ? 'true' : 'false',
1853
+ title: option.description ?? undefined,
1854
+ onClick: () => {
1855
+ const ok = permCycle.toggle(option.value);
1856
+ setPermIssue(ok ? null : t('perm.needTwo'));
1857
+ }
1858
+ }, permLabel(option.value, option.name));
1859
+ })),
1860
+ h('div', { className: 'dqs-hint' }, cycle.map((value) => permLabel(value)).join(' \u2192 ')),
1861
+ permCurrent
1862
+ ? h('div', { className: 'dqs-hint' }, `${t('perm.current')}\uFF1A${permLabel(permCurrent)}`)
1863
+ : null,
1864
+ permIssue
1865
+ ? h('div', { className: 'dqs-hint', style: { color: 'var(--dsw-alias-state-error-primary)' } }, permIssue)
1866
+ : null,
1867
+ h('div', { className: 'dqs-hint' }, t('perm.cycleHint')),
1868
+ h('div', { className: 'dqs-hint' }, t('perm.autoExcluded')));
1869
+ }
1870
+
1871
+ /** Host shortcut catalog with device-local rebind/reset through ctx.shortcuts.edit. */
1872
+ function SystemTab({ ctx, t, query }) {
1873
+ const shortcutRows = useShortcutRows(ctx);
1874
+ const configSnap = useSyncExternalStore(
1875
+ (cb) => (ctx.shortcuts?.config?.subscribe ? ctx.shortcuts.config.subscribe(cb) : () => {}),
1876
+ () => { try { return ctx.shortcuts?.config?.getSnapshot?.() ?? null; } catch { return null; } }
1877
+ );
1878
+ const fixedRows = useSyncExternalStore(
1879
+ (cb) => (ctx.shortcuts?.fixedCatalog?.subscribe ? ctx.shortcuts.fixedCatalog.subscribe(cb) : () => {}),
1880
+ () => {
1881
+ try { return ctx.shortcuts?.fixedCatalog?.getSnapshot?.() ?? EMPTY_SHORTCUT_ROWS; }
1882
+ catch { return EMPTY_SHORTCUT_ROWS; }
1883
+ }
1884
+ );
1885
+ const [sysIssues, setSysIssues] = useState({});
1886
+
1887
+ const profileKey = `${ctx.shortcuts?.runtime ?? 'desktop'}:${ctx.shortcuts?.platform ?? 'macos'}`;
1888
+ const overrides = configSnap?.document?.profiles?.[profileKey] ?? {};
1889
+ const FAILED = useMemo(() => new Set(['stale', 'not-ready', 'unreadable', 'write-failed']), []);
1890
+ const applyEdit = async (edit) => {
1891
+ let result = await ctx.shortcuts.edit(edit, configSnap?.revision);
1892
+ if (result?.status === 'stale') result = await ctx.shortcuts.edit(edit, result.snapshot?.revision);
1893
+ return result;
1894
+ };
1895
+ const markResult = (id, result) => {
1896
+ setSysIssues((current) => {
1897
+ const next = { ...current };
1898
+ if (result && FAILED.has(result.status)) next[id] = t('system.saveFailed');
1899
+ else delete next[id];
1900
+ return next;
1901
+ });
1902
+ };
1903
+ const sysRows = useMemo(() => {
1904
+ // The host catalog also lists this plugin's own commands; the reference
1905
+ // tab shows host/system commands only (the deck tab owns ours).
1906
+ const rows = (shortcutRows ?? []).filter((row) => !row.id.startsWith('shortcut-ext.'));
1907
+ const qq = (query ?? '').trim().toLowerCase();
1908
+ if (qq === '') return rows;
1909
+ return rows.filter((row) => {
1910
+ const hay = `${row.label ?? ''} ${(row.keys ?? []).join('')}`.toLowerCase();
1911
+ return hay.includes(qq) || subsequence(qq, hay);
1912
+ });
1913
+ }, [shortcutRows, query]);
1914
+
1915
+ return h(React.Fragment, null,
1916
+ h('div', { className: 'dqs-rows' },
1917
+ sysRows.map((row) => {
1918
+ return h('div', { key: row.id, className: 'dqs-sysRow' },
1919
+ h('span', { className: 'dqs-sysLabel' }, row.label),
1920
+ h(KeyRecorder, {
1921
+ ctx,
1922
+ t,
1923
+ value: row.binding ?? null,
1924
+ onChange: async (binding) => {
1925
+ markResult(row.id, await applyEdit({ type: 'set', id: row.id, binding }));
1926
+ }
1927
+ }),
1928
+ overrides[row.id] !== undefined
1929
+ ? h('button', {
1930
+ type: 'button',
1931
+ className: 'dqs-linkBtn',
1932
+ onClick: async () => {
1933
+ markResult(row.id, await applyEdit({ type: 'reset', id: row.id }));
1934
+ }
1935
+ }, t('system.reset'))
1936
+ : null,
1937
+ sysIssues[row.id]
1938
+ ? h('span', { className: 'dqs-editorError' }, sysIssues[row.id])
1939
+ : null);
1940
+ }),
1941
+ fixedRows.map((row) => {
1942
+ return h('div', { key: row.id, className: 'dqs-sysRow' },
1943
+ h('span', { className: 'dqs-sysLabel' }, row.label),
1944
+ h('span', { className: 'dqs-cmdKeys' },
1945
+ (row.keys ?? []).map((cap, index) => {
1946
+ return h('span', { key: index, className: 'dqs-keycap' }, cap);
1947
+ })),
1948
+ h('span', { className: 'dqs-sysFixed' }, t('system.fixed')));
1949
+ })),
1950
+ h('div', { className: 'dqs-hint' }, t('system.hint')));
1951
+ }
1952
+
1953
+ function CommandDeckPanel({ ctx, t, enabled, custom, permCycle }) {
1954
+ useLocaleTick(ctx);
1955
+ const shortcutRows = useShortcutRows(ctx);
1956
+ useStore(enabled);
1957
+ const customList = useStore(custom);
1958
+ const cycle = useStore(permCycle);
1959
+ const sessionId = useSessionId(ctx);
1960
+ const permCurrent = usePermissionValue(ctx, sessionId)?.currentValue;
1961
+ const catalog = usePermissionCatalog(ctx);
1962
+ const [permIssue, setPermIssue] = useState(null);
1963
+ const [query, setQuery] = useState('');
1964
+ const [expandedId, setExpandedId] = useState(null);
1965
+ const [tab, setTab] = useState('commands');
1966
+
1967
+ const catalogOptions = useMemo(() => {
1968
+ const options = (catalog.options ?? [])
1969
+ .filter((option) => option.value !== 'auto' && option.value !== 'custom');
1970
+ return [...options].sort((a, b) => presetOrder(a.value) - presetOrder(b.value));
1971
+ }, [catalog]);
1972
+ const permLabel = (value, hostName) => {
1973
+ const key = PRESET_LABEL_KEY[value];
1974
+ if (key) return t(key);
1975
+ return hostName ?? String(value);
1976
+ };
1977
+
1978
+ const entries = useMemo(() => commandEntries(customList), [customList]);
1979
+ const rowOf = (id) => (shortcutRows ?? []).find((candidate) => candidate.id === id);
1980
+ const labelOf = (entry) => (entry.paramKey
1981
+ ? `${t(entry.labelKey)}\uFF1A${t(entry.paramKey)}`
1982
+ : t(entry.labelKey));
1983
+ const q = query.trim().toLowerCase();
1984
+ const visible = entries.filter((entry) => {
1985
+ if (q === '') return true;
1986
+ const row = rowOf(entry.id);
1987
+ const hay = `${labelOf(entry)} ${entry.action.impl} ${(row?.keys ?? []).join('')}`.toLowerCase();
1988
+ return hay.includes(q) || subsequence(q, hay);
1989
+ });
1990
+ return h('div', { className: 'dqs-page' },
1991
+ h('div', { className: 'dqs-pageHead' },
1992
+ h('h1', { className: 'dqs-pageTitle' }, t('panel.title')),
1993
+ h('p', { className: 'dqs-pageSub' }, t('panel.subtitle'))),
1994
+
1995
+ h('div', { className: 'dqs-toolbar' },
1996
+ h('div', { className: 'dqs-tabs', role: 'tablist', 'aria-label': t('panel.title') },
1997
+ [['commands', t('tab.commands')], ['system', t('tab.system')]].map(([value, label]) =>
1998
+ h('button', {
1999
+ key: value,
2000
+ type: 'button',
2001
+ role: 'tab',
2002
+ 'aria-selected': tab === value ? 'true' : 'false',
2003
+ className: 'dqs-tab',
2004
+ onClick: () => setTab(value)
2005
+ }, label))),
2006
+ h('input', {
2007
+ className: 'dqs-search',
2008
+ style: { marginLeft: 'auto' },
2009
+ value: query,
2010
+ placeholder: t('panel.search'),
2011
+ 'aria-label': t('panel.search'),
2012
+ autoComplete: 'off',
2013
+ spellCheck: false,
2014
+ onChange: (event) => setQuery(event.target.value)
2015
+ })),
2016
+
2017
+ tab === 'commands'
2018
+ ? h(React.Fragment, null,
2019
+ h('div', { className: 'dqs-rows' },
2020
+ visible.length === 0
2021
+ ? h('div', { className: 'dqs-emptyState' }, t('panel.empty'))
2022
+ : visible.map((entry) => {
2023
+ const row = rowOf(entry.id);
2024
+ const on = enabled.isEnabled(entry.id);
2025
+ const label = row?.label ?? labelOf(entry);
2026
+ const expandable = entry.action.id === 'toggle-permission';
2027
+ const expanded = expandedId === entry.id;
2028
+ return h(React.Fragment, { key: entry.id },
2029
+ h('div', { className: 'dqs-cmdRow', 'data-enabled': on ? 'true' : 'false' },
2030
+ h('span', { className: 'dqs-tile' }, h(CommandGlyph, { id: entry.action.id })),
2031
+ h('div', { className: 'dqs-cmdInfo' },
2032
+ h('div', { className: 'dqs-cmdName' }, label),
2033
+ h('div', { className: 'dqs-cmdImpl' }, entry.action.impl)),
2034
+ h('div', { className: 'dqs-cmdRight' },
2035
+ on ? h(KeyCaps, { row, t }) : h('span', { className: 'dqs-keycapNone' }, t('panel.disabled')),
2036
+ expandable
2037
+ ? h('button', {
2038
+ type: 'button',
2039
+ className: 'dqs-expandBtn',
2040
+ 'aria-expanded': expanded ? 'true' : 'false',
2041
+ title: t('perm.section'),
2042
+ 'aria-label': t('perm.section'),
2043
+ onClick: () => setExpandedId(expanded ? null : entry.id)
2044
+ }, h(ChevronIcon, { open: expanded }))
2045
+ : null,
2046
+ entry.custom
2047
+ ? h('button', {
2048
+ type: 'button',
2049
+ className: 'dqs-removeBtn',
2050
+ title: t('custom.remove'),
2051
+ 'aria-label': `${t('custom.remove')}: ${label}`,
2052
+ onClick: () => custom.remove(entry.uid)
2053
+ }, '\u2715')
2054
+ : null,
2055
+ h('button', {
2056
+ type: 'button',
2057
+ role: 'switch',
2058
+ className: 'dqs-switch',
2059
+ 'aria-checked': on ? 'true' : 'false',
2060
+ 'aria-label': `${on ? t('panel.enabled') : t('panel.disabled')}: ${label}`,
2061
+ onClick: () => enabled.toggle(entry.id)
2062
+ }))),
2063
+ expanded
2064
+ ? h(PermExpand, {
2065
+ t,
2066
+ catalog,
2067
+ catalogOptions,
2068
+ cycle,
2069
+ permCurrent,
2070
+ permIssue,
2071
+ permLabel,
2072
+ permCycle,
2073
+ setPermIssue
2074
+ })
2075
+ : null);
2076
+ })))
2077
+ : h(SystemTab, { ctx, t, query }));
2078
+ }
2079
+
2080
+ /* ------------------------------------------------------------------ */
2081
+ /* Custom commands: persisted user entries over the action catalog */
2082
+ /* ------------------------------------------------------------------ */
2083
+
2084
+ const CUSTOM_KEY = 'dsh.shortcut-ext.custom.v1';
2085
+
2086
+ function sanitizeBinding(value) {
2087
+ if (!value || typeof value !== 'object') return undefined;
2088
+ if (typeof value.code !== 'string' || !Array.isArray(value.modifiers)) return undefined;
2089
+ const modifiers = value.modifiers.filter((m) => ['primary', 'control', 'alt', 'shift', 'meta'].includes(m));
2090
+ if (modifiers.length === 0) return undefined;
2091
+ return { code: value.code, modifiers };
2092
+ }
2093
+
2094
+ function readCustom() {
2095
+ try {
2096
+ const parsed = JSON.parse(localStorage.getItem(CUSTOM_KEY));
2097
+ if (!Array.isArray(parsed)) return [];
2098
+ const out = [];
2099
+ for (const entry of parsed) {
2100
+ if (!entry || typeof entry.uid !== 'string') continue;
2101
+ const action = ACTION_BY_ID.get(entry.action);
2102
+ const binding = sanitizeBinding(entry.binding);
2103
+ if (!action || !binding) continue;
2104
+ const wantsParam = action.param === 'preset';
2105
+ const paramOk = !wantsParam || PRESET_LABEL_KEY[entry.param] !== undefined;
2106
+ if (!paramOk) continue;
2107
+ out.push({
2108
+ uid: entry.uid,
2109
+ action: entry.action,
2110
+ ...wantsParam ? { param: entry.param } : {},
2111
+ binding
2112
+ });
2113
+ }
2114
+ return out;
2115
+ } catch { return []; }
2116
+ }
2117
+
2118
+ function createCustomStore() {
2119
+ const base = createStore(readCustom());
2120
+ const persist = () => {
2121
+ try { localStorage.setItem(CUSTOM_KEY, JSON.stringify(base.getSnapshot())); } catch { /* private mode */ }
2122
+ };
2123
+ return {
2124
+ getSnapshot: base.getSnapshot,
2125
+ subscribe: base.subscribe,
2126
+ add(entry) { base.set([...base.getSnapshot(), entry]); persist(); },
2127
+ remove(uid) { base.set(base.getSnapshot().filter((e) => e.uid !== uid)); persist(); }
2128
+ };
2129
+ }
2130
+
2131
+ function makeUid() {
2132
+ return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2133
+ }
2134
+
2135
+ function bindingProfiles(binding, includeWeb) {
2136
+ const profiles = {
2137
+ 'desktop:macos': binding,
2138
+ 'desktop:windows': binding,
2139
+ 'desktop:linux': binding
2140
+ };
2141
+ if (includeWeb) {
2142
+ profiles['web:macos'] = binding;
2143
+ profiles['web:windows'] = binding;
2144
+ }
2145
+ return profiles;
2146
+ }
2147
+
2148
+ /** Built-in + custom entries as one registration table. */
2149
+ function commandEntries(customList) {
2150
+ const entries = COMMANDS.map((command) => {
2151
+ const action = ACTION_BY_ID.get(command.action);
2152
+ return {
2153
+ id: command.id,
2154
+ action,
2155
+ labelKey: action.labelKey,
2156
+ attempts: command.attempts
2157
+ };
2158
+ });
2159
+ for (const custom of customList) {
2160
+ const action = ACTION_BY_ID.get(custom.action);
2161
+ if (!action) continue;
2162
+ entries.push({
2163
+ id: `shortcut-ext.custom-${custom.uid}`,
2164
+ custom: true,
2165
+ uid: custom.uid,
2166
+ action,
2167
+ param: custom.param,
2168
+ labelKey: action.labelKey,
2169
+ paramKey: custom.param ? PRESET_LABEL_KEY[custom.param] : undefined,
2170
+ attempts: [
2171
+ bindingProfiles(custom.binding, true),
2172
+ bindingProfiles(custom.binding, false),
2173
+ {}
2174
+ ]
2175
+ });
2176
+ }
2177
+ return entries;
2178
+ }
2179
+
2180
+ /**
2181
+ * One effect owning every toggleable shortcut registration: an entry is
2182
+ * registered exactly while its deck switch is on; custom entries follow
2183
+ * the persisted user table. Each registration walks its candidate
2184
+ * binding sets until the registry accepts one.
2185
+ */
2186
+ function installCommands(ctx, deps, enabled, custom) {
2187
+ const disposers = new Map();
2188
+ const labelOf = (entry) => () => (entry.paramKey
2189
+ ? `${deps.t(entry.labelKey)}\uFF1A${deps.t(entry.paramKey)}`
2190
+ : deps.t(entry.labelKey));
2191
+ const build = (entry) => {
2192
+ const resolve = () => ({
2193
+ status: 'handled',
2194
+ run: () => { entry.action.run(deps, entry.param); }
2195
+ });
2196
+ for (const defaults of entry.attempts) {
2197
+ try {
2198
+ return ctx.shortcuts.register({
2199
+ id: entry.id,
2200
+ label: labelOf(entry),
2201
+ aliases: [entry.action.id.replace(/-/g, ' '), entry.id],
2202
+ regions: ['page', 'editable'],
2203
+ modals: [],
2204
+ defaults,
2205
+ resolve
2206
+ });
2207
+ } catch { /* try the next candidate binding set */ }
2208
+ }
2209
+ console.error(`[shortcut-ext] shortcut registration failed: ${entry.id}`);
2210
+ return () => {};
2211
+ };
2212
+ const sync = () => {
2213
+ const entries = commandEntries(custom.getSnapshot());
2214
+ const wanted = new Map(entries.map((entry) => [entry.id, entry]));
2215
+ for (const [id, dispose] of [...disposers]) {
2216
+ if (!wanted.has(id) || !enabled.isEnabled(id)) {
2217
+ try { dispose(); } catch { /* already disposed */ }
2218
+ disposers.delete(id);
2219
+ }
2220
+ }
2221
+ for (const entry of entries) {
2222
+ if (enabled.isEnabled(entry.id) && !disposers.has(entry.id)) {
2223
+ disposers.set(entry.id, build(entry));
2224
+ }
2225
+ }
2226
+ };
2227
+ sync();
2228
+ const offEnabled = enabled.subscribe(sync);
2229
+ const offCustom = custom.subscribe(() => {
2230
+ for (const [id, dispose] of [...disposers]) {
2231
+ if (id.startsWith('shortcut-ext.custom-')) {
2232
+ try { dispose(); } catch { /* already disposed */ }
2233
+ disposers.delete(id);
2234
+ }
2235
+ }
2236
+ sync();
2237
+ });
2238
+ return () => {
2239
+ offEnabled();
2240
+ offCustom();
2241
+ for (const dispose of disposers.values()) {
2242
+ try { dispose(); } catch { /* already disposed */ }
2243
+ }
2244
+ disposers.clear();
2245
+ };
2246
+ }
2247
+
2248
+ /* ------------------------------------------------------------------ */
2249
+ /* Plugin body */
2250
+ /* ------------------------------------------------------------------ */
2251
+
2252
+ return {
2253
+ inject: [
2254
+ 'slots',
2255
+ 'locale',
2256
+ 'sessions',
2257
+ 'workspaces',
2258
+ 'uiWorkspace',
2259
+ 'conversation',
2260
+ 'layout',
2261
+ 'shortcuts',
2262
+ 'remote',
2263
+ 'remote.permissionPresets',
2264
+ 'modelDirectories'
2265
+ ],
2266
+ apply(ctx) {
2267
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'shortcut-ext: dictionaries');
2268
+ const t = ctx.locale.bind(NS);
2269
+ const paletteOpen = createStore(false);
2270
+ const sessionPaletteOpen = createStore(false);
2271
+ const pins = createPinStore();
2272
+ const enabled = createEnabledStore();
2273
+ const custom = createCustomStore();
2274
+ const permCycle = createPermCycleStore();
2275
+ const hintStore = createStore(null);
2276
+ let hintSeq = 0;
2277
+ const hint = (text) => hintStore.set({ seq: ++hintSeq, text });
2278
+
2279
+ ctx.effect(() => {
2280
+ if (typeof document === 'undefined') return undefined;
2281
+ if (document.querySelector('style[data-dqs="1"]')) return undefined;
2282
+ const style = document.createElement('style');
2283
+ style.setAttribute('data-dqs', '1');
2284
+ style.textContent = CSS;
2285
+ document.head.appendChild(style);
2286
+ return () => { style.remove(); };
2287
+ }, 'shortcut-ext: styles');
2288
+
2289
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
2290
+ name: 'shell.overlay',
2291
+ id: 'shortcut-ext-palette',
2292
+ order: 40
2293
+ }, () => h(PaletteLayer, { ctx, t, openStore: paletteOpen, pins })));
2294
+
2295
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
2296
+ name: 'shell.overlay',
2297
+ id: 'shortcut-ext-sessions',
2298
+ order: 41
2299
+ }, () => h(SessionPaletteLayer, { ctx, t, openStore: sessionPaletteOpen })));
2300
+
2301
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
2302
+ name: 'shell.overlay',
2303
+ id: 'shortcut-ext-hint',
2304
+ order: 43
2305
+ }, () => h(HintBanner, { store: hintStore })));
2306
+
2307
+ ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
2308
+ name: 'sidebar.panellist',
2309
+ id: PANEL_ID,
2310
+ order: 50,
2311
+ label: () => t('panel.title')
2312
+ }, PanelIcon));
2313
+
2314
+ ctx.slots.inject('main', () => ctx.slots.register({
2315
+ name: 'main',
2316
+ key: PANEL_ID
2317
+ }, () => h(CommandDeckPanel, { ctx, t, enabled, custom, permCycle })));
2318
+
2319
+ ctx.effect(() => {
2320
+ const jumpTracker = createJumpTracker(ctx);
2321
+ const disposeCommands = installCommands(ctx, {
2322
+ ctx, t, paletteOpen, sessionPaletteOpen, jumpTracker, hint, permCycle
2323
+ }, enabled, custom);
2324
+ return () => {
2325
+ disposeCommands();
2326
+ jumpTracker.dispose();
2327
+ };
2328
+ }, 'shortcut-ext: shortcut commands');
2329
+ }
2330
+ };
2331
+ }
2332
+ });