@hellokidder/dsh-hotkey 0.0.0-stage → 0.0.1

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,9 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1
4
+
5
+ - First release.
6
+ - Hold Command (Control on Windows/Linux) to number the sidebar conversations.
7
+ - `Command+N` / `Command+Shift+N` cycle through conversations.
8
+ - `Command+1` through `Command+9` jump to a numbered conversation.
9
+ - 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,79 @@
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
+ - **按住 Command**(Windows/Linux 为 Control)时,左侧每个对话行首显示 1…n 的序号;当前对话的序号使用品牌色高亮。
10
+ - **Command + N** 切换到下一个对话,**Command + Shift + N** 切换到上一个,均循环。
11
+ - **Command + 1…9** 直接跳转到对应序号的对话。
12
+ - 序号顺序跟随侧边栏默认视图:按 Workspace 分组、置顶优先、组内按最近使用排序;归档会话、子代理会话和空的「新会话」行不参与编号。
13
+ - 三个快捷键会出现在「设置 → 键盘快捷键」的只读分组中,便于查找。
14
+
15
+ ## 平台说明
16
+
17
+ - 在桌面 App 中,Command 组合先经过原生层;插件把 `Command+N` 注册为固定快捷键,因此它会交给页面而不是「新建会话」。
18
+ - 如果直接在 Chrome / Safari 里打开 Web GUI,`Command+N` 和 `Command+Shift+N` 会被浏览器占用(新建窗口 / 无痕窗口),页面收不到,请使用桌面 App。
19
+
20
+ ## 安装
21
+
22
+ 从 npm 安装:
23
+
24
+ ```sh
25
+ dsh plugin --profile web add @hellokidder/dsh-hotkey
26
+ ```
27
+
28
+ 从本地目录安装(开发):
29
+
30
+ ```sh
31
+ dsh plugin --profile web add "link:/absolute/path/to/dsh-hotkey"
32
+ ```
33
+
34
+ 安装后在插件管理中启用,客户端会通过 HMR 加载;如果没有更新,刷新一次页面。
35
+
36
+ ## 显示细节
37
+
38
+ - 编号占用每行标题左侧的 16px 座位;正在运行、等待审批或有未读完成状态的行会优先显示宿主的状态点,该行不显示编号。
39
+ - 编号顺序与侧边栏默认视图一致:按 Workspace 分组、置顶优先、组内最新在前。折叠分组中的会话仍参与编号和切换。
40
+
41
+ ## 与「新建会话」的关系
42
+
43
+ 宿主给「新建会话」分配的默认快捷键也是 Command + N。本插件把 Command + N 注册为固定(不可编辑)快捷键,因此:
44
+
45
+ - Command + N 现在用于切换对话;
46
+ - 「新建会话」的 Command + N 会显示为冲突且不再触发,侧边栏的新建按钮和菜单仍然可用;
47
+ - 如需保留「新建会话」的键盘入口,可在「设置 → 键盘快捷键」中给它换一个组合。
48
+
49
+ 卸载插件后,Command + N 自动回到「新建会话」。
50
+
51
+ ## 扩展新快捷键
52
+
53
+ 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` 拦截。
55
+ 3. 需要随快捷键状态显示 UI 时,沿用当前 `store` + `React.useSyncExternalStore` 的模式,通过槽位渲染。
56
+ 4. 同步更新 `locale/en.json`、`locale/zh.json` 的插件标题与描述,以及 `client.js` 中的 `en`/`zh` 命令标签。
57
+ 5. 保存后客户端 HMR 会自动重载;如未生效刷新页面即可。修改 `package.json` 或 patch 需要重新安装。
58
+
59
+ ## 开发
60
+
61
+ ```sh
62
+ git clone https://github.com/hellokidder/dsh-hotkey.git
63
+ cd dsh-hotkey
64
+ node --check client.js
65
+ ```
66
+
67
+ - `client.js` 是浏览器端模块(`window.__ModuleLoader__` 格式),不依赖构建工具。
68
+ - 插件清单与入口见 `package.json`、`cordis.patch.yml`,宿主半边 `index.js` 目前为空。
69
+
70
+ ## 发布
71
+
72
+ ```sh
73
+ npm publish --access public
74
+ git tag v0.0.1 && git push origin v0.0.1
75
+ ```
76
+
77
+ ## License
78
+
79
+ MIT
package/client.js ADDED
@@ -0,0 +1,310 @@
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
+ }
17
+ const zh = {
18
+ 'command.next': '下一个对话',
19
+ 'command.previous': '上一个对话',
20
+ 'command.jump': '跳转到对话 1-9',
21
+ }
22
+
23
+ const CSS = `
24
+ .${BADGE_CLASS} {
25
+ display: inline-flex;
26
+ align-items: center;
27
+ justify-content: center;
28
+ flex: none;
29
+ width: 16px;
30
+ height: 16px;
31
+ border: 0.5px solid var(--dsw-alias-border-l1);
32
+ border-radius: 4px;
33
+ background: var(--dsw-alias-bg-layer-2);
34
+ color: var(--dsw-alias-label-secondary);
35
+ font-size: 9px;
36
+ font-weight: 600;
37
+ line-height: 1;
38
+ font-variant-numeric: tabular-nums;
39
+ letter-spacing: -0.02em;
40
+ pointer-events: none;
41
+ user-select: none;
42
+ }
43
+ .${BADGE_CURRENT_CLASS} {
44
+ border-color: transparent;
45
+ background: var(--dsw-alias-brand-primary);
46
+ color: var(--dsw-alias-bg-base);
47
+ }
48
+ `
49
+
50
+ function createStore(initial) {
51
+ let snapshot = initial
52
+ const listeners = new Set()
53
+ return {
54
+ getSnapshot: () => snapshot,
55
+ subscribe: (listener) => {
56
+ listeners.add(listener)
57
+ return () => {
58
+ listeners.delete(listener)
59
+ }
60
+ },
61
+ publish(patch) {
62
+ let changed = false
63
+ for (const key of Object.keys(patch)) {
64
+ if (!Object.is(snapshot[key], patch[key])) {
65
+ changed = true
66
+ break
67
+ }
68
+ }
69
+ if (!changed) return
70
+ snapshot = { ...snapshot, ...patch }
71
+ for (const listener of [...listeners]) listener()
72
+ },
73
+ }
74
+ }
75
+
76
+ /** Newest first, with the Session id as a deterministic tie-break. */
77
+ function orderByRecency(ids, byId) {
78
+ return ids
79
+ .map((id) => ({ id, rank: byId[id] === undefined ? 0 : byId[id].updatedAt }))
80
+ .sort((left, right) => (left.rank !== right.rank ? right.rank - left.rank : left.id < right.id ? -1 : 1))
81
+ .map((entry) => entry.id)
82
+ }
83
+
84
+ /**
85
+ * Reproduce the sidebar's default browsing order: Workspace groups in Host
86
+ * order, pinned rows before ordinary rows inside each group, then newest
87
+ * first. Only rows the sidebar can show are numbered: no archived Session,
88
+ * no subagent Session, and no blank New Session placeholder.
89
+ */
90
+ function deriveOrder(list, workspaceState) {
91
+ const byId = list.byId === undefined ? {} : list.byId
92
+ const state = workspaceState === undefined || workspaceState === null ? {} : workspaceState
93
+ const archived = new Set(state.archivedSessionIds === undefined ? [] : state.archivedSessionIds)
94
+ const pinned = new Set(state.pinnedSessionIds === undefined ? [] : state.pinnedSessionIds)
95
+ const workspaces = Array.isArray(state.items) ? state.items : []
96
+ const ids = Array.isArray(list.ids) ? list.ids : []
97
+ const ordered = []
98
+ const indexById = {}
99
+ const accounted = new Set()
100
+
101
+ const visible = (session) => session !== undefined
102
+ && session.origin !== 'subagent'
103
+ && session.blank !== true
104
+ && !archived.has(session.id)
105
+
106
+ const pushSection = (memberIds) => {
107
+ const recency = orderByRecency(memberIds, byId)
108
+ const section = [
109
+ ...recency.filter((id) => pinned.has(id)),
110
+ ...recency.filter((id) => !pinned.has(id)),
111
+ ]
112
+ for (const id of section) {
113
+ if (Object.hasOwn(indexById, id)) continue
114
+ indexById[id] = ordered.length + 1
115
+ ordered.push(id)
116
+ }
117
+ }
118
+
119
+ for (const workspace of workspaces) {
120
+ const memberIds = Array.isArray(workspace.sessionIds) ? workspace.sessionIds : []
121
+ const visibleIds = []
122
+ for (const id of memberIds) {
123
+ accounted.add(id)
124
+ if (visible(byId[id])) visibleIds.push(id)
125
+ }
126
+ pushSection(visibleIds)
127
+ }
128
+
129
+ pushSection(ids.filter((id) => !accounted.has(id) && visible(byId[id])))
130
+
131
+ let currentId
132
+ for (const id of ids) {
133
+ const session = byId[id]
134
+ if (session !== undefined && (session.retainedBy === undefined ? 0 : session.retainedBy.mainView ?? 0) > 0) {
135
+ currentId = id
136
+ break
137
+ }
138
+ }
139
+
140
+ return { ordered, indexById, currentId }
141
+ }
142
+
143
+ return {
144
+ inject: ['slots', 'locale', 'shortcuts', 'sessions', 'workspaces', 'uiWorkspace'],
145
+ apply(ctx) {
146
+ ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-hotkey: dictionaries')
147
+ const t = ctx.locale.bind(NS)
148
+
149
+ const initial = deriveOrder(ctx.sessions.list.getSnapshot(), ctx.workspaces.list.getSnapshot())
150
+ const store = createStore({ held: false, ...initial })
151
+
152
+ function SwitcherBadge({ sessionId }) {
153
+ const snapshot = React.useSyncExternalStore(store.subscribe, store.getSnapshot)
154
+ const number = snapshot.held ? snapshot.indexById[sessionId] : 0
155
+ if (!number) return null
156
+ return h('span', {
157
+ className: snapshot.currentId === sessionId
158
+ ? `${BADGE_CLASS} ${BADGE_CURRENT_CLASS}`
159
+ : BADGE_CLASS,
160
+ 'aria-hidden': 'true',
161
+ }, String(number))
162
+ }
163
+
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
+ ctx.effect(() => {
172
+ const style = document.createElement('style')
173
+ style.setAttribute('data-plugin-css', STYLE_ID)
174
+ style.textContent = CSS
175
+ document.head.appendChild(style)
176
+ return () => {
177
+ style.remove()
178
+ }
179
+ }, 'dsh-hotkey: badge styles')
180
+
181
+ 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()
188
+ return () => {
189
+ offSessions()
190
+ offWorkspaces()
191
+ }
192
+ }, 'dsh-hotkey: switch order')
193
+
194
+ // Numbers follow the physical primary modifier: Command on macOS, Control elsewhere.
195
+ ctx.effect(() => {
196
+ const sync = (event) => store.publish({ held: isMac ? event.metaKey : event.ctrlKey })
197
+ const clear = () => store.publish({ held: false })
198
+ const onVisibility = () => {
199
+ if (document.visibilityState === 'hidden') clear()
200
+ }
201
+ window.addEventListener('keydown', sync, true)
202
+ window.addEventListener('keyup', sync, true)
203
+ window.addEventListener('blur', clear)
204
+ document.addEventListener('visibilitychange', onVisibility)
205
+ return () => {
206
+ window.removeEventListener('keydown', sync, true)
207
+ window.removeEventListener('keyup', sync, true)
208
+ window.removeEventListener('blur', clear)
209
+ document.removeEventListener('visibilitychange', onVisibility)
210
+ }
211
+ }, 'dsh-hotkey: modifier state')
212
+
213
+ const activate = (sessionId) => {
214
+ if (sessionId !== undefined) ctx.uiWorkspace.openSession(sessionId)
215
+ }
216
+
217
+ const step = (delta) => {
218
+ const { ordered, currentId } = store.getSnapshot()
219
+ if (ordered.length === 0) return
220
+ const at = currentId === undefined ? -1 : ordered.indexOf(currentId)
221
+ const target = at === -1
222
+ ? (delta > 0 ? 0 : ordered.length - 1)
223
+ : (at + delta + ordered.length) % ordered.length
224
+ const nextId = ordered[target]
225
+ if (nextId !== currentId) activate(nextId)
226
+ }
227
+
228
+ const jump = (number) => {
229
+ const { ordered, currentId } = store.getSnapshot()
230
+ const target = ordered[number - 1]
231
+ if (target !== undefined && target !== currentId) activate(target)
232
+ }
233
+
234
+ ctx.effect(() => {
235
+ const nextBinding = { code: 'KeyN', modifiers: ['primary'] }
236
+ const previousBinding = { code: 'KeyN', modifiers: ['primary', 'shift'] }
237
+ const jumpBindings = []
238
+ for (let number = 1; number <= 9; number++) {
239
+ jumpBindings.push({ code: `Digit${number}`, modifiers: ['primary'] })
240
+ }
241
+ const jumpKeys = ctx.shortcuts.describeBinding({ code: 'Digit1', modifiers: ['primary'] }).keys
242
+ const disposers = [
243
+ ctx.shortcuts.registerFixed({
244
+ id: 'dsh-hotkey.next',
245
+ label: () => t('command.next'),
246
+ keys: ctx.shortcuts.describeBinding(nextBinding).keys,
247
+ bindings: [nextBinding],
248
+ group: 'application',
249
+ }),
250
+ ctx.shortcuts.registerFixed({
251
+ id: 'dsh-hotkey.previous',
252
+ label: () => t('command.previous'),
253
+ keys: ctx.shortcuts.describeBinding(previousBinding).keys,
254
+ bindings: [previousBinding],
255
+ group: 'application',
256
+ }),
257
+ ctx.shortcuts.registerFixed({
258
+ id: 'dsh-hotkey.jump',
259
+ label: () => t('command.jump'),
260
+ keys: [...jumpKeys.slice(0, -1), '1-9'],
261
+ bindings: jumpBindings,
262
+ group: 'application',
263
+ }),
264
+ ]
265
+ return () => {
266
+ for (const dispose of disposers) dispose()
267
+ }
268
+ }, 'dsh-hotkey: fixed shortcuts')
269
+
270
+ // Capture phase: own the combination before editors, terminals, and
271
+ // 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.
274
+ ctx.effect(() => {
275
+ const onKeyDown = (event) => {
276
+ if (event.defaultPrevented || event.repeat) return
277
+ // 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
283
+ if (!primary || event.altKey === true) return
284
+ if (event.code === 'KeyN') {
285
+ event.preventDefault()
286
+ event.stopPropagation()
287
+ step(event.shiftKey === true ? -1 : 1)
288
+ return
289
+ }
290
+ if (event.shiftKey !== true && /^Digit[1-9]$/u.test(event.code)) {
291
+ event.preventDefault()
292
+ event.stopPropagation()
293
+ jump(Number(event.code.slice('Digit'.length)))
294
+ }
295
+ }
296
+ window.addEventListener('keydown', onKeyDown, true)
297
+ return () => {
298
+ window.removeEventListener('keydown', onKeyDown, true)
299
+ }
300
+ }, 'dsh-hotkey: keyboard')
301
+
302
+ ctx.slots.inject('sidebar.session.row.leading', () => ctx.slots.register({
303
+ name: 'sidebar.session.row.leading',
304
+ id: 'dsh-hotkey.number',
305
+ order: 20,
306
+ }, SwitcherBadge))
307
+ },
308
+ }
309
+ },
310
+ })
@@ -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. 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."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "DSH 快捷键",
4
+ "description": "DSH 快捷键的统一入口。当前包含:按住 Command 显示侧边栏对话编号,Command+N / Command+Shift+N 循环切换,Command+1-9 直接跳转到对应序号。"
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.1",
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.",
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
+ }