@hellokidder/dsh-hotkey 0.0.1 → 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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
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
+
3
16
  ## 0.0.1
4
17
 
5
18
  - First release.
package/README.md CHANGED
@@ -6,16 +6,21 @@ npm:`@hellokidder/dsh-hotkey`
6
6
 
7
7
  ## 已包含
8
8
 
9
- - **按住 Command**(Windows/Linux 为 Control)时,左侧每个对话行首显示 1…n 的序号;当前对话的序号使用品牌色高亮。
10
- - **Command + N** 切换到下一个对话,**Command + Shift + N** 切换到上一个,均循环。
11
- - **Command + 1…9** 直接跳转到对应序号的对话。
12
- - 序号顺序跟随侧边栏默认视图:按 Workspace 分组、置顶优先、组内按最近使用排序;归档会话、子代理会话和空的「新会话」行不参与编号。
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 只负责会话编号与轮转。
13
16
  - 三个快捷键会出现在「设置 → 键盘快捷键」的只读分组中,便于查找。
14
17
 
15
18
  ## 平台说明
16
19
 
17
- - 在桌面 App 中,Command 组合先经过原生层;插件把 `Command+N` 注册为固定快捷键,因此它会交给页面而不是「新建会话」。
18
- - 如果直接在 Chrome / Safari 里打开 Web GUI,`Command+N` 和 `Command+Shift+N` 会被浏览器占用(新建窗口 / 无痕窗口),页面收不到,请使用桌面 App。
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 更稳妥。
19
24
 
20
25
  ## 安装
21
26
 
@@ -31,30 +36,32 @@ dsh plugin --profile web add @hellokidder/dsh-hotkey
31
36
  dsh plugin --profile web add "link:/absolute/path/to/dsh-hotkey"
32
37
  ```
33
38
 
34
- 安装后在插件管理中启用,客户端会通过 HMR 加载;如果没有更新,刷新一次页面。
39
+ 安装后在插件管理中启用;修改 `client.js` 后刷新一次页面即可加载,如果运行在带 `pnpm run dev:web` watcher 的 checkout 中则会自动重载。
35
40
 
36
41
  ## 显示细节
37
42
 
38
43
  - 编号占用每行标题左侧的 16px 座位;正在运行、等待审批或有未读完成状态的行会优先显示宿主的状态点,该行不显示编号。
39
- - 编号顺序与侧边栏默认视图一致:按 Workspace 分组、置顶优先、组内最新在前。折叠分组中的会话仍参与编号和切换。
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 仍是对话编号与轮转。
40
49
 
41
50
  ## 与「新建会话」的关系
42
51
 
43
- 宿主给「新建会话」分配的默认快捷键也是 Command + N。本插件把 Command + N 注册为固定(不可编辑)快捷键,因此:
52
+ 插件只使用 Control 组合,不占用任何 `Command`/`Ctrl + N`:
44
53
 
45
- - Command + N 现在用于切换对话;
46
- - 「新建会话」的 Command + N 会显示为冲突且不再触发,侧边栏的新建按钮和菜单仍然可用;
47
- - 如需保留「新建会话」的键盘入口,可在「设置 → 键盘快捷键」中给它换一个组合。
48
-
49
- 卸载插件后,Command + N 自动回到「新建会话」。
54
+ - `Command+N`(macOS)和宿主默认的 `Ctrl+N`(Windows/Linux)都保持原样,「新建会话」不受影响;
55
+ - 对话轮转使用 `Control+Tab` / `Control+Shift+Tab`,直达使用 `Control+1…9`;
56
+ - 固定快捷键仍然会出现在「设置 → 键盘快捷键」中,方便查看。
50
57
 
51
58
  ## 扩展新快捷键
52
59
 
53
60
  1. 在 `client.js` 的 `apply` 中用 `ctx.shortcuts.registerFixed({ id, label, keys, bindings, group })` 声明固定组合,用来占键并让快捷键面板可见;需要用户可改键时改用 `ctx.shortcuts.register`(与宿主已有默认组合冲突时 `register` 会抛错)。
54
- 2. 在 `window` 的**捕获阶段**监听 `keydown` 并分发动作,不要依赖 `observeFixedInput` 的 bubble 监听:编辑器和输入法可能提前 `stopPropagation`,中文输入法还会把 Shift 标记为组合态。带 Command/Ctrl 的组合是命令,不应被 `isComposing` 拦截。
61
+ 2. 在 `window` 的**捕获阶段**监听 `keydown` 并分发动作,不要依赖 `observeFixedInput` 的 bubble 监听:编辑器和输入法可能提前 `stopPropagation`,中文输入法还会把 Shift 标记为组合态。带 Control 的组合是命令,不应被 `isComposing` 拦截。
55
62
  3. 需要随快捷键状态显示 UI 时,沿用当前 `store` + `React.useSyncExternalStore` 的模式,通过槽位渲染。
56
63
  4. 同步更新 `locale/en.json`、`locale/zh.json` 的插件标题与描述,以及 `client.js` 中的 `en`/`zh` 命令标签。
57
- 5. 保存后客户端 HMR 会自动重载;如未生效刷新页面即可。修改 `package.json` 或 patch 需要重新安装。
64
+ 5. 保存后刷新页面即可;如果运行在带 `pnpm run dev:web` watcher 的 checkout 中会自动重载。修改 `package.json` 或 patch 需要重新安装。
58
65
 
59
66
  ## 开发
60
67
 
@@ -71,7 +78,7 @@ node --check client.js
71
78
 
72
79
  ```sh
73
80
  npm publish --access public
74
- git tag v0.0.1 && git push origin v0.0.1
81
+ git tag v0.0.2 && git push origin v0.0.2
75
82
  ```
76
83
 
77
84
  ## License
package/client.js CHANGED
@@ -13,11 +13,15 @@ window.__ModuleLoader__.load({
13
13
  'command.next': 'Next conversation',
14
14
  'command.previous': 'Previous conversation',
15
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',
16
18
  }
17
19
  const zh = {
18
20
  'command.next': '下一个对话',
19
21
  'command.previous': '上一个对话',
20
22
  'command.jump': '跳转到对话 1-9',
23
+ 'command.focus': '聚焦输入框',
24
+ 'hints.footer': '输入字母筛选并点击 · Esc 关闭',
21
25
  }
22
26
 
23
27
  const CSS = `
@@ -45,6 +49,71 @@ window.__ModuleLoader__.load({
45
49
  background: var(--dsw-alias-brand-primary);
46
50
  color: var(--dsw-alias-bg-base);
47
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
+ }
48
117
  `
49
118
 
50
119
  function createStore(initial) {
@@ -140,15 +209,264 @@ window.__ModuleLoader__.load({
140
209
  return { ordered, indexById, currentId }
141
210
  }
142
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
+
143
436
  return {
144
437
  inject: ['slots', 'locale', 'shortcuts', 'sessions', 'workspaces', 'uiWorkspace'],
145
438
  apply(ctx) {
146
439
  ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-hotkey: dictionaries')
147
440
  const t = ctx.locale.bind(NS)
148
441
 
149
- const initial = deriveOrder(ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot())
442
+ const initial = deriveVisibleOrder(ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot())
150
443
  const store = createStore({ held: false, ...initial })
151
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
+
152
470
  function SwitcherBadge({ sessionId }) {
153
471
  const snapshot = React.useSyncExternalStore(store.subscribe, store.getSnapshot)
154
472
  const number = snapshot.held ? snapshot.indexById[sessionId] : 0
@@ -161,13 +479,6 @@ window.__ModuleLoader__.load({
161
479
  }, String(number))
162
480
  }
163
481
 
164
- const platform = String(
165
- (navigator.userAgentData === undefined ? undefined : navigator.userAgentData.platform)
166
- ?? navigator.platform
167
- ?? '',
168
- ).toLowerCase()
169
- const isMac = platform.includes('mac') || platform.includes('iphone') || platform.includes('ipad')
170
-
171
482
  ctx.effect(() => {
172
483
  const style = document.createElement('style')
173
484
  style.setAttribute('data-plugin-css', STYLE_ID)
@@ -179,21 +490,21 @@ window.__ModuleLoader__.load({
179
490
  }, 'dsh-hotkey: badge styles')
180
491
 
181
492
  ctx.effect(() => {
182
- const rebuild = () => {
183
- store.publish(deriveOrder(ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot()))
184
- }
185
- const offSessions = ctx.sessions.list.subscribe(rebuild)
186
- const offWorkspaces = ctx.workspaces.list.subscribe(rebuild)
187
- rebuild()
493
+ const offSessions = ctx.sessions.list.subscribe(refreshOrder)
494
+ const offWorkspaces = ctx.workspaces.list.subscribe(refreshOrder)
495
+ refreshOrder()
188
496
  return () => {
189
497
  offSessions()
190
498
  offWorkspaces()
191
499
  }
192
500
  }, 'dsh-hotkey: switch order')
193
501
 
194
- // Numbers follow the physical primary modifier: Command on macOS, Control elsewhere.
502
+ // Numbers follow Control on every platform, macOS included.
195
503
  ctx.effect(() => {
196
- const sync = (event) => store.publish({ held: isMac ? event.metaKey : event.ctrlKey })
504
+ const sync = (event) => {
505
+ if (event.ctrlKey) refreshOrder()
506
+ store.publish({ held: event.ctrlKey })
507
+ }
197
508
  const clear = () => store.publish({ held: false })
198
509
  const onVisibility = () => {
199
510
  if (document.visibilityState === 'hidden') clear()
@@ -210,6 +521,209 @@ window.__ModuleLoader__.load({
210
521
  }
211
522
  }, 'dsh-hotkey: modifier state')
212
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
+
213
727
  const activate = (sessionId) => {
214
728
  if (sessionId !== undefined) ctx.uiWorkspace.openSession(sessionId)
215
729
  }
@@ -232,13 +746,13 @@ window.__ModuleLoader__.load({
232
746
  }
233
747
 
234
748
  ctx.effect(() => {
235
- const nextBinding = { code: 'KeyN', modifiers: ['primary'] }
236
- const previousBinding = { code: 'KeyN', modifiers: ['primary', 'shift'] }
749
+ const nextBinding = { code: 'Tab', modifiers: ['control'] }
750
+ const previousBinding = { code: 'Tab', modifiers: ['control', 'shift'] }
237
751
  const jumpBindings = []
238
752
  for (let number = 1; number <= 9; number++) {
239
- jumpBindings.push({ code: `Digit${number}`, modifiers: ['primary'] })
753
+ jumpBindings.push({ code: `Digit${number}`, modifiers: ['control'] })
240
754
  }
241
- const jumpKeys = ctx.shortcuts.describeBinding({ code: 'Digit1', modifiers: ['primary'] }).keys
755
+ const jumpKeys = ctx.shortcuts.describeBinding({ code: 'Digit1', modifiers: ['control'] }).keys
242
756
  const disposers = [
243
757
  ctx.shortcuts.registerFixed({
244
758
  id: 'dsh-hotkey.next',
@@ -261,6 +775,13 @@ window.__ModuleLoader__.load({
261
775
  bindings: jumpBindings,
262
776
  group: 'application',
263
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
+ }),
264
785
  ]
265
786
  return () => {
266
787
  for (const dispose of disposers) dispose()
@@ -269,19 +790,17 @@ window.__ModuleLoader__.load({
269
790
 
270
791
  // Capture phase: own the combination before editors, terminals, and
271
792
  // modal controls can stop its propagation. The fixed registrations above
272
- // reserve the keys (so Command+N is not intercepted as New Session) and
273
- // keep them visible in the Keyboard Shortcuts reference.
793
+ // reserve the Control chords and keep them visible in the Keyboard
794
+ // Shortcuts reference; Command+N stays with New Session.
274
795
  ctx.effect(() => {
275
796
  const onKeyDown = (event) => {
276
797
  if (event.defaultPrevented || event.repeat) return
277
798
  // Do not consult isComposing/keyCode 229 here: Chinese IMEs report
278
- // Shift as a composition key, which used to swallow Command+Shift+N.
279
- // A primary-modifier chord is a command, not text input.
280
- const primary = isMac
281
- ? event.metaKey === true && event.ctrlKey !== true
282
- : event.ctrlKey === true && event.metaKey !== true
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
283
802
  if (!primary || event.altKey === true) return
284
- if (event.code === 'KeyN') {
803
+ if (event.code === 'Tab') {
285
804
  event.preventDefault()
286
805
  event.stopPropagation()
287
806
  step(event.shiftKey === true ? -1 : 1)
@@ -299,6 +818,12 @@ window.__ModuleLoader__.load({
299
818
  }
300
819
  }, 'dsh-hotkey: keyboard')
301
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
+
302
827
  ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({
303
828
  name: 'sidebar.session.row.leading',
304
829
  id: 'dsh-hotkey.number',
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "DSH Hotkey",
4
- "description": "One home for DSH keyboard shortcuts. Currently: hold Command to number the conversations in the sidebar, Command+N / Command+Shift+N to move between them, and Command+1-9 to jump straight to that number."
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
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "DSH 快捷键",
4
- "description": "DSH 快捷键的统一入口。当前包含:按住 Command 显示侧边栏对话编号,Command+N / Command+Shift+N 循环切换,Command+1-9 直接跳转到对应序号。"
4
+ "description": "DSH 快捷键的统一入口。按住 Control 显示侧边栏对话编号并轮转;双击 Option 显示字母提示;Option+Enter 聚焦输入框。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@hellokidder/dsh-hotkey",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "type": "module",
5
- "description": "One home for DSH keyboard shortcuts: hold Command to number sidebar conversations, Command+N / Shift+N to cycle, Command+1-9 to jump.",
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
6
  "license": "MIT",
7
7
  "author": "kidder",
8
8
  "keywords": [