dsh-skill-picker 0.2.0 → 0.2.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-skill-picker",
3
3
  "description": "DSH Web GUI skill picker: a button beside the composer opens a searchable list of installed skills; picking one inserts the official `/skill-name` gesture into the input box, so the skill loads with your message (WorkBuddy-style skill invocation for DeepSeek Harness).",
4
- "version": "0.2.0",
4
+ "version": "0.2.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -13,7 +13,8 @@
13
13
  "build": "node build.mjs"
14
14
  },
15
15
  "devDependencies": {
16
- "esbuild": "^0.25.0"
16
+ "esbuild": "^0.25.0",
17
+ "fuzzysort": "^4.0.2"
17
18
  },
18
19
  "dsh": {
19
20
  "bundle": {
@@ -25,16 +26,29 @@
25
26
  "@deepseek-ai/dsh-client-locale"
26
27
  ],
27
28
  "platform": "web"
29
+ },
30
+ "compatibility": {
31
+ "dshReleases": {
32
+ "0.1.1-rc.1": "unknown",
33
+ "0.1.1-rc.2": "compatible",
34
+ "0.1.2-alpha.2": "compatible",
35
+ "0.1.2-alpha.3": "compatible",
36
+ "0.1.2-alpha.4": "compatible",
37
+ "0.1.2-alpha.5": "compatible"
38
+ }
28
39
  }
29
40
  },
41
+ "engines": {
42
+ "node": ">=22"
43
+ },
30
44
  "peerDependencies": {
31
45
  "@deepseek-ai/cordis": "^4.0.1",
46
+ "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
47
+ "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
48
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
32
49
  "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6",
33
50
  "@deepseek-ai/dsh-skill": "^0.1.0-rc.6",
34
51
  "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
35
- "@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
36
- "@deepseek-ai/dsh-client-locale": "^0.1.0-rc.6",
37
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.0-rc.6",
38
52
  "react": "^18.2.0"
39
53
  },
40
54
  "files": [
@@ -47,5 +61,8 @@
47
61
  "repository": {
48
62
  "type": "git",
49
63
  "url": "https://github.com/a735624258/dsh-skill-picker.git"
64
+ },
65
+ "dependencies": {
66
+ "pinyin-pro": "^3.29.3"
50
67
  }
51
68
  }
@@ -16,6 +16,8 @@
16
16
  */
17
17
 
18
18
  import React, { useCallback, useEffect, useRef, useState } from 'react'
19
+ import fuzzysort from 'fuzzysort'
20
+ import { pinyin } from 'pinyin-pro'
19
21
 
20
22
  /** Required services: slot registry, host connection (official skills API), sessions (workspace cwd fallback), input triggers (/ fuzzy source). */
21
23
  export const inject = ['slots', 'connection', 'sessions', 'inputTriggers']
@@ -44,13 +46,64 @@ function saveUsage(usage) {
44
46
  }
45
47
  }
46
48
 
49
+ /**
50
+ * Shared usage ordering — the single rule used by BOTH the ⚡ panel and the
51
+ * `/` completion: last picked first, then most frequent, then by name.
52
+ * A fresh copy is returned; the input array is untouched.
53
+ */
54
+ function rankByUsage(skills, usage) {
55
+ return skills.slice().sort((a, b) => {
56
+ const ua = usage[a.name]
57
+ const ub = usage[b.name]
58
+ const la = ua?.lastUsed ?? 0
59
+ const lb = ub?.lastUsed ?? 0
60
+ if (la !== lb) return lb - la
61
+ const ca = ua?.count ?? 0
62
+ const cb = ub?.count ?? 0
63
+ if (ca !== cb) return cb - ca
64
+ return a.name.localeCompare(b.name)
65
+ })
66
+ }
67
+
68
+ /**
69
+ * Pinyin search text for a skill name/description: spaced full pinyin
70
+ * (`ji yi`), joined (`jiyi`), and initials (`jy`) for the name, plus joined
71
+ * full pinyin and initials for the description — so either the `/` fuzzy
72
+ * completion or the ⚡ panel matches queries like `ji yi`, `jiyi`, or `jy`
73
+ * against 记忆/知识库/每日打卡-ish Chinese text. Cached per (name, desc)
74
+ * pair; never throws (falls back to '').
75
+ */
76
+ const pinyinCache = new Map()
77
+ function skillPinyinText(name, description = '') {
78
+ const key = `${name}\u0000${description}`
79
+ const cached = pinyinCache.get(key)
80
+ if (cached !== undefined) return cached
81
+ let text = ''
82
+ try {
83
+ const base = { toneType: 'none', nonZh: 'consecutive' }
84
+ const nameSpaced = pinyin(name, base)
85
+ const nameJoined = pinyin(name, { ...base, separator: '' })
86
+ const nameFirstSpaced = pinyin(name, { ...base, pattern: 'first' })
87
+ const nameFirstJoined = pinyin(name, { ...base, pattern: 'first', separator: '' })
88
+ const descSpaced = pinyin(description, base)
89
+ const descJoined = pinyin(description, { ...base, separator: '' })
90
+ const descFirstSpaced = pinyin(description, { ...base, pattern: 'first' })
91
+ const descFirstJoined = pinyin(description, { ...base, pattern: 'first', separator: '' })
92
+ text = `${nameSpaced} ${nameJoined} ${nameFirstSpaced} ${nameFirstJoined} ${descSpaced} ${descJoined} ${descFirstSpaced} ${descFirstJoined}`
93
+ } catch {
94
+ text = ''
95
+ }
96
+ pinyinCache.set(key, text)
97
+ return text
98
+ }
99
+
47
100
  /** Row height matches the resident chrome (access mode, plan, attach, model). */
48
101
  const buttonStyle = {
49
102
  display: 'inline-flex',
50
103
  alignItems: 'center',
51
104
  justifyContent: 'center',
52
- width: '28px',
53
- height: '28px',
105
+ width: '24px',
106
+ height: '24px',
54
107
  margin: '0 2px',
55
108
  padding: '0',
56
109
  border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))',
@@ -138,7 +191,7 @@ const statusStyle = {
138
191
  /** The picker's bolt glyph: DeepSeek palette gradient + slim stroke. */
139
192
  function BoltIcon() {
140
193
  return (
141
- <svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" style={{ display: 'block' }}>
194
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" style={{ display: 'block' }}>
142
195
  <defs>
143
196
  <linearGradient id="dsh-sp-bolt-grad" x1="0" y1="0" x2="0" y2="1">
144
197
  <stop offset="0%" stopColor="var(--dsw-static-deepseek-400, rgb(103, 158, 254))" />
@@ -149,7 +202,7 @@ function BoltIcon() {
149
202
  d="M11 21h-1l1-7H7.5c-.58 0-.57-.32-.38-.66.19-.34.05-.08.07-.12C8.48 10.94 10.42 7.54 13 3h1l-1 7h3.5c.49 0 .56.33.47.51l-.07.15C12.96 17.55 11 21 11 21z"
150
203
  fill="url(#dsh-sp-bolt-grad)"
151
204
  stroke="var(--dsw-static-deepseek-600, rgb(72, 104, 178))"
152
- strokeWidth="0.8"
205
+ strokeWidth="1"
153
206
  strokeLinejoin="round"
154
207
  />
155
208
  </svg>
@@ -167,7 +220,9 @@ function SkillPickerButton(props) {
167
220
  const [error, setError] = useState(undefined)
168
221
  const [query, setQuery] = useState('')
169
222
  const [usage, setUsage] = useState(() => loadUsage())
223
+ const [active, setActive] = useState(0)
170
224
  const boxRef = useRef(null)
225
+ const itemRefs = useRef([])
171
226
 
172
227
  const load = useCallback(async () => {
173
228
  if (skills !== undefined || error !== undefined) return
@@ -243,27 +298,53 @@ function SkillPickerButton(props) {
243
298
  return () => document.removeEventListener('mousedown', onDown)
244
299
  }, [open])
245
300
 
246
- // Usage ordering: last picked first, then most frequent, then by name.
247
- const ordered = (skills ?? []).slice().sort((a, b) => {
248
- const ua = usage[a.name]
249
- const ub = usage[b.name]
250
- const la = ua?.lastUsed ?? 0
251
- const lb = ub?.lastUsed ?? 0
252
- if (la !== lb) return lb - la
253
- const ca = ua?.count ?? 0
254
- const cb = ub?.count ?? 0
255
- if (ca !== cb) return cb - ca
256
- return a.name.localeCompare(b.name)
257
- })
301
+ // Usage ordering: last picked first, then most frequent, then by name
302
+ // (shared with the `/` completion one rule everywhere).
303
+ const ordered = rankByUsage(skills ?? [], usage)
258
304
 
259
305
  const filtered = ordered
260
306
  .filter((skill) => {
261
307
  const q = query.trim().toLowerCase()
262
308
  if (q === '') return true
263
- return skill.name.toLowerCase().includes(q) || String(skill.description ?? '').toLowerCase().includes(q)
309
+ return (
310
+ skill.name.toLowerCase().includes(q) ||
311
+ String(skill.description ?? '').toLowerCase().includes(q) ||
312
+ skillPinyinText(skill.name, skill.description ?? '').toLowerCase().includes(q)
313
+ )
264
314
  })
265
315
  .slice(0, 60)
266
316
 
317
+ // Keyboard navigation (#1): reset highlight when the query changes, keep it
318
+ // in range when the result list shrinks, and keep the highlighted row visible.
319
+ useEffect(() => {
320
+ setActive(0)
321
+ }, [query])
322
+
323
+ useEffect(() => {
324
+ setActive((cur) => Math.min(cur, Math.max(0, filtered.length - 1)))
325
+ }, [filtered.length])
326
+
327
+ useEffect(() => {
328
+ itemRefs.current[active]?.scrollIntoView({ block: 'nearest' })
329
+ }, [active, filtered.length])
330
+
331
+ const onKeyDown = (event) => {
332
+ if (event.key === 'ArrowDown') {
333
+ event.preventDefault()
334
+ setActive((i) => Math.min(i + 1, filtered.length - 1))
335
+ } else if (event.key === 'ArrowUp') {
336
+ event.preventDefault()
337
+ setActive((i) => Math.max(i - 1, 0))
338
+ } else if (event.key === 'Enter') {
339
+ event.preventDefault()
340
+ const skill = filtered[active]
341
+ if (skill !== undefined) pick(skill.name)
342
+ } else if (event.key === 'Escape') {
343
+ event.preventDefault()
344
+ setOpen(false)
345
+ }
346
+ }
347
+
267
348
  return (
268
349
  <div ref={boxRef} style={{ position: 'relative', display: 'inline-flex', flex: 'none' }}>
269
350
  <button
@@ -283,7 +364,8 @@ function SkillPickerButton(props) {
283
364
  <input
284
365
  value={query}
285
366
  onChange={(event) => setQuery(event.target.value)}
286
- placeholder="搜索技能…"
367
+ onKeyDown={onKeyDown}
368
+ placeholder="搜索技能…(↑↓ 选择,Enter 插入)"
287
369
  style={searchStyle}
288
370
  autoFocus
289
371
  />
@@ -296,18 +378,27 @@ function SkillPickerButton(props) {
296
378
  {filtered.length === 0 ? (
297
379
  <div style={statusStyle}>没有匹配的技能</div>
298
380
  ) : (
299
- filtered.map((skill) => (
381
+ filtered.map((skill, index) => (
300
382
  <button
301
383
  key={skill.name}
302
384
  type="button"
385
+ ref={(el) => {
386
+ itemRefs.current[index] = el
387
+ }}
303
388
  onClick={() => pick(skill.name)}
304
389
  onMouseEnter={(event) => {
390
+ setActive(index)
305
391
  event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))'
306
392
  }}
307
393
  onMouseLeave={(event) => {
308
394
  event.currentTarget.style.background = 'transparent'
309
395
  }}
310
- style={itemStyle}
396
+ style={{
397
+ ...itemStyle,
398
+ ...(index === active
399
+ ? { background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))' }
400
+ : {}),
401
+ }}
311
402
  >
312
403
  <span style={nameStyle}>{`/${skill.name}`}</span>
313
404
  <span style={descStyle}>{skill.description ?? ''}</span>
@@ -375,6 +466,25 @@ export function apply(ctx) {
375
466
  // official ui-skill prefix source too — typing `/` matches name AND
376
467
  // description anywhere, with usage ordering (same rule as the ⚡ panel).
377
468
  ctx.effect(() => {
469
+ // Skills cache per session, so both candidates and the lexicon (chip
470
+ // decoration of `/skill-name` in the draft) have the same names.
471
+ const namesCache = new Map() // sessionId -> string[] (skill names)
472
+ const lexiconListeners = new Map() // sessionId -> Set<listener>
473
+ const notifyLexicon = (sessionId) => {
474
+ for (const fn of [...(lexiconListeners.get(sessionId) ?? [])]) {
475
+ try { fn() } catch (err) { console.error('[dsh-skill-picker] lexicon listener failed:', err) }
476
+ }
477
+ }
478
+ const refreshNames = async (sessionId) => {
479
+ try {
480
+ const skills = await listSkills(sessionId)
481
+ namesCache.set(sessionId, (Array.isArray(skills) ? skills : []).map((s) => s.name))
482
+ notifyLexicon(sessionId)
483
+ } catch {
484
+ /* keep last cache; lexicon stays empty until a successful fetch */
485
+ }
486
+ }
487
+
378
488
  const source = {
379
489
  trigger: '/',
380
490
  name: 'skill-fuzzy',
@@ -382,43 +492,70 @@ export function apply(ctx) {
382
492
  async candidates(session, { query, signal }) {
383
493
  const skills = await listSkills(session.sessionId)
384
494
  if (signal.aborted) return []
385
- const usage = loadUsage()
386
- // Usage ordering: last picked first, then most frequent, then by name
387
- // (same rule as the ⚡ panel, read fresh so picks show up immediately).
388
- const ordered = skills.slice().sort((a, b) => {
389
- const ua = usage[a.name]
390
- const ub = usage[b.name]
391
- const la = ua?.lastUsed ?? 0
392
- const lb = ub?.lastUsed ?? 0
393
- if (la !== lb) return lb - la
394
- const ca = ua?.count ?? 0
395
- const cb = ub?.count ?? 0
396
- if (ca !== cb) return cb - ca
397
- return a.name.localeCompare(b.name)
398
- })
495
+ namesCache.set(session.sessionId, skills.map((s) => s.name))
496
+ notifyLexicon(session.sessionId)
497
+ const ordered = rankByUsage(skills, loadUsage())
399
498
  const q = String(query ?? '').trim().toLowerCase()
400
- const matches =
401
- q === ''
402
- ? ordered
403
- : ordered.filter(
404
- (s) =>
405
- s.name.toLowerCase().includes(q) ||
406
- String(s.description ?? '').toLowerCase().includes(q),
407
- )
408
- // Empty `/` shows ALL skills (usage-ordered); typing fuzzy-filters.
409
- // Keep a generous cap so very large skill sets stay snappy.
410
- return matches.slice(0, 50).map((s) => ({ name: s.name, description: s.description }))
499
+ if (q === '') {
500
+ // Empty `/` surfaces ALL skills, usage first (same rule as ⚡ panel).
501
+ return ordered.map((s) => ({ name: s.name, description: s.description }))
502
+ }
503
+ // Typing uses fuzzysort (subsequence matching + relevance score) over
504
+ // name AND description, so partial/gappy queries and keywords match.
505
+ // The search string also carries the skill's pinyin forms (spaced,
506
+ // joined, initials — name and description), so `ji yi` / `jiyi` / `jy`
507
+ // match Chinese skill names & descriptions.
508
+ const targets = ordered.map((s) => ({
509
+ s,
510
+ search: `${s.name} ${s.description ?? ''} ${skillPinyinText(s.name, s.description ?? '')}`,
511
+ }))
512
+ const results = fuzzysort.go(q, targets, {
513
+ key: 'search',
514
+ limit: 12,
515
+ threshold: -10000,
516
+ })
517
+ return results
518
+ .filter((r) => r.score > 0)
519
+ .map((r) => ({ name: r.obj.s.name, description: r.obj.s.description }))
411
520
  },
412
521
  warm(session) {
413
- listSkills(session.sessionId).catch(() => {})
522
+ refreshNames(session.sessionId)
523
+ },
524
+ lexicon(session) {
525
+ return namesCache.get(session.sessionId)
526
+ },
527
+ subscribeLexicon(session, listener) {
528
+ const key = session.sessionId
529
+ const set = lexiconListeners.get(key) ?? new Set()
530
+ set.add(listener)
531
+ lexiconListeners.set(key, set)
532
+ return () => {
533
+ const cur = lexiconListeners.get(key)
534
+ if (cur !== undefined) {
535
+ cur.delete(listener)
536
+ if (cur.size === 0) lexiconListeners.delete(key)
537
+ }
538
+ }
414
539
  },
415
540
  onPick({ candidate }) {
541
+ // Record usage (same rule as the ⚡ panel) so slash-picked skills
542
+ // rank higher the next time they open `/`.
543
+ try {
544
+ const usage = loadUsage()
545
+ const name = candidate.name
546
+ const next = { ...usage, [name]: { count: (usage[name]?.count ?? 0) + 1, lastUsed: Date.now() } }
547
+ saveUsage(next)
548
+ } catch {
549
+ /* usage recording is best-effort */
550
+ }
416
551
  return { text: `/${candidate.name} ` }
417
552
  },
418
553
  }
419
554
  const unregister = ctx.inputTriggers.registerSource(source)
420
555
  return () => {
421
556
  unregister()
557
+ namesCache.clear()
558
+ lexiconListeners.clear()
422
559
  }
423
560
  }, 'dsh-skill-picker: fuzzy / source')
424
561
  }