dsh-composer-skill-picker 0.0.0-stage → 1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark930209
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,66 @@
1
- # Temporary Holding Version
1
+ # composer-skill-picker
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 Web 输入框的**工具行**(`+` 按钮右侧)常驻一个短按钮「技能」,点开后是一个浮层面板,
4
+ 面板顶部的两个复选框控制列表内容:
5
+
6
+ | 复选框 | 内容 | 默认 |
7
+ |---|---|---|
8
+ | 项目技能 | `<项目根>/.agents/skills`、`<项目根>/.dsh/skills` 下的 `SKILL.md` | ✅ 勾选 |
9
+ | 全局技能 | 用户级(`<agentsHome>/skills`、`<dshHome>/skills`)与随包内置(`app.asar`)技能 | ⬜ |
10
+
11
+ 选中一个技能 → 把 `/技能名 ` 插进草稿。
12
+
13
+ ## 解决什么问题
14
+
15
+ - 内置的 `/` 菜单会同时列出内置命令、全部技能(内置 + 用户级 + 项目级),技能一多就很难找。
16
+ - 打开会话即自动发现当前项目的技能,不用手工维护清单;需要全局技能时再勾一下复选框。
17
+
18
+ ## 目录与来源
19
+
20
+ | SkillSource | 目录 | 归类 |
21
+ |---|---|---|
22
+ | `project-dsh` | `<项目根>/.dsh/skills/<名字>/SKILL.md` | 项目技能 |
23
+ | `project-agents` | `<项目根>/.agents/skills/<名字>/SKILL.md` | 项目技能 |
24
+ | `user-dsh` | `<dshHome>/skills` | 全局技能 |
25
+ | `user-agents` | `<agentsHome>/skills`(如 `C:\Users\congzhisen\.agents\skills`) | 全局技能 |
26
+ | `bundled` | 包内 `assets/**/SKILL.md`(如 `office-*`、`diagnose-windows-sandbox-acl`) | 全局技能 |
27
+
28
+ 项目根 = 最近的含 `.git` 的祖先目录(没有 `.git` 时用会话 cwd),由 DSH 的 skill provider 决定。
29
+
30
+ ## 用法
31
+
32
+ 1. 在项目里放技能,例如 `E:\5_8U\Cybertron\.agents\skills\ec9-architect\SKILL.md`。
33
+ 2. 打开该项目的会话,点工具行的「技能」按钮,面板默认只列该项目的技能(按名字排序)。
34
+ 3. 点一个技能 → 自动插入 `/ec9-architect `,面板关闭,焦点回到输入框。
35
+
36
+ ## 实现要点
37
+
38
+ - 触发器:插槽 `conversation.input.left`(id `composer-skill-picker`,order 80),
39
+ 一个紧凑按钮(`max-width:96px`,超出省略),样式对齐内置模型选择器。
40
+ - 面板:插槽 `shell.overlay`(id `composer-skill-picker-panel`,order 900),
41
+ `position:fixed` 贴着触发器上方弹出,避免被输入框容器裁剪;宽度 420px(随视口收缩),
42
+ 列表区 `overflow-y:auto` 独立滚动,长技能名横向可滚(`scrollbar-width:none`)。
43
+ 背景用 `--dsw-alias-bg-layer-1`(浅色主题下为纯白),边框 `--dsw-alias-border-l2`。
44
+ - 归类:`skills/list` 的条目只有 `path/name/description/whenToUse/modelInvocable`(**没有 `source`**),
45
+ 所以取 `.agents/skills` / `.dsh/skills` 之前的目录前缀与会话 cwd 比较——前缀是 cwd 本身或
46
+ 其祖先才算项目技能;没有 `path`(随包内置)或路径不在项目内的都归「全局技能」,每条带
47
+ 「内置 / 用户」标签。
48
+ - 插入:`inputActions.captureInsertion()` 取 CAS span → `insertText('/名字 ', span)`;
49
+ CAS 失败则退回最新光标位置;随后 `conversation.input.for(actx).focus()` 把焦点交还输入框。
50
+
51
+ ## 开发
52
+
53
+ ```powershell
54
+ node --check client.js
55
+ node --check index.js
56
+ # 安装:plugin_manager install_bundle target=E:\Projects\DSH_Plugins_Dev\plugins\composer-skill-picker
57
+ # 只改 client.js / locale → 重启 DeepSeek Harness 生效
58
+ ```
59
+
60
+ ## 已知限制
61
+
62
+ - 不提供 Agent(agent preset)切换:DSH 的 preset 是会话级选择,且会话开始首轮后即锁死;
63
+ 需要时在新建会话处选择。DSH 也不读取 `.github/agents/*.agent.md`(那是 Copilot 等工具的
64
+ 约定),项目里的自定义 agent 表现为 `.agents/skills/<名字>/SKILL.md`。
65
+ - 面板没有搜索框;列表较长时用面板内滚动(技能名过长时可横向滚动查看全文)。
66
+ - 面板不是模态对话框,点击面板外或按 `Esc` 关闭。
package/client.js ADDED
@@ -0,0 +1,496 @@
1
+ /**
2
+ * 项目技能选择器(composer-skill-picker)
3
+ *
4
+ * 在输入框工具行常驻一个短按钮,点开是一个浮层面板,用 2 个复选框控制列表:
5
+ * - 项目技能:`<项目根>/.agents/skills` 或 `<项目根>/.dsh/skills` 下的 SKILL.md
6
+ * - 全局技能:用户级(`<agentsHome>/skills`、`<dshHome>/skills`)与随包内置(bundled)
7
+ *
8
+ * 项目根 = 最近的含 `.git` 的祖先目录(没有则用会话 cwd),由 DSH 的 skill provider
9
+ * 判定;这里只做路径归属判断,因为 `skills/list` 的结果条目**没有 source 字段**
10
+ * (只有 path / name / description / whenToUse / modelInvocable)。
11
+ *
12
+ * 选中即把 `/技能名 ` 插入草稿(与内置 ui-skill 的 onPick 一致),发送时由宿主解析。
13
+ */
14
+ window.__ModuleLoader__.load({
15
+ id: 'dsh-composer-skill-picker',
16
+ factory(require) {
17
+ const React = require('react');
18
+ const h = React.createElement;
19
+
20
+ // ---------------------------------------------------------------- constants
21
+ /** 本插件拥有的 locale 命名空间。 */
22
+ const NS = 'composer-skill-picker';
23
+ /** 视为“项目技能”的目录标记(取标记之前的目录作为项目根)。 */
24
+ const PROJECT_MARKERS = ['/.agents/skills/', '/.dsh/skills/'];
25
+ /** 面板宽度(px)。 */
26
+ const PANEL_WIDTH = 420;
27
+ /** 下拉箭头。 */
28
+ const CHEVRON = 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'12\' height=\'12\' viewBox=\'0 0 12 12\' fill=\'none\'%3E%3Cpath d=\'M3 4.5L6 7.5L9 4.5\' stroke=\'%2381858C\' stroke-width=\'1.5\' stroke-linecap=\'round\' stroke-linejoin=\'round\'/%3E%3C/svg%3E")';
29
+
30
+ // ---------------------------------------------------------------- locale
31
+ /** 简体中文(key 集合的事实来源)。 */
32
+ const zh = {
33
+ 'label': '技能',
34
+ 'title': '选择技能(项目 / 全局)',
35
+ 'project': '项目技能',
36
+ 'global': '全局技能',
37
+ 'loading': '加载中…',
38
+ 'error': '加载失败',
39
+ 'emptyProject': '当前项目没有技能',
40
+ 'emptyGlobal': '没有全局技能',
41
+ 'emptyPick': '至少勾选一类',
42
+ 'builtin': '内置',
43
+ 'user': '用户'
44
+ };
45
+ /** English mirror. */
46
+ const en = {
47
+ 'label': 'Skills',
48
+ 'title': 'Pick a skill (project / global)',
49
+ 'project': 'Project',
50
+ 'global': 'Global',
51
+ 'loading': 'Loading…',
52
+ 'error': 'Load failed',
53
+ 'emptyProject': 'No skills in this project',
54
+ 'emptyGlobal': 'No global skills',
55
+ 'emptyPick': 'Select at least one group',
56
+ 'builtin': 'Built-in',
57
+ 'user': 'User'
58
+ };
59
+
60
+ // ---------------------------------------------------------------- styles
61
+ const CSS = [
62
+ '.cskp-trigger{display:inline-flex;align-items:center;gap:4px;height:28px;max-width:96px;',
63
+ 'padding:0 6px;border:none;border-radius:var(--dsw-radius-sm);background:transparent;',
64
+ 'color:var(--dsw-alias-label-secondary);font:inherit;font-size:12px;cursor:pointer;flex:none;}',
65
+ '.cskp-trigger:hover,.cskp-trigger[data-open="true"]{background:var(--dsw-alias-interactive-bg-hover-solid);',
66
+ 'color:var(--dsw-alias-label-primary);}',
67
+ '.cskp-trigger:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:1px;}',
68
+ '.cskp-trigger-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;}',
69
+ '.cskp-chevron{width:12px;height:12px;flex:none;opacity:.75;background-image:' + CHEVRON + ';',
70
+ 'background-repeat:no-repeat;background-position:center;background-size:12px 12px;}',
71
+ '.cskp-panel{position:fixed;z-index:90;display:flex;flex-direction:column;overflow:hidden;',
72
+ 'max-height:min(420px,60vh);background:var(--dsw-alias-bg-layer-1);',
73
+ 'border:1px solid var(--dsw-alias-border-l2);border-radius:8px;',
74
+ 'box-shadow:0 12px 32px rgba(0,0,0,.16),0 2px 8px rgba(0,0,0,.08);font-size:12px;}',
75
+ '.cskp-filters{display:flex;flex-wrap:wrap;gap:10px;padding:8px 10px;',
76
+ 'border-bottom:1px solid var(--dsw-alias-border-l2);flex:none;}',
77
+ '.cskp-check{display:inline-flex;align-items:center;gap:4px;font-size:12px;cursor:pointer;',
78
+ 'color:var(--dsw-alias-label-secondary);user-select:none;}',
79
+ '.cskp-check input{accent-color:var(--dsw-alias-brand-primary);margin:0;cursor:pointer;}',
80
+ '.cskp-count{color:var(--dsw-alias-state-idle-primary);font-size:11px;}',
81
+ '.cskp-list{overflow-y:auto;overscroll-behavior:contain;padding:4px;flex:1 1 auto;min-height:0;}',
82
+ '.cskp-group{font-size:11px;font-weight:600;padding:6px 8px 2px;',
83
+ 'color:var(--dsw-alias-label-secondary);}',
84
+ '.cskp-row{display:flex;flex-direction:column;gap:1px;width:100%;padding:5px 8px;border:none;',
85
+ 'border-radius:var(--dsw-radius-sm);background:transparent;text-align:left;cursor:pointer;font:inherit;}',
86
+ '.cskp-row:hover{background:var(--dsw-alias-interactive-bg-hover-solid);}',
87
+ '.cskp-row:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px;}',
88
+ '.cskp-line{display:flex;align-items:center;gap:6px;min-width:0;}',
89
+ '.cskp-name{flex:0 1 auto;min-width:0;font-size:12px;color:var(--dsw-alias-label-primary);',
90
+ 'white-space:nowrap;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;}',
91
+ '.cskp-name::-webkit-scrollbar{height:0;}',
92
+ '.cskp-meta{font-size:11px;color:var(--dsw-alias-label-secondary);white-space:nowrap;',
93
+ 'overflow:hidden;text-overflow:ellipsis;}',
94
+ '.cskp-badge{flex:none;font-size:10px;line-height:14px;padding:0 4px;border-radius:4px;',
95
+ 'background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary);}',
96
+ '.cskp-empty{padding:10px;font-size:12px;color:var(--dsw-alias-label-secondary);}'
97
+ ].join('');
98
+
99
+ /**
100
+ * 插入插件样式表,优先使用 Client 的 `styles` 内建;不可用时退回 `<style>` 标签。
101
+ * @param {string} css - 样式文本。
102
+ * @returns {() => void} 清理函数。
103
+ */
104
+ function insertCss(css) {
105
+ if (typeof styles !== 'undefined' && styles && typeof styles.insert === 'function') {
106
+ return styles.insert(css);
107
+ }
108
+ const tag = document.createElement('style');
109
+ tag.dataset.pluginCss = NS;
110
+ tag.textContent = css;
111
+ document.head.appendChild(tag);
112
+ return () => tag.remove();
113
+ }
114
+
115
+ /** 路径规范化:正斜杠 + 小写 + 去尾斜杠。 */
116
+ function normalizePath(value) {
117
+ if (typeof value !== 'string' || value === '') return undefined;
118
+ return value.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
119
+ }
120
+
121
+ /** 中文排序。 */
122
+ function byName(left, right) {
123
+ return String(left.name ?? left.id ?? '').localeCompare(String(right.name ?? right.id ?? ''));
124
+ }
125
+
126
+ // ---------------------------------------------------------------- client body
127
+ /**
128
+ * Client 插件主体。
129
+ * @param {object} ctx - Client 根上下文。
130
+ */
131
+ function apply(ctx) {
132
+ const locale = ctx.locale;
133
+ const sessions = ctx.sessions;
134
+ const skills = ctx.remote.skills;
135
+ const t = locale.bind(NS);
136
+
137
+ ctx.effect(() => insertCss(CSS), 'composer-skill-picker: stylesheet');
138
+ ctx.effect(() => locale.register(NS, { zh, en }), 'composer-skill-picker: dictionaries');
139
+
140
+ // ------------------------------------------------------------ shared store
141
+ /** 模块级共享状态:触发器与浮层面板都订阅它。 */
142
+ const state = {
143
+ open: false,
144
+ sessionId: undefined,
145
+ rect: null,
146
+ anchorEl: null,
147
+ panelEl: null,
148
+ filters: { project: true, global: false },
149
+ catalog: [],
150
+ cwd: undefined,
151
+ skillsStatus: 'idle',
152
+ skillsError: null,
153
+ insertSkill: null
154
+ };
155
+ const listeners = new Set();
156
+ function emit() {
157
+ for (const listener of Array.from(listeners)) {
158
+ try {
159
+ listener();
160
+ } catch {
161
+ // 单个订阅者异常不影响其他订阅者。
162
+ }
163
+ }
164
+ }
165
+ function setState(patch) {
166
+ Object.assign(state, patch);
167
+ emit();
168
+ }
169
+ function subscribe(listener) {
170
+ listeners.add(listener);
171
+ return () => {
172
+ listeners.delete(listener);
173
+ };
174
+ }
175
+ function close() {
176
+ if (!state.open) return;
177
+ setState({ open: false, panelEl: null, anchorEl: null });
178
+ }
179
+
180
+ /** 会话摘要(含 cwd)。 */
181
+ function sessionSummary(sessionId) {
182
+ try {
183
+ return sessions.list?.getSnapshot?.()?.byId?.[sessionId];
184
+ } catch {
185
+ return undefined;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * 判断技能是否属于当前项目:取 `.agents/skills` / `.dsh/skills` 之前的目录作为
191
+ * 项目根,只有它等于 cwd 或是 cwd 的祖先才算项目技能。
192
+ * 没有 path 的条目(bundled / runtime 提供的技能)无法证明属于项目 → 归为全局。
193
+ * @param {object} skill - skills/list 条目。
194
+ * @param {string | undefined} cwd - 规范化后的会话 cwd。
195
+ * @returns {boolean} 是否属于当前项目。
196
+ */
197
+ function isProjectSkill(skill, cwd) {
198
+ if (cwd === undefined) return false;
199
+ const path = normalizePath(skill?.path);
200
+ if (path === undefined) return false;
201
+ for (const marker of PROJECT_MARKERS) {
202
+ let at = path.indexOf(marker);
203
+ while (at > 0) {
204
+ const root = path.slice(0, at);
205
+ if (cwd === root || cwd.startsWith(`${root}/`)) return true;
206
+ at = path.indexOf(marker, at + 1);
207
+ }
208
+ }
209
+ return false;
210
+ }
211
+
212
+ /** 是否随包内置(没有 path,或路径落在 app.asar 内)。 */
213
+ function isBuiltinSkill(skill) {
214
+ const path = normalizePath(skill?.path);
215
+ return path === undefined || path.includes('/app.asar');
216
+ }
217
+
218
+ /** 拉取技能目录(不分类,分类在渲染时做)。 */
219
+ async function fetchSkills(sessionId, signal) {
220
+ if (sessions.subagentAddress(sessionId) !== undefined) {
221
+ return { list: [], cwd: undefined };
222
+ }
223
+ if (sessions.binding(sessionId) === undefined) {
224
+ throw new Error(`composer-skill-picker: session "${sessionId}" is not retained`);
225
+ }
226
+ return sessions.using(sessionId, { source: 'composerSkillPicker', signal }, async (reference) => {
227
+ signal.throwIfAborted();
228
+ const snapshot = reference.binding.session.getSnapshot();
229
+ if (snapshot.openState !== 'open') {
230
+ throw snapshot.openError ?? new Error(`composer-skill-picker: session "${sessionId}" is not open`);
231
+ }
232
+ const result = await skills.list({ sessionId }, signal);
233
+ signal.throwIfAborted();
234
+ if (!result.ok) {
235
+ throw new Error(`skills/list failed: ${result.error.code}: ${result.error.message}`);
236
+ }
237
+ return {
238
+ list: result.value.skills,
239
+ cwd: normalizePath(sessionSummary(sessionId)?.cwd)
240
+ };
241
+ });
242
+ }
243
+
244
+ let skillsAbort;
245
+ /** 加载会话可见的技能目录。 */
246
+ async function loadSkills(sessionId) {
247
+ if (typeof sessionId !== 'string' || sessionId === '') return;
248
+ if (skillsAbort !== undefined) skillsAbort.abort();
249
+ const abort = new AbortController();
250
+ skillsAbort = abort;
251
+ setState({ skillsStatus: 'loading', skillsError: null });
252
+ try {
253
+ const { list, cwd } = await fetchSkills(sessionId, abort.signal);
254
+ if (abort.signal.aborted) return;
255
+ setState({ catalog: list, cwd, skillsStatus: 'ready' });
256
+ } catch (error) {
257
+ if (abort.signal.aborted) return;
258
+ setState({
259
+ skillsStatus: 'error',
260
+ skillsError: String((error && error.message) || error)
261
+ });
262
+ }
263
+ }
264
+
265
+ /** 把 `/名字 ` 插入草稿并交还输入焦点。 */
266
+ function insertSkill(name) {
267
+ const actions = state.insertSkill;
268
+ if (actions === undefined || actions === null) return;
269
+ try {
270
+ const span = actions.captureInsertion();
271
+ if (span === undefined || actions.insertText(`/${name} `, span) !== true) {
272
+ const fresh = actions.captureInsertion();
273
+ if (fresh !== undefined) actions.insertText(`/${name} `, fresh);
274
+ }
275
+ } finally {
276
+ const sessionId = state.sessionId;
277
+ close();
278
+ try {
279
+ const actx = sessions.scope(sessionId);
280
+ if (actx !== undefined) ctx.get('conversation')?.input?.for?.(actx)?.focus?.();
281
+ } catch {
282
+ // 焦点回填是尽力而为。
283
+ }
284
+ }
285
+ }
286
+
287
+ /** 分组:项目 / 全局。 */
288
+ function classify() {
289
+ const project = [];
290
+ const global = [];
291
+ for (const skill of state.catalog) {
292
+ if (isProjectSkill(skill, state.cwd)) project.push(skill);
293
+ else global.push(skill);
294
+ }
295
+ project.sort(byName);
296
+ global.sort(byName);
297
+ return { project, global };
298
+ }
299
+
300
+ // ------------------------------------------------------------ views
301
+ /** 复选框。 */
302
+ function filterCheck(key, label, count) {
303
+ return h('label', { key: `f:${key}`, className: 'cskp-check' }, [
304
+ h('input', {
305
+ type: 'checkbox',
306
+ checked: state.filters[key] === true,
307
+ onChange: () => {
308
+ const filters = Object.assign({}, state.filters, { [key]: state.filters[key] !== true });
309
+ setState({ filters });
310
+ }
311
+ }),
312
+ h('span', null, label),
313
+ h('span', { className: 'cskp-count' }, String(count))
314
+ ]);
315
+ }
316
+
317
+ /** 技能行。 */
318
+ function skillRow(skill, withBadge) {
319
+ const tone = isBuiltinSkill(skill) ? t('builtin') : t('user');
320
+ return h('button', {
321
+ key: `s:${skill.name}`,
322
+ type: 'button',
323
+ className: 'cskp-row',
324
+ title: skill.whenToUse ?? skill.description ?? skill.name,
325
+ onClick: () => insertSkill(skill.name)
326
+ }, [
327
+ h('span', { key: 'line', className: 'cskp-line' }, [
328
+ h('span', { key: 'name', className: 'cskp-name' }, skill.name),
329
+ withBadge
330
+ ? h('span', { key: 'badge', className: 'cskp-badge' }, tone)
331
+ : null
332
+ ]),
333
+ skill.description !== undefined && skill.description !== ''
334
+ ? h('span', { key: 'meta', className: 'cskp-meta' }, skill.description)
335
+ : null
336
+ ]);
337
+ }
338
+
339
+ /** 浮层面板。 */
340
+ function SkillPanel() {
341
+ const [, force] = React.useState(0);
342
+ React.useEffect(() => subscribe(() => force((value) => value + 1)), []);
343
+
344
+ const open = state.open;
345
+ React.useEffect(() => {
346
+ if (!open) return undefined;
347
+ const onKeyDown = (event) => {
348
+ if (event.key === 'Escape') {
349
+ event.stopPropagation();
350
+ close();
351
+ }
352
+ };
353
+ const onPointerDown = (event) => {
354
+ const target = event.target;
355
+ if (state.panelEl !== null && state.panelEl.contains(target)) return;
356
+ if (state.anchorEl !== null && state.anchorEl.contains(target)) return;
357
+ close();
358
+ };
359
+ const onViewportChange = () => close();
360
+ document.addEventListener('keydown', onKeyDown, true);
361
+ document.addEventListener('pointerdown', onPointerDown, true);
362
+ window.addEventListener('resize', onViewportChange);
363
+ return () => {
364
+ document.removeEventListener('keydown', onKeyDown, true);
365
+ document.removeEventListener('pointerdown', onPointerDown, true);
366
+ window.removeEventListener('resize', onViewportChange);
367
+ };
368
+ }, [open]);
369
+
370
+ if (!open || state.rect === null) return null;
371
+
372
+ const width = Math.min(PANEL_WIDTH, window.innerWidth - 24);
373
+ const left = Math.max(8, Math.min(state.rect.left, window.innerWidth - width - 12));
374
+ const bottom = Math.max(8, window.innerHeight - state.rect.top + 8);
375
+ const style = { left: `${left}px`, bottom: `${bottom}px`, width: `${width}px` };
376
+
377
+ const { project, global } = classify();
378
+ const filters = state.filters;
379
+
380
+ const body = [];
381
+ if (!filters.project && !filters.global) {
382
+ body.push(h('div', { key: 'pick', className: 'cskp-empty' }, t('emptyPick')));
383
+ }
384
+ if (filters.project) {
385
+ body.push(h('div', { key: 'p:title', className: 'cskp-group' }, t('project')));
386
+ if (state.skillsStatus === 'loading' || state.skillsStatus === 'idle') {
387
+ body.push(h('div', { key: 'p:loading', className: 'cskp-empty' }, t('loading')));
388
+ } else if (state.skillsStatus === 'error') {
389
+ body.push(h('div', { key: 'p:error', className: 'cskp-empty' }, `${t('error')}: ${state.skillsError ?? ''}`));
390
+ } else if (project.length === 0) {
391
+ body.push(h('div', { key: 'p:empty', className: 'cskp-empty' }, t('emptyProject')));
392
+ } else {
393
+ body.push(...project.map((skill) => skillRow(skill, false)));
394
+ }
395
+ }
396
+ if (filters.global) {
397
+ body.push(h('div', { key: 'g:title', className: 'cskp-group' }, t('global')));
398
+ if (state.skillsStatus === 'loading' || state.skillsStatus === 'idle') {
399
+ body.push(h('div', { key: 'g:loading', className: 'cskp-empty' }, t('loading')));
400
+ } else if (state.skillsStatus === 'error') {
401
+ body.push(h('div', { key: 'g:error', className: 'cskp-empty' }, `${t('error')}: ${state.skillsError ?? ''}`));
402
+ } else if (global.length === 0) {
403
+ body.push(h('div', { key: 'g:empty', className: 'cskp-empty' }, t('emptyGlobal')));
404
+ } else {
405
+ body.push(...global.map((skill) => skillRow(skill, true)));
406
+ }
407
+ }
408
+
409
+ return h('div', {
410
+ className: 'cskp-panel',
411
+ style,
412
+ ref: (element) => {
413
+ state.panelEl = element;
414
+ }
415
+ }, [
416
+ h('div', { key: 'filters', className: 'cskp-filters' }, [
417
+ filterCheck('project', t('project'), project.length),
418
+ filterCheck('global', t('global'), global.length)
419
+ ]),
420
+ h('div', { key: 'list', className: 'cskp-list' }, body)
421
+ ]);
422
+ }
423
+
424
+ /** 工具行里的短按钮。 */
425
+ function SkillTrigger(props) {
426
+ const [, force] = React.useState(0);
427
+ React.useEffect(() => subscribe(() => force((value) => value + 1)), []);
428
+
429
+ const sessionId = props.sessionId;
430
+ const actions = props.inputActions;
431
+
432
+ React.useEffect(() => {
433
+ state.insertSkill = actions === undefined || actions === null ? null : actions;
434
+ return () => {
435
+ state.insertSkill = null;
436
+ };
437
+ }, [actions]);
438
+
439
+ React.useEffect(() => {
440
+ if (sessionId === state.sessionId) return;
441
+ close();
442
+ setState({
443
+ sessionId,
444
+ catalog: [],
445
+ cwd: undefined,
446
+ skillsStatus: 'idle'
447
+ });
448
+ loadSkills(sessionId);
449
+ }, [sessionId]);
450
+
451
+ const onClick = (event) => {
452
+ if (state.open) {
453
+ close();
454
+ return;
455
+ }
456
+ const rect = event.currentTarget.getBoundingClientRect();
457
+ setState({
458
+ open: true,
459
+ rect: { left: rect.left, top: rect.top },
460
+ anchorEl: event.currentTarget,
461
+ sessionId
462
+ });
463
+ if (state.skillsStatus === 'idle' || state.skillsStatus === 'error') loadSkills(sessionId);
464
+ };
465
+
466
+ return h('button', {
467
+ type: 'button',
468
+ className: 'cskp-trigger',
469
+ 'data-open': state.open ? 'true' : 'false',
470
+ title: t('title'),
471
+ 'aria-label': t('title'),
472
+ 'aria-haspopup': 'dialog',
473
+ 'aria-expanded': state.open ? 'true' : 'false',
474
+ onClick
475
+ }, [
476
+ h('span', { key: 'label', className: 'cskp-trigger-label' }, t('label')),
477
+ h('span', { key: 'chevron', className: 'cskp-chevron' })
478
+ ]);
479
+ }
480
+
481
+ // 触发器在工具行(`+` 之后);面板挂在 frame 级浮层,避免被输入框容器裁剪。
482
+ ctx.slots.inject('conversation.input.left', () => ctx.slots.register({
483
+ name: 'conversation.input.left',
484
+ id: 'composer-skill-picker',
485
+ order: 80
486
+ }, SkillTrigger));
487
+ ctx.slots.inject('shell.overlay', () => ctx.slots.register({
488
+ name: 'shell.overlay',
489
+ id: 'composer-skill-picker-panel',
490
+ order: 900
491
+ }, SkillPanel));
492
+ }
493
+
494
+ return { inject: ['slots', 'locale', 'sessions', 'remote', 'remote.skills'], apply };
495
+ }
496
+ });
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: composer-skill-picker
3
+ name: 'dsh-composer-skill-picker'
package/icon.svg ADDED
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Project Skill Picker">
2
+ <rect x="4" y="4" width="56" height="56" rx="14" fill="#2563EB"/>
3
+ <path d="M30.4 11.6 34.9 24.1 47.4 28.6 34.9 33.1 30.4 45.6 25.9 33.1 13.4 28.6 25.9 24.1 30.4 11.6Z" fill="#FFFFFF"/>
4
+ <circle cx="47.6" cy="47.6" r="6.2" fill="#FFFFFF" opacity="0.7"/>
5
+ </svg>
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Host 半部:不写 Host 逻辑时保持空 apply 即可。 */
2
+ export function apply() {}
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Project Skill Picker",
4
+ "description": "Adds a compact button to the composer tool row whose popup panel filters between project and global skills: project skills are discovered from the current project's .agents/skills and .dsh/skills by path, and a pick inserts /name."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "项目技能选择器",
4
+ "description": "在输入框工具行常驻一个短按钮,点开浮层面板后用复选框切换「项目技能 / 全局技能」:项目技能按路径自动发现当前项目的 .agents/skills 与 .dsh/skills,选中即插入 /技能名。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "dsh-composer-skill-picker",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.1",
4
+ "description": "Pick a skill of the current project ( .dsh/skills, .agents/skills ) directly from the composer tool row and insert its /name token, instead of scrolling the global slash menu.",
5
+ "type": "module",
6
+ "author": "Mark930209 <guimaxingtian@163.com>",
7
+ "license": "MIT",
8
+ "keywords": [
9
+ "dsh",
10
+ "deepseek-harness",
11
+ "plugin",
12
+ "bundle",
13
+ "skill",
14
+ "composer",
15
+ "picker",
16
+ "project",
17
+ "popup-select"
18
+ ],
19
+ "homepage": "https://github.com/Mark930209/GoDSH/tree/main/plugins/composer-skill-picker",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/Mark930209/GoDSH.git",
23
+ "directory": "plugins/composer-skill-picker"
24
+ },
25
+ "scripts": {
26
+ "check": "node --check client.js && node --check index.js"
27
+ },
28
+ "exports": {
29
+ ".": "./index.js",
30
+ "./client": "./client.js",
31
+ "./locale/*.json": "./locale/*.json",
32
+ "./icon": "./icon.svg"
33
+ },
34
+ "files": [
35
+ "index.js",
36
+ "client.js",
37
+ "cordis.patch.yml",
38
+ "locale/*.json",
39
+ "icon.svg"
40
+ ],
41
+ "dsh": {
42
+ "bundle": {
43
+ "patch": "./cordis.patch.yml"
44
+ },
45
+ "client": {
46
+ "platform": "web",
47
+ "immediately": true,
48
+ "inject": [
49
+ "@deepseek-ai/dsh-api-session-controller",
50
+ "@deepseek-ai/dsh-client-ui-conversation",
51
+ "@deepseek-ai/dsh-client-ui-commands",
52
+ "@deepseek-ai/dsh-client-locale"
53
+ ]
54
+ }
55
+ }
56
+ }