@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 +22 -0
- package/LICENSE +21 -0
- package/README.md +85 -2
- package/client.js +835 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +9 -0
- package/index.js +2 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -4
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
|
-
#
|
|
1
|
+
# dsh-hotkey
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
})
|
package/cordis.patch.yml
ADDED
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
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hellokidder/dsh-hotkey",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
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
|
+
}
|