@hellokidder/dsh-hotkey 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 0.0.2
4
+
5
+ - Use Control on every platform, including macOS; Command+N is no longer reserved and keeps opening a new session.
6
+ - Cycle conversations with Control+Tab / Control+Shift+Tab; Control+1-9 still jumps to a numbered conversation.
7
+ - Numbering and cycling follow the rendered sidebar order, so changing grouping, sorting, folding or the archive filter renumbers the visible rows; when no row is rendered, fall back to the service order.
8
+ - Limit hint collection to real controls (including buttons and links inside the chat transcript); skip generic tabindex containers and the turn-navigation rail.
9
+ - Hint codes are two letters: the first letter filters and highlights matches while other badges disappear, and the second letter clicks; Backspace rewinds the filter.
10
+ - Add Option+Enter to focus the message composer from anywhere, closing the active main panel first.
11
+ - Include the Lexical composer and menu/listbox portal items in hint scanning, and skip hidden ancestors via checkVisibility.
12
+ - Promote the hint layer to the browser top layer so its badges appear above portaled menus and dropdowns.
13
+ - Double-tap Option to toggle the clickable-control hint layer; every target is a compact letter pill matching the conversation number badges, with no outline boxes and native shortcut labels hidden for now. Control stays reserved for conversation numbering and rotation.
14
+ - Keep the capture-phase keyboard handling and IME-safe dispatch from 0.0.1.
15
+
16
+ ## 0.0.1
17
+
18
+ - First release.
19
+ - Hold Command (Control on Windows/Linux) to number the sidebar conversations.
20
+ - `Command+N` / `Command+Shift+N` cycle through conversations.
21
+ - `Command+1` through `Command+9` jump to a numbered conversation.
22
+ - Capture-phase keyboard handling that keeps working while an editor or IME has focus.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kidder
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,86 @@
1
- # Temporary Holding Version
1
+ # dsh-hotkey
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DSH 快捷键的统一入口。当前版本提供侧边栏对话切换;后续快捷键能力都集成在这个插件里。
4
+
5
+ npm:`@hellokidder/dsh-hotkey`
6
+
7
+ ## 已包含
8
+
9
+ - **按住 Control** 时,左侧每个对话行首显示 1…n 的序号;当前对话的序号使用品牌色高亮。macOS 上也是 **Control**,不是 Command。
10
+ - **Control + Tab** 切换到下一个对话,**Control + Shift + Tab** 切换到上一个,均循环。
11
+ - **Control + 1…9** 直接跳转到对应序号的对话。
12
+ - **Option + Enter** 从任意位置把光标聚焦到当前输入框;如果主面板(设置、Review 等)盖住了会话,会先关掉面板再聚焦。
13
+ - 编号和轮转顺序直接跟随**当前渲染出来的侧边栏行**:切换 Workspace 分组、In one list、Manual 排序、固定、折叠或归档过滤后,都会按你看到的行顺序重排。
14
+ - 归档会话、子代理会话和空的「新会话」行不参与编号。
15
+ - **双击 Option(⌥)** 打开或关闭点击提示层:整个窗口的可点击控件会在左上角显示一个字母胶囊,样式与侧边栏的 1…n 编号一致(原生快捷键暂时不展示,也不绘制外框)。提示层保持显示:每个控件显示两个字母的提示码,输入第一个字母会保留并高亮命中的提示、其余消失,输入第二个字母才点击对应控件;`Backspace` 回退,`Esc` 或点击任意位置关闭。Ctrl 只负责会话编号与轮转。
16
+ - 三个快捷键会出现在「设置 → 键盘快捷键」的只读分组中,便于查找。
17
+
18
+ ## 平台说明
19
+
20
+ - 会话编号与轮转统一使用 **Control**;`Command+N` 完全不受影响,仍然是「新建会话」。
21
+ - 字母提示层用 **双击 Option**(Windows/Linux 上是 Alt)打开/关闭;它和 Ctrl 的会话编号/轮转互不影响,也不会和 Option+字母输入特殊字符冲突。
22
+ - macOS 的 `Control+1…9` 可能被系统「切换桌面」占用。如果数字跳转没有反应,可以在 **系统设置 → 键盘 → 键盘快捷键 → 调度中心** 关闭对应的桌面切换快捷键;`Control+Tab` / `Control+Shift+Tab` 不受影响。
23
+ - 在 Chrome / Safari 里直接打开 Web GUI 时,`Control+Tab` / `Control+Shift+Tab` 会被浏览器标签页切换占用,`Control+1…9` 也可能被占用;桌面 App 更稳妥。
24
+
25
+ ## 安装
26
+
27
+ 从 npm 安装:
28
+
29
+ ```sh
30
+ dsh plugin --profile web add @hellokidder/dsh-hotkey
31
+ ```
32
+
33
+ 从本地目录安装(开发):
34
+
35
+ ```sh
36
+ dsh plugin --profile web add "link:/absolute/path/to/dsh-hotkey"
37
+ ```
38
+
39
+ 安装后在插件管理中启用;修改 `client.js` 后刷新一次页面即可加载,如果运行在带 `pnpm run dev:web` watcher 的 checkout 中则会自动重载。
40
+
41
+ ## 显示细节
42
+
43
+ - 编号占用每行标题左侧的 16px 座位;正在运行、等待审批或有未读完成状态的行会优先显示宿主的状态点,该行不显示编号。
44
+ - 顺序来自会话行上的 `data-row-key="session:<id>"`,按 DOM 中的视觉顺序编号。折叠分组和「显示更多」里尚未渲染的会话不参与编号与 `Control+Tab`;侧边栏收起或处于搜索态、读不到行时,会回退到服务端默认顺序(按 Workspace 分组 + 最近使用)。
45
+ - 这是一个 DOM 依赖:如果 DSH 升级改动了会话行的 `data-row-key`,编号会退回默认顺序,需要同步更新插件;插件不会修改侧边栏 DOM。
46
+ - 提示层只扫描真正的控件(原生可交互标签、Lexical/textarea 输入框,以及 button / link / tab / menuitem / textbox 等角色),覆盖侧边栏、输入区、顶栏、设置、右侧面板、对话正文里的按钮/链接,以及菜单/下拉项的 portal。右侧轮次缩略轴(turn rail)不参与;被 aria-hidden/inert 隐藏或不可见的元素会跳过。段落、消息行这类非控件容器不会生成提示。
47
+ - 会话行本身由 1…n 编号负责,不再重复生成字母提示;折叠、隐藏或禁用的控件不显示。
48
+ - 当前提示层只显示生成的字母提示,不绘制外框/背景;原生快捷键标签默认关闭,匹配逻辑保留在 `SHOW_NATIVE_SHORTCUTS` 常量后面,以后需要时可以重新打开。字母提示是插件生成的点击键,不依赖宿主的快捷键注册表;双击 Option 打开它,按住 Control 仍是对话编号与轮转。
49
+
50
+ ## 与「新建会话」的关系
51
+
52
+ 插件只使用 Control 组合,不占用任何 `Command`/`Ctrl + N`:
53
+
54
+ - `Command+N`(macOS)和宿主默认的 `Ctrl+N`(Windows/Linux)都保持原样,「新建会话」不受影响;
55
+ - 对话轮转使用 `Control+Tab` / `Control+Shift+Tab`,直达使用 `Control+1…9`;
56
+ - 固定快捷键仍然会出现在「设置 → 键盘快捷键」中,方便查看。
57
+
58
+ ## 扩展新快捷键
59
+
60
+ 1. 在 `client.js` 的 `apply` 中用 `ctx.shortcuts.registerFixed({ id, label, keys, bindings, group })` 声明固定组合,用来占键并让快捷键面板可见;需要用户可改键时改用 `ctx.shortcuts.register`(与宿主已有默认组合冲突时 `register` 会抛错)。
61
+ 2. 在 `window` 的**捕获阶段**监听 `keydown` 并分发动作,不要依赖 `observeFixedInput` 的 bubble 监听:编辑器和输入法可能提前 `stopPropagation`,中文输入法还会把 Shift 标记为组合态。带 Control 的组合是命令,不应被 `isComposing` 拦截。
62
+ 3. 需要随快捷键状态显示 UI 时,沿用当前 `store` + `React.useSyncExternalStore` 的模式,通过槽位渲染。
63
+ 4. 同步更新 `locale/en.json`、`locale/zh.json` 的插件标题与描述,以及 `client.js` 中的 `en`/`zh` 命令标签。
64
+ 5. 保存后刷新页面即可;如果运行在带 `pnpm run dev:web` watcher 的 checkout 中会自动重载。修改 `package.json` 或 patch 需要重新安装。
65
+
66
+ ## 开发
67
+
68
+ ```sh
69
+ git clone https://github.com/hellokidder/dsh-hotkey.git
70
+ cd dsh-hotkey
71
+ node --check client.js
72
+ ```
73
+
74
+ - `client.js` 是浏览器端模块(`window.__ModuleLoader__` 格式),不依赖构建工具。
75
+ - 插件清单与入口见 `package.json`、`cordis.patch.yml`,宿主半边 `index.js` 目前为空。
76
+
77
+ ## 发布
78
+
79
+ ```sh
80
+ npm publish --access public
81
+ git tag v0.0.2 && git push origin v0.0.2
82
+ ```
83
+
84
+ ## License
85
+
86
+ MIT
package/client.js ADDED
@@ -0,0 +1,835 @@
1
+ window.__ModuleLoader__.load({
2
+ id: '@hellokidder/dsh-hotkey',
3
+ factory(require) {
4
+ const React = require('react')
5
+ const h = React.createElement
6
+
7
+ const NS = 'dshHotkey'
8
+ const BADGE_CLASS = 'dsh-hotkey__number'
9
+ const BADGE_CURRENT_CLASS = 'dsh-hotkey__number--current'
10
+ const STYLE_ID = '@hellokidder/dsh-hotkey/badge.css'
11
+
12
+ const en = {
13
+ 'command.next': 'Next conversation',
14
+ 'command.previous': 'Previous conversation',
15
+ 'command.jump': 'Go to conversation 1-9',
16
+ 'command.focus': 'Focus message input',
17
+ 'hints.footer': 'Type hint letters to filter and click · Esc to close',
18
+ }
19
+ const zh = {
20
+ 'command.next': '下一个对话',
21
+ 'command.previous': '上一个对话',
22
+ 'command.jump': '跳转到对话 1-9',
23
+ 'command.focus': '聚焦输入框',
24
+ 'hints.footer': '输入字母筛选并点击 · Esc 关闭',
25
+ }
26
+
27
+ const CSS = `
28
+ .${BADGE_CLASS} {
29
+ display: inline-flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ flex: none;
33
+ width: 16px;
34
+ height: 16px;
35
+ border: 0.5px solid var(--dsw-alias-border-l1);
36
+ border-radius: 4px;
37
+ background: var(--dsw-alias-bg-layer-2);
38
+ color: var(--dsw-alias-label-secondary);
39
+ font-size: 9px;
40
+ font-weight: 600;
41
+ line-height: 1;
42
+ font-variant-numeric: tabular-nums;
43
+ letter-spacing: -0.02em;
44
+ pointer-events: none;
45
+ user-select: none;
46
+ }
47
+ .${BADGE_CURRENT_CLASS} {
48
+ border-color: transparent;
49
+ background: var(--dsw-alias-brand-primary);
50
+ color: var(--dsw-alias-bg-base);
51
+ }
52
+ .dsh-hotkey-hints {
53
+ position: fixed;
54
+ inset: 0;
55
+ width: 100vw;
56
+ height: 100vh;
57
+ max-width: none;
58
+ max-height: none;
59
+ margin: 0;
60
+ padding: 0;
61
+ border: 0;
62
+ background: transparent;
63
+ color: inherit;
64
+ overflow: visible;
65
+ box-sizing: border-box;
66
+ pointer-events: none;
67
+ font-family: inherit;
68
+ }
69
+ .dsh-hotkey-hints__badge {
70
+ position: fixed;
71
+ display: inline-flex;
72
+ align-items: center;
73
+ justify-content: center;
74
+ min-width: 16px;
75
+ height: 16px;
76
+ padding: 0 3px;
77
+ border: 0.5px solid var(--dsw-alias-border-l1, rgba(255, 255, 255, 0.12));
78
+ border-radius: 4px;
79
+ background: var(--dsw-alias-bg-layer-2, #262a31);
80
+ color: var(--dsw-alias-label-secondary, #c9cdd4);
81
+ font-family: inherit;
82
+ font-size: 9px;
83
+ font-weight: 600;
84
+ line-height: 1;
85
+ letter-spacing: -0.02em;
86
+ text-transform: lowercase;
87
+ white-space: nowrap;
88
+ pointer-events: none;
89
+ user-select: none;
90
+ transition: background-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
91
+ }
92
+ .dsh-hotkey-hints__badge--match {
93
+ border-color: transparent;
94
+ background: var(--dsw-alias-brand-primary, #e8eaed);
95
+ color: var(--dsw-alias-bg-base, #15171c);
96
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #e8eaed) 35%, transparent);
97
+ }
98
+ .dsh-hotkey-hints__footer {
99
+ position: fixed;
100
+ left: 50%;
101
+ bottom: 14px;
102
+ transform: translateX(-50%);
103
+ padding: 4px 10px;
104
+ border: 1px solid var(--dsw-alias-border-l1, #3a3f46);
105
+ border-radius: 6px;
106
+ background: var(--dsw-alias-bg-overlay, #1f2329);
107
+ color: var(--dsw-alias-label-secondary, #c9cdd4);
108
+ max-width: calc(100vw - 24px);
109
+ overflow: hidden;
110
+ font-size: 11px;
111
+ line-height: 16px;
112
+ white-space: nowrap;
113
+ text-overflow: ellipsis;
114
+ pointer-events: none;
115
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
116
+ }
117
+ `
118
+
119
+ function createStore(initial) {
120
+ let snapshot = initial
121
+ const listeners = new Set()
122
+ return {
123
+ getSnapshot: () => snapshot,
124
+ subscribe: (listener) => {
125
+ listeners.add(listener)
126
+ return () => {
127
+ listeners.delete(listener)
128
+ }
129
+ },
130
+ publish(patch) {
131
+ let changed = false
132
+ for (const key of Object.keys(patch)) {
133
+ if (!Object.is(snapshot[key], patch[key])) {
134
+ changed = true
135
+ break
136
+ }
137
+ }
138
+ if (!changed) return
139
+ snapshot = { ...snapshot, ...patch }
140
+ for (const listener of [...listeners]) listener()
141
+ },
142
+ }
143
+ }
144
+
145
+ /** Newest first, with the Session id as a deterministic tie-break. */
146
+ function orderByRecency(ids, byId) {
147
+ return ids
148
+ .map((id) => ({ id, rank: byId[id] === undefined ? 0 : byId[id].updatedAt }))
149
+ .sort((left, right) => (left.rank !== right.rank ? right.rank - left.rank : left.id < right.id ? -1 : 1))
150
+ .map((entry) => entry.id)
151
+ }
152
+
153
+ /**
154
+ * Reproduce the sidebar's default browsing order: Workspace groups in Host
155
+ * order, pinned rows before ordinary rows inside each group, then newest
156
+ * first. Only rows the sidebar can show are numbered: no archived Session,
157
+ * no subagent Session, and no blank New Session placeholder.
158
+ */
159
+ function deriveOrder(list, workspaceState) {
160
+ const byId = list.byId === undefined ? {} : list.byId
161
+ const state = workspaceState === undefined || workspaceState === null ? {} : workspaceState
162
+ const archived = new Set(state.archivedSessionIds === undefined ? [] : state.archivedSessionIds)
163
+ const pinned = new Set(state.pinnedSessionIds === undefined ? [] : state.pinnedSessionIds)
164
+ const workspaces = Array.isArray(state.items) ? state.items : []
165
+ const ids = Array.isArray(list.ids) ? list.ids : []
166
+ const ordered = []
167
+ const indexById = {}
168
+ const accounted = new Set()
169
+
170
+ const visible = (session) => session !== undefined
171
+ && session.origin !== 'subagent'
172
+ && session.blank !== true
173
+ && !archived.has(session.id)
174
+
175
+ const pushSection = (memberIds) => {
176
+ const recency = orderByRecency(memberIds, byId)
177
+ const section = [
178
+ ...recency.filter((id) => pinned.has(id)),
179
+ ...recency.filter((id) => !pinned.has(id)),
180
+ ]
181
+ for (const id of section) {
182
+ if (Object.hasOwn(indexById, id)) continue
183
+ indexById[id] = ordered.length + 1
184
+ ordered.push(id)
185
+ }
186
+ }
187
+
188
+ for (const workspace of workspaces) {
189
+ const memberIds = Array.isArray(workspace.sessionIds) ? workspace.sessionIds : []
190
+ const visibleIds = []
191
+ for (const id of memberIds) {
192
+ accounted.add(id)
193
+ if (visible(byId[id])) visibleIds.push(id)
194
+ }
195
+ pushSection(visibleIds)
196
+ }
197
+
198
+ pushSection(ids.filter((id) => !accounted.has(id) && visible(byId[id])))
199
+
200
+ let currentId
201
+ for (const id of ids) {
202
+ const session = byId[id]
203
+ if (session !== undefined && (session.retainedBy === undefined ? 0 : session.retainedBy.mainView ?? 0) > 0) {
204
+ currentId = id
205
+ break
206
+ }
207
+ }
208
+
209
+ return { ordered, indexById, currentId }
210
+ }
211
+
212
+ /** Read the Session rows the sidebar actually rendered, in visual order. */
213
+ function readRenderedSessionIds() {
214
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return []
215
+ const nodes = document.querySelectorAll('[data-row-key^="session:"]')
216
+ const ids = []
217
+ const seen = new Set()
218
+ for (const node of nodes) {
219
+ const key = node.getAttribute('data-row-key')
220
+ if (key === null) continue
221
+ const id = key.slice('session:'.length)
222
+ if (id === '' || seen.has(id)) continue
223
+ seen.add(id)
224
+ ids.push(id)
225
+ }
226
+ return ids
227
+ }
228
+
229
+ /**
230
+ * Prefer the rendered rows so numbering follows the user's current
231
+ * grouping, sorting, pinning, folding and archive filter. When no row is
232
+ * rendered (rail, search, early boot), fall back to the service order.
233
+ */
234
+ function deriveVisibleOrder(list, workspaceState) {
235
+ const fallback = deriveOrder(list, workspaceState)
236
+ const rendered = readRenderedSessionIds()
237
+ if (rendered.length === 0) return fallback
238
+ const state = workspaceState === undefined || workspaceState === null ? {} : workspaceState
239
+ const archived = new Set(state.archivedSessionIds === undefined ? [] : state.archivedSessionIds)
240
+ const byId = list.byId === undefined ? {} : list.byId
241
+ const ordered = []
242
+ const indexById = {}
243
+ for (const id of rendered) {
244
+ const session = byId[id]
245
+ if (session === undefined) continue
246
+ if (session.origin === 'subagent') continue
247
+ if (session.blank === true) continue
248
+ if (archived.has(id)) continue
249
+ indexById[id] = ordered.length + 1
250
+ ordered.push(id)
251
+ }
252
+ if (ordered.length === 0) return fallback
253
+ return { ordered, indexById, currentId: fallback.currentId }
254
+ }
255
+
256
+ const HINT_INTERACTIVE = 'button, a[href], input:not([type="hidden"]), select, textarea, summary, [data-composer-input], [data-lexical-editor="true"], [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="treeitem"], [role="textbox"], [role="searchbox"], [contenteditable]:not([contenteditable="false"])'
257
+ const HINT_LIMIT = 80
258
+ const HINT_GENERIC_LABELS = new Set(['close', 'more', 'copy', 'send', 'stop', 'ok', 'cancel', 'back', 'next', 'previous', 'delete', 'rename'])
259
+ const HINT_NATIVE_TAGS = new Set(['BUTTON', 'A', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY'])
260
+ // Native shortcut labels are intentionally hidden for now; flip this to
261
+ // true to surface the registered key next to the generated letter again.
262
+ const SHOW_NATIVE_SHORTCUTS = false
263
+
264
+ function normalizeHintLabel(value) {
265
+ if (typeof value !== 'string') return ''
266
+ return value
267
+ .toLowerCase()
268
+ .replace(/[\s\u00a0]+/gu, ' ')
269
+ .replace(/[.。,,::;;!!??…]+$/gu, '')
270
+ .trim()
271
+ }
272
+
273
+ function readElementNames(element) {
274
+ const names = []
275
+ const add = (value) => {
276
+ const normalized = normalizeHintLabel(value)
277
+ if (normalized !== '' && !names.includes(normalized)) names.push(normalized)
278
+ }
279
+ add(element.getAttribute('aria-label'))
280
+ const labelledBy = element.getAttribute('aria-labelledby')
281
+ if (labelledBy !== null && typeof document !== 'undefined') {
282
+ for (const id of labelledBy.split(/\s+/u)) {
283
+ if (id === '') continue
284
+ add(document.getElementById(id)?.textContent)
285
+ }
286
+ }
287
+ add(element.getAttribute('title'))
288
+ add(element.getAttribute('placeholder'))
289
+ if (element.tagName === 'IMG') add(element.getAttribute('alt'))
290
+ if (names.length === 0) add((element.textContent ?? '').slice(0, 80))
291
+ return names
292
+ }
293
+
294
+ function buildShortcutIndex(catalog, fixedCatalog) {
295
+ const index = []
296
+ for (const row of catalog) {
297
+ if (row.binding === null || !Array.isArray(row.keys) || row.keys.length === 0) continue
298
+ if (row.issue !== null && row.issue !== undefined) continue
299
+ if (Array.isArray(row.conflicts) && row.conflicts.length > 0) continue
300
+ const labels = [row.label, ...(Array.isArray(row.aliases) ? row.aliases : [])]
301
+ .map(normalizeHintLabel)
302
+ .filter((label) => label !== '')
303
+ if (labels.length === 0) continue
304
+ index.push({ labels, keys: row.keys.join(' ') })
305
+ }
306
+ for (const row of fixedCatalog) {
307
+ if (!Array.isArray(row.keys) || row.keys.length === 0) continue
308
+ const label = normalizeHintLabel(row.label)
309
+ if (label === '') continue
310
+ index.push({ labels: [label], keys: row.keys.join(' ') })
311
+ }
312
+ return index
313
+ }
314
+
315
+ function matchShortcut(index, names) {
316
+ for (const name of names) {
317
+ if (name.length < 4 || HINT_GENERIC_LABELS.has(name)) continue
318
+ for (const row of index) {
319
+ if (row.labels.includes(name)) return row
320
+ }
321
+ }
322
+ return undefined
323
+ }
324
+
325
+ function assignHintCodes(count) {
326
+ const codes = []
327
+ for (let position = 0; position < count; position++) {
328
+ codes.push(String.fromCharCode(97 + (position % 26)) + String.fromCharCode(97 + Math.floor(position / 26)))
329
+ }
330
+ return codes
331
+ }
332
+
333
+ /**
334
+ * The transcript's turn-navigation minimap is a narrow nav stacked with
335
+ * `data-index` mark buttons. Its marks are far too dense for hints.
336
+ */
337
+ function isTurnRail(element) {
338
+ const nav = element.closest('nav')
339
+ if (nav === null || nav.getAttribute('aria-label') === null) return false
340
+ if (typeof nav.getBoundingClientRect !== 'function' || typeof nav.querySelectorAll !== 'function') return false
341
+ if (nav.getBoundingClientRect().width > 80) return false
342
+ return nav.querySelectorAll('button[data-index]').length >= 2
343
+ }
344
+
345
+ /** The visible composer editor, preferring the largest rendered one. */
346
+ function findComposer() {
347
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return null
348
+ const nodes = document.querySelectorAll('[data-composer-input], [data-lexical-editor="true"][contenteditable]:not([contenteditable="false"]), [role="textbox"][aria-multiline="true"]')
349
+ let best = null
350
+ let bestArea = 0
351
+ for (const node of nodes) {
352
+ if (typeof node.getBoundingClientRect !== 'function') continue
353
+ const rect = node.getBoundingClientRect()
354
+ if (rect.width < 20 || rect.height < 10) continue
355
+ if (typeof node.checkVisibility === 'function') {
356
+ try {
357
+ if (!node.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) continue
358
+ } catch (_ignored) {}
359
+ }
360
+ const area = rect.width * rect.height
361
+ if (area > bestArea) {
362
+ best = node
363
+ bestArea = area
364
+ }
365
+ }
366
+ return best
367
+ }
368
+
369
+ /**
370
+ * Promote the hint layer to the browser's top layer so its badges stay
371
+ * above portaled menus and popovers.
372
+ */
373
+ function showHintPopover(node) {
374
+ if (node === null || node === undefined) return
375
+ try {
376
+ node.setAttribute('popover', 'manual')
377
+ if (typeof node.showPopover === 'function') node.showPopover()
378
+ } catch (_ignored) {}
379
+ }
380
+
381
+ function collectHintCandidates(shortcutIndex) {
382
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return []
383
+ if (typeof window === 'undefined') return []
384
+ const viewportWidth = window.innerWidth
385
+ const viewportHeight = window.innerHeight
386
+ const candidates = []
387
+ const seen = new Set()
388
+ for (const element of document.querySelectorAll(HINT_INTERACTIVE)) {
389
+ if (seen.has(element)) continue
390
+ seen.add(element)
391
+ if (typeof element.getBoundingClientRect !== 'function'
392
+ || typeof element.getAttribute !== 'function'
393
+ || typeof element.matches !== 'function'
394
+ || typeof element.closest !== 'function') continue
395
+ if (element.matches('[data-row-key^="session:"]')) continue
396
+ if (element.closest('[data-dsh-hotkey-overlay]') !== null) continue
397
+ if (isTurnRail(element)) continue
398
+ // Menu and listbox portals can sit under an aria-hidden/inert app shell
399
+ // while still being the visible interactive layer.
400
+ const inMenu = element.closest('[role="menu"], [role="listbox"]') !== null
401
+ const menuItem = element.matches('[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"]')
402
+ if (!inMenu && !menuItem && element.closest('[inert]') !== null) continue
403
+ if (!inMenu && !menuItem && element.closest('[aria-hidden="true"]') !== null) continue
404
+ if (element.tagName === 'BODY' || element.tagName === 'HTML') continue
405
+ if (element.disabled === true || element.getAttribute('aria-disabled') === 'true') continue
406
+ if (typeof element.checkVisibility === 'function') {
407
+ try {
408
+ if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) continue
409
+ } catch (_ignored) {}
410
+ }
411
+ if (typeof window.getComputedStyle === 'function') {
412
+ const style = window.getComputedStyle(element)
413
+ if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') continue
414
+ if (Number(style.opacity) === 0) continue
415
+ if (style.pointerEvents === 'none' && element.tabIndex < 0) continue
416
+ }
417
+ const rect = element.getBoundingClientRect()
418
+ if (rect.width < 2 || rect.height < 2) continue
419
+ if (rect.bottom < 0 || rect.right < 0 || rect.top > viewportHeight || rect.left > viewportWidth) continue
420
+ if (!HINT_NATIVE_TAGS.has(element.tagName)
421
+ && rect.width * rect.height > viewportWidth * viewportHeight * 0.2) continue
422
+ const shortcut = shortcutIndex === null || shortcutIndex === undefined
423
+ ? undefined
424
+ : matchShortcut(shortcutIndex, readElementNames(element))
425
+ candidates.push({
426
+ element,
427
+ left: rect.left,
428
+ top: rect.top,
429
+ keys: shortcut === undefined ? '' : shortcut.keys,
430
+ })
431
+ }
432
+ candidates.sort((left, right) => (left.top !== right.top ? left.top - right.top : left.left - right.left))
433
+ return candidates.slice(0, HINT_LIMIT)
434
+ }
435
+
436
+ return {
437
+ inject: ['slots', 'locale', 'shortcuts', 'sessions', 'workspaces', 'uiWorkspace'],
438
+ apply(ctx) {
439
+ ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-hotkey: dictionaries')
440
+ const t = ctx.locale.bind(NS)
441
+
442
+ const initial = deriveVisibleOrder(ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot())
443
+ const store = createStore({ held: false, ...initial })
444
+
445
+ const refreshOrder = () => {
446
+ store.publish(deriveVisibleOrder(ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot()))
447
+ }
448
+
449
+ const hintStore = createStore({ active: false, hints: [] })
450
+
451
+ function HintOverlay() {
452
+ const snapshot = React.useSyncExternalStore(hintStore.subscribe, hintStore.getSnapshot)
453
+ if (!snapshot.active) return null
454
+ return h('div', {
455
+ className: 'dsh-hotkey-hints',
456
+ 'data-dsh-hotkey-overlay': 'true',
457
+ ref: showHintPopover,
458
+ },
459
+ h('div', { className: 'dsh-hotkey-hints__footer' }, t('hints.footer')),
460
+ snapshot.hints.map((hint) => h('div', {
461
+ key: `badge-${hint.code}`,
462
+ className: hint.matched
463
+ ? 'dsh-hotkey-hints__badge dsh-hotkey-hints__badge--match'
464
+ : 'dsh-hotkey-hints__badge',
465
+ style: { left: hint.left + 4, top: hint.top + 4 },
466
+ }, hint.code)),
467
+ )
468
+ }
469
+
470
+ function SwitcherBadge({ sessionId }) {
471
+ const snapshot = React.useSyncExternalStore(store.subscribe, store.getSnapshot)
472
+ const number = snapshot.held ? snapshot.indexById[sessionId] : 0
473
+ if (!number) return null
474
+ return h('span', {
475
+ className: snapshot.currentId === sessionId
476
+ ? `${BADGE_CLASS} ${BADGE_CURRENT_CLASS}`
477
+ : BADGE_CLASS,
478
+ 'aria-hidden': 'true',
479
+ }, String(number))
480
+ }
481
+
482
+ ctx.effect(() => {
483
+ const style = document.createElement('style')
484
+ style.setAttribute('data-plugin-css', STYLE_ID)
485
+ style.textContent = CSS
486
+ document.head.appendChild(style)
487
+ return () => {
488
+ style.remove()
489
+ }
490
+ }, 'dsh-hotkey: badge styles')
491
+
492
+ ctx.effect(() => {
493
+ const offSessions = ctx.sessions.list.subscribe(refreshOrder)
494
+ const offWorkspaces = ctx.workspaces.list.subscribe(refreshOrder)
495
+ refreshOrder()
496
+ return () => {
497
+ offSessions()
498
+ offWorkspaces()
499
+ }
500
+ }, 'dsh-hotkey: switch order')
501
+
502
+ // Numbers follow Control on every platform, macOS included.
503
+ ctx.effect(() => {
504
+ const sync = (event) => {
505
+ if (event.ctrlKey) refreshOrder()
506
+ store.publish({ held: event.ctrlKey })
507
+ }
508
+ const clear = () => store.publish({ held: false })
509
+ const onVisibility = () => {
510
+ if (document.visibilityState === 'hidden') clear()
511
+ }
512
+ window.addEventListener('keydown', sync, true)
513
+ window.addEventListener('keyup', sync, true)
514
+ window.addEventListener('blur', clear)
515
+ document.addEventListener('visibilitychange', onVisibility)
516
+ return () => {
517
+ window.removeEventListener('keydown', sync, true)
518
+ window.removeEventListener('keyup', sync, true)
519
+ window.removeEventListener('blur', clear)
520
+ document.removeEventListener('visibilitychange', onVisibility)
521
+ }
522
+ }, 'dsh-hotkey: modifier state')
523
+
524
+ // Double-tap Option (Alt) to toggle the clickable-control hint layer.
525
+ // Control stays reserved for conversation numbering and rotation.
526
+ ctx.effect(() => {
527
+ const DOUBLE_TAP_MS = 350
528
+ let active = false
529
+ let lastAltUp = 0
530
+ let altClean = true
531
+ let frame
532
+ let buffer = ''
533
+ let entries = []
534
+
535
+ const close = () => {
536
+ if (frame !== undefined) frame = undefined
537
+ buffer = ''
538
+ entries = []
539
+ if (active) {
540
+ active = false
541
+ hintStore.publish({ active: false, hints: [] })
542
+ }
543
+ }
544
+
545
+ const open = () => {
546
+ const index = SHOW_NATIVE_SHORTCUTS
547
+ ? buildShortcutIndex(
548
+ ctx.shortcuts.catalog.getSnapshot(),
549
+ ctx.shortcuts.fixedCatalog.getSnapshot(),
550
+ )
551
+ : null
552
+ const candidates = collectHintCandidates(index)
553
+ if (candidates.length === 0) {
554
+ if (active) close()
555
+ return
556
+ }
557
+ const codes = assignHintCodes(candidates.length)
558
+ entries = candidates.map((candidate, position) => ({
559
+ element: candidate.element,
560
+ code: codes[position],
561
+ keys: candidate.keys,
562
+ left: candidate.left,
563
+ top: candidate.top,
564
+ }))
565
+ buffer = ''
566
+ active = true
567
+ hintStore.publish({
568
+ active: true,
569
+ hints: entries.map(({ element, ...hint }) => ({ ...hint, matched: false })),
570
+ })
571
+ }
572
+
573
+ const toggle = () => {
574
+ if (active) close()
575
+ else open()
576
+ }
577
+
578
+ const clickTarget = (element) => {
579
+ try {
580
+ element.click()
581
+ } catch (_error) {
582
+ try {
583
+ element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }))
584
+ } catch (_ignored) {}
585
+ }
586
+ }
587
+
588
+ const handleHintKey = (letter) => {
589
+ buffer += letter
590
+ const matches = entries.filter((entry) => entry.code.startsWith(buffer))
591
+ if (matches.length === 0) {
592
+ close()
593
+ return
594
+ }
595
+ const exact = matches.find((entry) => entry.code === buffer)
596
+ if (exact !== undefined) {
597
+ const element = exact.element
598
+ buffer = ''
599
+ close()
600
+ clickTarget(element)
601
+ return
602
+ }
603
+ hintStore.publish({
604
+ active: true,
605
+ hints: matches.map(({ element, ...hint }) => ({ ...hint, matched: true })),
606
+ })
607
+ }
608
+
609
+ const onKeyDown = (event) => {
610
+ if (event.repeat) return
611
+ const altEnter = event.code === 'Enter' && event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey
612
+ if (altEnter) {
613
+ const composer = findComposer()
614
+ const layout = ctx.get('layout')
615
+ if (composer !== null && document.activeElement === composer) return
616
+ if (composer === null && layout === undefined) return
617
+ event.preventDefault()
618
+ event.stopPropagation()
619
+ if (active) close()
620
+ const focusComposer = () => {
621
+ const target = findComposer()
622
+ if (target !== null) target.focus({ preventScroll: true })
623
+ }
624
+ if (layout !== undefined) {
625
+ try {
626
+ layout.selectPanel(null)
627
+ } catch (_ignored) {}
628
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(focusComposer)
629
+ else setTimeout(focusComposer, 0)
630
+ } else {
631
+ focusComposer()
632
+ }
633
+ return
634
+ }
635
+ const alt = event.code === 'AltLeft' || event.code === 'AltRight'
636
+ if (alt) {
637
+ if (event.ctrlKey || event.metaKey || event.shiftKey) return
638
+ const now = Date.now()
639
+ if (altClean && lastAltUp !== 0 && now - lastAltUp <= DOUBLE_TAP_MS) {
640
+ lastAltUp = 0
641
+ altClean = false
642
+ toggle()
643
+ }
644
+ return
645
+ }
646
+ if (/^(Control|Shift|Meta)(Left|Right)$/u.test(event.code)) return
647
+ altClean = false
648
+ lastAltUp = 0
649
+ if (!active) return
650
+ // Any other chord closes the layer and proceeds; Control keeps
651
+ // driving conversation rotation.
652
+ if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
653
+ close()
654
+ return
655
+ }
656
+ if (event.code === 'Escape') {
657
+ event.preventDefault()
658
+ event.stopPropagation()
659
+ close()
660
+ return
661
+ }
662
+ if (event.code === 'Backspace' && buffer !== '') {
663
+ event.preventDefault()
664
+ event.stopPropagation()
665
+ buffer = buffer.slice(0, -1)
666
+ const matches = entries.filter((entry) => entry.code.startsWith(buffer))
667
+ if (matches.length === 0) close()
668
+ else {
669
+ hintStore.publish({
670
+ active: true,
671
+ hints: matches.map(({ element, ...hint }) => ({ ...hint, matched: buffer !== '' })),
672
+ })
673
+ }
674
+ return
675
+ }
676
+ if (/^Key[A-Z]$/u.test(event.code)) {
677
+ event.preventDefault()
678
+ event.stopPropagation()
679
+ handleHintKey(event.code.slice(3).toLowerCase())
680
+ return
681
+ }
682
+ close()
683
+ }
684
+
685
+ const onKeyUp = (event) => {
686
+ if (event.code !== 'AltLeft' && event.code !== 'AltRight') return
687
+ lastAltUp = Date.now()
688
+ altClean = true
689
+ }
690
+
691
+ const onViewport = () => {
692
+ if (!active || frame !== undefined) return
693
+ const run = () => {
694
+ frame = undefined
695
+ if (active) open()
696
+ }
697
+ frame = typeof requestAnimationFrame === 'function'
698
+ ? requestAnimationFrame(run)
699
+ : setTimeout(run, 16)
700
+ }
701
+
702
+ const onPointerDown = () => close()
703
+ const onBlur = () => close()
704
+ const onVisibility = () => {
705
+ if (document.visibilityState === 'hidden') close()
706
+ }
707
+
708
+ window.addEventListener('keydown', onKeyDown, true)
709
+ window.addEventListener('keyup', onKeyUp, true)
710
+ window.addEventListener('blur', onBlur)
711
+ window.addEventListener('pointerdown', onPointerDown, true)
712
+ window.addEventListener('scroll', onViewport, true)
713
+ window.addEventListener('resize', onViewport)
714
+ document.addEventListener('visibilitychange', onVisibility)
715
+ return () => {
716
+ close()
717
+ window.removeEventListener('keydown', onKeyDown, true)
718
+ window.removeEventListener('keyup', onKeyUp, true)
719
+ window.removeEventListener('blur', onBlur)
720
+ window.removeEventListener('pointerdown', onPointerDown, true)
721
+ window.removeEventListener('scroll', onViewport, true)
722
+ window.removeEventListener('resize', onViewport)
723
+ document.removeEventListener('visibilitychange', onVisibility)
724
+ }
725
+ }, 'dsh-hotkey: hint overlay')
726
+
727
+ const activate = (sessionId) => {
728
+ if (sessionId !== undefined) ctx.uiWorkspace.openSession(sessionId)
729
+ }
730
+
731
+ const step = (delta) => {
732
+ const { ordered, currentId } = store.getSnapshot()
733
+ if (ordered.length === 0) return
734
+ const at = currentId === undefined ? -1 : ordered.indexOf(currentId)
735
+ const target = at === -1
736
+ ? (delta > 0 ? 0 : ordered.length - 1)
737
+ : (at + delta + ordered.length) % ordered.length
738
+ const nextId = ordered[target]
739
+ if (nextId !== currentId) activate(nextId)
740
+ }
741
+
742
+ const jump = (number) => {
743
+ const { ordered, currentId } = store.getSnapshot()
744
+ const target = ordered[number - 1]
745
+ if (target !== undefined && target !== currentId) activate(target)
746
+ }
747
+
748
+ ctx.effect(() => {
749
+ const nextBinding = { code: 'Tab', modifiers: ['control'] }
750
+ const previousBinding = { code: 'Tab', modifiers: ['control', 'shift'] }
751
+ const jumpBindings = []
752
+ for (let number = 1; number <= 9; number++) {
753
+ jumpBindings.push({ code: `Digit${number}`, modifiers: ['control'] })
754
+ }
755
+ const jumpKeys = ctx.shortcuts.describeBinding({ code: 'Digit1', modifiers: ['control'] }).keys
756
+ const disposers = [
757
+ ctx.shortcuts.registerFixed({
758
+ id: 'dsh-hotkey.next',
759
+ label: () => t('command.next'),
760
+ keys: ctx.shortcuts.describeBinding(nextBinding).keys,
761
+ bindings: [nextBinding],
762
+ group: 'application',
763
+ }),
764
+ ctx.shortcuts.registerFixed({
765
+ id: 'dsh-hotkey.previous',
766
+ label: () => t('command.previous'),
767
+ keys: ctx.shortcuts.describeBinding(previousBinding).keys,
768
+ bindings: [previousBinding],
769
+ group: 'application',
770
+ }),
771
+ ctx.shortcuts.registerFixed({
772
+ id: 'dsh-hotkey.jump',
773
+ label: () => t('command.jump'),
774
+ keys: [...jumpKeys.slice(0, -1), '1-9'],
775
+ bindings: jumpBindings,
776
+ group: 'application',
777
+ }),
778
+ ctx.shortcuts.registerFixed({
779
+ id: 'dsh-hotkey.focus',
780
+ label: () => t('command.focus'),
781
+ keys: ctx.shortcuts.describeBinding({ code: 'Enter', modifiers: ['alt'] }).keys,
782
+ bindings: [{ code: 'Enter', modifiers: ['alt'] }],
783
+ group: 'application',
784
+ }),
785
+ ]
786
+ return () => {
787
+ for (const dispose of disposers) dispose()
788
+ }
789
+ }, 'dsh-hotkey: fixed shortcuts')
790
+
791
+ // Capture phase: own the combination before editors, terminals, and
792
+ // modal controls can stop its propagation. The fixed registrations above
793
+ // reserve the Control chords and keep them visible in the Keyboard
794
+ // Shortcuts reference; Command+N stays with New Session.
795
+ ctx.effect(() => {
796
+ const onKeyDown = (event) => {
797
+ if (event.defaultPrevented || event.repeat) return
798
+ // Do not consult isComposing/keyCode 229 here: Chinese IMEs report
799
+ // Shift as a composition key, which would swallow Control+Shift+Tab.
800
+ // A Control chord is a command, not text input.
801
+ const primary = event.ctrlKey === true && event.metaKey !== true
802
+ if (!primary || event.altKey === true) return
803
+ if (event.code === 'Tab') {
804
+ event.preventDefault()
805
+ event.stopPropagation()
806
+ step(event.shiftKey === true ? -1 : 1)
807
+ return
808
+ }
809
+ if (event.shiftKey !== true && /^Digit[1-9]$/u.test(event.code)) {
810
+ event.preventDefault()
811
+ event.stopPropagation()
812
+ jump(Number(event.code.slice('Digit'.length)))
813
+ }
814
+ }
815
+ window.addEventListener('keydown', onKeyDown, true)
816
+ return () => {
817
+ window.removeEventListener('keydown', onKeyDown, true)
818
+ }
819
+ }, 'dsh-hotkey: keyboard')
820
+
821
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
822
+ name: 'shell.overlay',
823
+ id: 'dsh-hotkey.hints',
824
+ order: 100,
825
+ }, HintOverlay))
826
+
827
+ ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({
828
+ name: 'sidebar.session.row.leading',
829
+ id: 'dsh-hotkey.number',
830
+ order: 20,
831
+ }, SwitcherBadge))
832
+ },
833
+ }
834
+ },
835
+ })
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-hotkey
3
+ name: '@hellokidder/dsh-hotkey'
package/icon.svg ADDED
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" fill="none">
2
+ <rect width="64" height="64" rx="14" fill="#1b1e24"/>
3
+ <rect x="12" y="13" width="9" height="9" rx="2.5" fill="#5b8def"/>
4
+ <rect x="26" y="15.5" width="26" height="4" rx="2" fill="#8fb3ff"/>
5
+ <rect x="12" y="27.5" width="9" height="9" rx="2.5" fill="#8fb3ff"/>
6
+ <rect x="26" y="30" width="26" height="4" rx="2" fill="#c3d4ff"/>
7
+ <rect x="12" y="42" width="9" height="9" rx="2.5" fill="#c3d4ff"/>
8
+ <rect x="26" y="44.5" width="26" height="4" rx="2" fill="#e2eafc"/>
9
+ </svg>
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Host half of the dsh-hotkey bundle; all behavior lives in the Client module. */
2
+ export function apply() {}
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "DSH Hotkey",
4
+ "description": "One home for DSH keyboard shortcuts. Hold Control to number and cycle conversations; double-tap Option to reveal letter hints; Option+Enter focuses the composer."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "DSH 快捷键",
4
+ "description": "DSH 快捷键的统一入口。按住 Control 显示侧边栏对话编号并轮转;双击 Option 显示字母提示;Option+Enter 聚焦输入框。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,55 @@
1
1
  {
2
2
  "name": "@hellokidder/dsh-hotkey",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "description": "One home for DSH keyboard shortcuts: hold Control to number and cycle conversations, double-tap Option to reveal clickable controls with letter hints, Option+Enter to focus the composer.",
6
+ "license": "MIT",
7
+ "author": "kidder",
8
+ "keywords": [
9
+ "dsh",
10
+ "deepseek-harness",
11
+ "dsh-plugin",
12
+ "hotkeys",
13
+ "keyboard-shortcuts",
14
+ "session-switcher"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/hellokidder/dsh-hotkey.git"
19
+ },
20
+ "homepage": "https://github.com/hellokidder/dsh-hotkey#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/hellokidder/dsh-hotkey/issues"
23
+ },
24
+ "exports": {
25
+ ".": "./index.js",
26
+ "./client": "./client.js",
27
+ "./locale/*.json": "./locale/*.json",
28
+ "./icon": "./icon.svg"
29
+ },
30
+ "files": [
31
+ "index.js",
32
+ "client.js",
33
+ "cordis.patch.yml",
34
+ "locale/*.json",
35
+ "icon.svg",
36
+ "README.md",
37
+ "CHANGELOG.md"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dsh": {
43
+ "bundle": {
44
+ "patch": "./cordis.patch.yml"
45
+ },
46
+ "client": {
47
+ "platform": "web",
48
+ "immediately": true,
49
+ "inject": [
50
+ "@deepseek-ai/dsh-client-shortcuts",
51
+ "@deepseek-ai/dsh-client-ui-workspace"
52
+ ]
53
+ }
54
+ }
55
+ }