dsh-skill-picker 0.5.11 → 0.5.13
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/README.md +9 -2
- package/lib/client.js +43 -14
- package/lib/client.js.map +3 -3
- package/lib/index.js +97 -5
- package/lib/index.js.map +2 -2
- package/package.json +12 -8
- package/src/client/index.jsx +758 -717
- package/src/client/session-view.js +68 -0
- package/src/index.js +91 -3
- package/src/patch-ui-skill.js +110 -5
package/src/client/index.jsx
CHANGED
|
@@ -1,717 +1,758 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-skill-picker — browser half: mounts a skill-picker control into the
|
|
3
|
-
* composer's right tool row (`conversation.input.right` list slot, the seat
|
|
4
|
-
* just before the send button). Clicking it opens a searchable list of
|
|
5
|
-
* installed skills (fetched from the host route `/dsh-skill-picker/skills`),
|
|
6
|
-
* ordered by usage: recently picked first, then frequently used, then the
|
|
7
|
-
* untouched rest (by name). Picking one inserts the official `/skill-name`
|
|
8
|
-
* gesture into the draft via the framework input machine
|
|
9
|
-
* (`inputActions.setDraft`), so DSH's native user-invocation path loads the
|
|
10
|
-
* skill with the message.
|
|
11
|
-
*
|
|
12
|
-
* All DOM/runtime wiring failures are logged, never thrown — the web shell
|
|
13
|
-
* fails the whole boot when a plugin apply throws.
|
|
14
|
-
*
|
|
15
|
-
* @module dsh-skill-picker/client
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
|
19
|
-
import fuzzysort from 'fuzzysort'
|
|
20
|
-
import { pinyin } from 'pinyin-pro'
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
return []
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return {}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
*
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
background: 'var(--dsw-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
ctx.
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* dsh-skill-picker — browser half: mounts a skill-picker control into the
|
|
3
|
+
* composer's right tool row (`conversation.input.right` list slot, the seat
|
|
4
|
+
* just before the send button). Clicking it opens a searchable list of
|
|
5
|
+
* installed skills (fetched from the host route `/dsh-skill-picker/skills`),
|
|
6
|
+
* ordered by usage: recently picked first, then frequently used, then the
|
|
7
|
+
* untouched rest (by name). Picking one inserts the official `/skill-name`
|
|
8
|
+
* gesture into the draft via the framework input machine
|
|
9
|
+
* (`inputActions.setDraft`), so DSH's native user-invocation path loads the
|
|
10
|
+
* skill with the message.
|
|
11
|
+
*
|
|
12
|
+
* All DOM/runtime wiring failures are logged, never thrown — the web shell
|
|
13
|
+
* fails the whole boot when a plugin apply throws.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-skill-picker/client
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import React, { Fragment, useCallback, useEffect, useRef, useState } from 'react'
|
|
19
|
+
import fuzzysort from 'fuzzysort'
|
|
20
|
+
import { pinyin } from 'pinyin-pro'
|
|
21
|
+
|
|
22
|
+
import { sessionIdOf, useWorkspaceCwd } from './session-view.js'
|
|
23
|
+
|
|
24
|
+
/** Required services: slot registry, host connection (official skills API), sessions (workspace cwd fallback), input triggers (/ fuzzy source). */
|
|
25
|
+
export const inject = ['slots', 'connection', 'sessions', 'inputTriggers']
|
|
26
|
+
|
|
27
|
+
/** localStorage key for the picker's per-browser usage history. */
|
|
28
|
+
const USAGE_KEY = 'dsh-skill-picker:usage'
|
|
29
|
+
|
|
30
|
+
/** localStorage key for the user's manually pinned skills (ordered array of names). */
|
|
31
|
+
const PINNED_KEY = 'dsh-skill-picker:pinned'
|
|
32
|
+
|
|
33
|
+
/** Read the pinned list {string[]}; never throws. */
|
|
34
|
+
function loadPinned() {
|
|
35
|
+
try {
|
|
36
|
+
const raw = localStorage.getItem(PINNED_KEY)
|
|
37
|
+
if (raw === null) return []
|
|
38
|
+
const parsed = JSON.parse(raw)
|
|
39
|
+
return Array.isArray(parsed) ? parsed.filter((name) => typeof name === 'string') : []
|
|
40
|
+
} catch {
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Persist the pinned list; never throws. */
|
|
46
|
+
function savePinned(pinned) {
|
|
47
|
+
try {
|
|
48
|
+
localStorage.setItem(PINNED_KEY, JSON.stringify(pinned))
|
|
49
|
+
} catch {
|
|
50
|
+
/* storage unavailable — pinning just won't persist */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Read the usage history {name: {count, lastUsed}}; never throws. */
|
|
55
|
+
function loadUsage() {
|
|
56
|
+
try {
|
|
57
|
+
const raw = localStorage.getItem(USAGE_KEY)
|
|
58
|
+
if (raw === null) return {}
|
|
59
|
+
const parsed = JSON.parse(raw)
|
|
60
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {}
|
|
61
|
+
} catch {
|
|
62
|
+
return {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Persist the usage history; never throws. */
|
|
67
|
+
function saveUsage(usage) {
|
|
68
|
+
try {
|
|
69
|
+
localStorage.setItem(USAGE_KEY, JSON.stringify(usage))
|
|
70
|
+
} catch {
|
|
71
|
+
/* storage unavailable — ordering just won't persist */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether a skill entry may be offered by a human-facing surface (this panel
|
|
77
|
+
* and the `/` completion it feeds). The official `skills/list` DTO
|
|
78
|
+
* (`SkillEntry`) carries only `modelInvocable` — the host has already filtered
|
|
79
|
+
* user-invocable skills out of it — but the registry summary nests both flags
|
|
80
|
+
* under `invocation`. Accept either shape and hide only an explicit `false`,
|
|
81
|
+
* so an omitted flag keeps the historical behaviour (issue #10).
|
|
82
|
+
*/
|
|
83
|
+
function isUserFacingSkill(skill) {
|
|
84
|
+
const flag = skill?.userInvocable ?? skill?.invocation?.userInvocable
|
|
85
|
+
return flag !== false
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Shared usage ordering — the single rule used by BOTH the ⚡ panel and the
|
|
90
|
+
* `/` completion: last picked first, then most frequent, then by name.
|
|
91
|
+
* A fresh copy is returned; the input array is untouched.
|
|
92
|
+
*/
|
|
93
|
+
function rankByUsage(skills, usage) {
|
|
94
|
+
return skills.slice().sort((a, b) => {
|
|
95
|
+
const ua = usage[a.name]
|
|
96
|
+
const ub = usage[b.name]
|
|
97
|
+
const la = ua?.lastUsed ?? 0
|
|
98
|
+
const lb = ub?.lastUsed ?? 0
|
|
99
|
+
if (la !== lb) return lb - la
|
|
100
|
+
const ca = ua?.count ?? 0
|
|
101
|
+
const cb = ub?.count ?? 0
|
|
102
|
+
if (ca !== cb) return cb - ca
|
|
103
|
+
return a.name.localeCompare(b.name)
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Grouped ordering for the ⚡ panel: pinned (manual, pinned order) first,
|
|
109
|
+
* then recently/frequently used (usage order), then the untouched rest (by
|
|
110
|
+
* name). Groups with no members are dropped. A fresh structure is returned;
|
|
111
|
+
* the input arrays are untouched.
|
|
112
|
+
*/
|
|
113
|
+
function groupByPinned(skills, usage, pinned) {
|
|
114
|
+
const pinnedSet = new Set(pinned)
|
|
115
|
+
const pinnedList = pinned.map((name) => skills.find((s) => s.name === name)).filter(Boolean)
|
|
116
|
+
const ranked = rankByUsage(skills, usage)
|
|
117
|
+
const recent = []
|
|
118
|
+
const rest = []
|
|
119
|
+
for (const skill of ranked) {
|
|
120
|
+
if (pinnedSet.has(skill.name)) continue
|
|
121
|
+
if (usage[skill.name] !== undefined) recent.push(skill)
|
|
122
|
+
else rest.push(skill)
|
|
123
|
+
}
|
|
124
|
+
return [
|
|
125
|
+
{ title: '📌 置顶', items: pinnedList },
|
|
126
|
+
{ title: '🔥 最近使用', items: recent },
|
|
127
|
+
{ title: '🗂️ 全部', items: rest },
|
|
128
|
+
].filter((group) => group.items.length > 0)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Pinyin search text for a skill name/description: spaced full pinyin
|
|
133
|
+
* (`ji yi`), joined (`jiyi`), and initials (`jy`) for the name, plus joined
|
|
134
|
+
* full pinyin and initials for the description — so either the `/` fuzzy
|
|
135
|
+
* completion or the ⚡ panel matches queries like `ji yi`, `jiyi`, or `jy`
|
|
136
|
+
* against 记忆/知识库/每日打卡-ish Chinese text. Cached per (name, desc)
|
|
137
|
+
* pair; never throws (falls back to '').
|
|
138
|
+
*/
|
|
139
|
+
const pinyinCache = new Map()
|
|
140
|
+
function skillPinyinText(name, description = '') {
|
|
141
|
+
const key = `${name}\u0000${description}`
|
|
142
|
+
const cached = pinyinCache.get(key)
|
|
143
|
+
if (cached !== undefined) return cached
|
|
144
|
+
let text = ''
|
|
145
|
+
try {
|
|
146
|
+
const base = { toneType: 'none', nonZh: 'consecutive' }
|
|
147
|
+
const nameSpaced = pinyin(name, base)
|
|
148
|
+
const nameJoined = pinyin(name, { ...base, separator: '' })
|
|
149
|
+
const nameFirstSpaced = pinyin(name, { ...base, pattern: 'first' })
|
|
150
|
+
const nameFirstJoined = pinyin(name, { ...base, pattern: 'first', separator: '' })
|
|
151
|
+
const descSpaced = pinyin(description, base)
|
|
152
|
+
const descJoined = pinyin(description, { ...base, separator: '' })
|
|
153
|
+
const descFirstSpaced = pinyin(description, { ...base, pattern: 'first' })
|
|
154
|
+
const descFirstJoined = pinyin(description, { ...base, pattern: 'first', separator: '' })
|
|
155
|
+
text = `${nameSpaced} ${nameJoined} ${nameFirstSpaced} ${nameFirstJoined} ${descSpaced} ${descJoined} ${descFirstSpaced} ${descFirstJoined}`
|
|
156
|
+
} catch {
|
|
157
|
+
text = ''
|
|
158
|
+
}
|
|
159
|
+
pinyinCache.set(key, text)
|
|
160
|
+
return text
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Search relevance rank for the ⚡ panel (and any exact-match filtering):
|
|
165
|
+
* 0 = name starts with the query, 1 = name contains it, 2 = description
|
|
166
|
+
* contains it, 3 = pinyin text contains it, 4 = no match (filtered out).
|
|
167
|
+
* Lower is more relevant; the panel keeps the pinned/usage order as the
|
|
168
|
+
* tiebreak within the same rank, so a name-exact skill like svg-diagram
|
|
169
|
+
* surfaces above merely-used-but-vaguely-matching ones.
|
|
170
|
+
*/
|
|
171
|
+
function matchRank(skill, q) {
|
|
172
|
+
const name = skill.name.toLowerCase()
|
|
173
|
+
const desc = String(skill.description ?? '').toLowerCase()
|
|
174
|
+
const py = skillPinyinText(skill.name, skill.description ?? '').toLowerCase()
|
|
175
|
+
if (name.startsWith(q)) return 0
|
|
176
|
+
if (name.includes(q)) return 1
|
|
177
|
+
if (desc.includes(q)) return 2
|
|
178
|
+
if (py.includes(q)) return 3
|
|
179
|
+
return 4
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Row height matches the resident chrome (access mode, plan, attach, model). */
|
|
183
|
+
const buttonStyle = {
|
|
184
|
+
display: 'inline-flex',
|
|
185
|
+
alignItems: 'center',
|
|
186
|
+
justifyContent: 'center',
|
|
187
|
+
width: '24px',
|
|
188
|
+
height: '24px',
|
|
189
|
+
margin: '0 2px',
|
|
190
|
+
padding: '0',
|
|
191
|
+
border: '1px solid var(--dsw-alias-border-l2, rgba(128,128,128,0.25))',
|
|
192
|
+
borderRadius: '8px',
|
|
193
|
+
background: 'transparent',
|
|
194
|
+
color: 'var(--dsw-alias-label-secondary, #c9d2e0)',
|
|
195
|
+
cursor: 'pointer',
|
|
196
|
+
fontSize: '15px',
|
|
197
|
+
lineHeight: '1',
|
|
198
|
+
flex: 'none',
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const popoverStyle = {
|
|
202
|
+
position: 'absolute',
|
|
203
|
+
bottom: 'calc(100% + 8px)',
|
|
204
|
+
right: '0',
|
|
205
|
+
width: '340px',
|
|
206
|
+
maxHeight: '320px',
|
|
207
|
+
display: 'flex',
|
|
208
|
+
flexDirection: 'column',
|
|
209
|
+
background: 'var(--dsw-specific-tip, #1e2533)',
|
|
210
|
+
border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.35))',
|
|
211
|
+
borderRadius: '12px',
|
|
212
|
+
boxShadow: '0 8px 28px rgba(0,0,0,0.35)',
|
|
213
|
+
overflow: 'hidden',
|
|
214
|
+
zIndex: 1000,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const searchStyle = {
|
|
218
|
+
boxSizing: 'border-box',
|
|
219
|
+
width: 'calc(100% - 16px)',
|
|
220
|
+
margin: '8px',
|
|
221
|
+
padding: '6px 10px',
|
|
222
|
+
border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3))',
|
|
223
|
+
borderRadius: '8px',
|
|
224
|
+
background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.1))',
|
|
225
|
+
color: 'var(--dsw-alias-label-primary, #e6ebf2)',
|
|
226
|
+
fontSize: '13px',
|
|
227
|
+
outline: 'none',
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const listStyle = {
|
|
231
|
+
overflowY: 'auto',
|
|
232
|
+
flex: 'auto',
|
|
233
|
+
padding: '0 6px 8px',
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const itemStyle = {
|
|
237
|
+
display: 'flex',
|
|
238
|
+
flexDirection: 'column',
|
|
239
|
+
alignItems: 'flex-start',
|
|
240
|
+
gap: '2px',
|
|
241
|
+
width: '100%',
|
|
242
|
+
padding: '7px 10px',
|
|
243
|
+
border: 'none',
|
|
244
|
+
borderRadius: '8px',
|
|
245
|
+
background: 'transparent',
|
|
246
|
+
color: 'var(--dsw-alias-label-primary, #e6ebf2)',
|
|
247
|
+
cursor: 'pointer',
|
|
248
|
+
textAlign: 'left',
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const nameStyle = {
|
|
252
|
+
fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)',
|
|
253
|
+
fontSize: '13px',
|
|
254
|
+
fontWeight: 500,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const descStyle = {
|
|
258
|
+
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
259
|
+
fontSize: '12px',
|
|
260
|
+
lineHeight: '16px',
|
|
261
|
+
overflow: 'hidden',
|
|
262
|
+
textOverflow: 'ellipsis',
|
|
263
|
+
whiteSpace: 'nowrap',
|
|
264
|
+
maxWidth: '100%',
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const statusStyle = {
|
|
268
|
+
padding: '12px',
|
|
269
|
+
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
270
|
+
fontSize: '13px',
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Lightweight source badge shown only when the list came from the host scan fallback (official API unavailable). */
|
|
274
|
+
const sourceBadgeStyle = {
|
|
275
|
+
display: 'inline-flex',
|
|
276
|
+
alignItems: 'center',
|
|
277
|
+
alignSelf: 'flex-start',
|
|
278
|
+
margin: '0 8px 8px',
|
|
279
|
+
padding: '2px 8px',
|
|
280
|
+
border: '1px solid rgba(255, 193, 7, 0.35)',
|
|
281
|
+
borderRadius: '999px',
|
|
282
|
+
background: 'rgba(255, 193, 7, 0.1)',
|
|
283
|
+
color: '#d9a520',
|
|
284
|
+
fontSize: '11px',
|
|
285
|
+
lineHeight: '16px',
|
|
286
|
+
flex: 'none',
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const sourceBadgeTextStyle = {
|
|
290
|
+
fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)',
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The picker's bolt glyph: DeepSeek palette gradient + slim stroke. */
|
|
294
|
+
function BoltIcon() {
|
|
295
|
+
return (
|
|
296
|
+
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" style={{ display: 'block' }}>
|
|
297
|
+
<defs>
|
|
298
|
+
<linearGradient id="dsh-sp-bolt-grad" x1="0" y1="0" x2="0" y2="1">
|
|
299
|
+
<stop offset="0%" stopColor="var(--dsw-static-deepseek-400, rgb(103, 158, 254))" />
|
|
300
|
+
<stop offset="100%" stopColor="var(--dsw-static-deepseek-600, rgb(72, 104, 178))" />
|
|
301
|
+
</linearGradient>
|
|
302
|
+
</defs>
|
|
303
|
+
<path
|
|
304
|
+
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"
|
|
305
|
+
fill="url(#dsh-sp-bolt-grad)"
|
|
306
|
+
stroke="var(--dsw-static-deepseek-600, rgb(72, 104, 178))"
|
|
307
|
+
strokeWidth="1"
|
|
308
|
+
strokeLinejoin="round"
|
|
309
|
+
/>
|
|
310
|
+
</svg>
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The picker control for the composer's right tool row. Rendered by the slot
|
|
316
|
+
* renderer with the composed props: owner InputZone (`session`, `input`) plus
|
|
317
|
+
* the framework session kit (`useInput`, `inputActions`).
|
|
318
|
+
*/
|
|
319
|
+
function SkillPickerButton(props) {
|
|
320
|
+
const [open, setOpen] = useState(false)
|
|
321
|
+
const [skills, setSkills] = useState(undefined)
|
|
322
|
+
const [error, setError] = useState(undefined)
|
|
323
|
+
const [source, setSource] = useState(undefined)
|
|
324
|
+
const [query, setQuery] = useState('')
|
|
325
|
+
const [usage, setUsage] = useState(() => loadUsage())
|
|
326
|
+
const [pinned, setPinned] = useState(() => loadPinned())
|
|
327
|
+
const [active, setActive] = useState(0)
|
|
328
|
+
const boxRef = useRef(null)
|
|
329
|
+
const itemRefs = useRef([])
|
|
330
|
+
|
|
331
|
+
// The usage store is shared with the official `/` menu: picks made there go
|
|
332
|
+
// through window.__dshSkillPickerTrack (localStorage only). Refresh this
|
|
333
|
+
// panel's state from storage whenever that event fires, so a slash pick
|
|
334
|
+
// shows up as "recently used" here too — not just in the slash list.
|
|
335
|
+
useEffect(() => {
|
|
336
|
+
const onUsageUpdated = () => setUsage(loadUsage())
|
|
337
|
+
window.addEventListener('dsh-skill-picker:usage-updated', onUsageUpdated)
|
|
338
|
+
return () => window.removeEventListener('dsh-skill-picker:usage-updated', onUsageUpdated)
|
|
339
|
+
}, [])
|
|
340
|
+
|
|
341
|
+
// Latest draft mirror: `useInput` is a selector hook and may only be called
|
|
342
|
+
// during render, while the pick handler runs from a click callback. Sync the
|
|
343
|
+
// store's current draft into a ref here (render time), so the click handler
|
|
344
|
+
// appends onto the REAL current draft instead of a stale snapshot. The
|
|
345
|
+
// owner-provided `input` snapshot is a secondary fallback only.
|
|
346
|
+
const draftRef = useRef('')
|
|
347
|
+
if (typeof props.useInput === 'function') {
|
|
348
|
+
try {
|
|
349
|
+
const state = props.useInput((s) => s)
|
|
350
|
+
if (state !== undefined && typeof state.draft === 'string') draftRef.current = state.draft
|
|
351
|
+
} catch {
|
|
352
|
+
/* keep the last known draft */
|
|
353
|
+
}
|
|
354
|
+
} else if (props.input !== undefined && typeof props.input.draft === 'string') {
|
|
355
|
+
draftRef.current = props.input.draft
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const load = useCallback(async () => {
|
|
359
|
+
if (skills !== undefined || error !== undefined) return
|
|
360
|
+
// Session identity comes from the slot's standard props; older kernels
|
|
361
|
+
// carried it as `props.session` (see ./session-view.js).
|
|
362
|
+
const sessionId = sessionIdOf(props)
|
|
363
|
+
try {
|
|
364
|
+
// Primary path: the official host skills API (same source as DSH's own
|
|
365
|
+
// `/` completion — session-scoped, covers user + project level).
|
|
366
|
+
if (typeof props.listSkills === 'function' && sessionId !== undefined) {
|
|
367
|
+
const listed = await props.listSkills(sessionId)
|
|
368
|
+
setSkills(Array.isArray(listed) ? listed : [])
|
|
369
|
+
setSource('official')
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
} catch (cause) {
|
|
373
|
+
console.warn('[dsh-skill-picker] official skills API failed, falling back to host route:', cause)
|
|
374
|
+
}
|
|
375
|
+
// Fallback path: the host's own scan route (official provider roots).
|
|
376
|
+
try {
|
|
377
|
+
const cwd = typeof props.cwd === 'string' && props.cwd !== '' ? `?cwd=${encodeURIComponent(props.cwd)}` : ''
|
|
378
|
+
const res = await fetch(`/dsh-skill-picker/skills${cwd}`, { headers: { accept: 'application/json' } })
|
|
379
|
+
const json = await res.json()
|
|
380
|
+
if (!json.ok) throw new Error(json.error || 'bad response')
|
|
381
|
+
setSkills((Array.isArray(json.skills) ? json.skills : []).filter(isUserFacingSkill))
|
|
382
|
+
setSource('host')
|
|
383
|
+
} catch (cause) {
|
|
384
|
+
setError(String(cause?.message ?? cause))
|
|
385
|
+
}
|
|
386
|
+
}, [skills, error, props.listSkills, props.sessionId, props.session, props.cwd])
|
|
387
|
+
|
|
388
|
+
const toggle = () => {
|
|
389
|
+
if (!open) {
|
|
390
|
+
setUsage(loadUsage())
|
|
391
|
+
void load()
|
|
392
|
+
}
|
|
393
|
+
setOpen(!open)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const pick = (name) => {
|
|
397
|
+
// Draft source: the render-time mirror of the live input store (see the
|
|
398
|
+
// `draftRef` sync above). Appending onto anything else risks overwriting
|
|
399
|
+
// the user's typed draft with a stale snapshot.
|
|
400
|
+
const draft = draftRef.current
|
|
401
|
+
const separator = draft === '' || draft.endsWith(' ') || draft.endsWith('\n') ? '' : ' '
|
|
402
|
+
const next = `${draft}${separator}/${name} `
|
|
403
|
+
try {
|
|
404
|
+
if (typeof props.inputActions?.setDraft === 'function') {
|
|
405
|
+
props.inputActions.setDraft(next)
|
|
406
|
+
} else {
|
|
407
|
+
console.error('[dsh-skill-picker] inputActions.setDraft unavailable; draft not written:', next)
|
|
408
|
+
}
|
|
409
|
+
} catch (cause) {
|
|
410
|
+
console.error('[dsh-skill-picker] setDraft failed:', cause)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Record usage for ordering (recent first, then frequent).
|
|
414
|
+
const nextUsage = { ...usage, [name]: { count: (usage[name]?.count ?? 0) + 1, lastUsed: Date.now() } }
|
|
415
|
+
setUsage(nextUsage)
|
|
416
|
+
saveUsage(nextUsage)
|
|
417
|
+
|
|
418
|
+
setOpen(false)
|
|
419
|
+
setQuery('')
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const togglePin = (name) => {
|
|
423
|
+
const next = pinned.includes(name) ? pinned.filter((n) => n !== name) : [...pinned, name]
|
|
424
|
+
setPinned(next)
|
|
425
|
+
savePinned(next)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Close on outside pointer-down (the shell's menu convention).
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
if (!open) return
|
|
431
|
+
const onDown = (event) => {
|
|
432
|
+
if (boxRef.current !== null && !boxRef.current.contains(event.target)) setOpen(false)
|
|
433
|
+
}
|
|
434
|
+
document.addEventListener('mousedown', onDown)
|
|
435
|
+
return () => document.removeEventListener('mousedown', onDown)
|
|
436
|
+
}, [open])
|
|
437
|
+
|
|
438
|
+
// Grouped ordering: pinned first (manual), then usage-ranked (recent then
|
|
439
|
+
// frequent then untouched by name). Shared rule with the `/` completion.
|
|
440
|
+
const groups = groupByPinned(skills ?? [], usage, pinned)
|
|
441
|
+
const flat = groups.flatMap((group) => group.items)
|
|
442
|
+
|
|
443
|
+
const filtered = (() => {
|
|
444
|
+
const q = query.trim().toLowerCase()
|
|
445
|
+
if (q === '') return flat.slice(0, 60)
|
|
446
|
+
return flat
|
|
447
|
+
.map((skill, index) => ({ skill, index, score: matchRank(skill, q) }))
|
|
448
|
+
.filter((x) => x.score < 4)
|
|
449
|
+
.sort((a, b) => a.score - b.score || a.index - b.index)
|
|
450
|
+
.slice(0, 60)
|
|
451
|
+
.map((x) => x.skill)
|
|
452
|
+
})()
|
|
453
|
+
|
|
454
|
+
// Group titles show only while browsing (no query); searching collapses the
|
|
455
|
+
// list into one flat, pinned-first result set.
|
|
456
|
+
const showTitles = query.trim() === '' && groups.length > 1
|
|
457
|
+
const filteredNames = new Set(filtered.map((skill) => skill.name))
|
|
458
|
+
|
|
459
|
+
// Keyboard navigation (#1): reset highlight when the query changes, keep it
|
|
460
|
+
// in range when the result list shrinks, and keep the highlighted row visible.
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
setActive(0)
|
|
463
|
+
}, [query])
|
|
464
|
+
|
|
465
|
+
useEffect(() => {
|
|
466
|
+
setActive((cur) => Math.min(cur, Math.max(0, filtered.length - 1)))
|
|
467
|
+
}, [filtered.length])
|
|
468
|
+
|
|
469
|
+
useEffect(() => {
|
|
470
|
+
itemRefs.current[active]?.scrollIntoView({ block: 'nearest' })
|
|
471
|
+
}, [active, filtered.length])
|
|
472
|
+
|
|
473
|
+
const onKeyDown = (event) => {
|
|
474
|
+
if (event.key === 'ArrowDown') {
|
|
475
|
+
event.preventDefault()
|
|
476
|
+
setActive((i) => Math.min(i + 1, filtered.length - 1))
|
|
477
|
+
} else if (event.key === 'ArrowUp') {
|
|
478
|
+
event.preventDefault()
|
|
479
|
+
setActive((i) => Math.max(i - 1, 0))
|
|
480
|
+
} else if (event.key === 'Enter') {
|
|
481
|
+
event.preventDefault()
|
|
482
|
+
const skill = filtered[active]
|
|
483
|
+
if (skill !== undefined) pick(skill.name)
|
|
484
|
+
} else if (event.key === 'Escape') {
|
|
485
|
+
event.preventDefault()
|
|
486
|
+
setOpen(false)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return (
|
|
491
|
+
<div ref={boxRef} style={{ position: 'relative', display: 'inline-flex', flex: 'none' }}>
|
|
492
|
+
<button
|
|
493
|
+
type="button"
|
|
494
|
+
onClick={toggle}
|
|
495
|
+
title="选择技能(插入 /技能名 到发送框)"
|
|
496
|
+
aria-label="选择技能"
|
|
497
|
+
style={{
|
|
498
|
+
...buttonStyle,
|
|
499
|
+
...(open ? { color: 'var(--dsw-alias-label-primary-bluish, #4cc9f0)' } : {}),
|
|
500
|
+
}}
|
|
501
|
+
>
|
|
502
|
+
<BoltIcon />
|
|
503
|
+
</button>
|
|
504
|
+
{open && (
|
|
505
|
+
<div style={popoverStyle}>
|
|
506
|
+
<input
|
|
507
|
+
value={query}
|
|
508
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
509
|
+
onKeyDown={onKeyDown}
|
|
510
|
+
placeholder="搜索技能…(↑↓ 选择,Enter 插入)"
|
|
511
|
+
style={searchStyle}
|
|
512
|
+
autoFocus
|
|
513
|
+
/>
|
|
514
|
+
{error !== undefined ? (
|
|
515
|
+
<div style={statusStyle}>{`加载失败:${error}`}</div>
|
|
516
|
+
) : skills === undefined ? (
|
|
517
|
+
<div style={statusStyle}>加载中…</div>
|
|
518
|
+
) : (
|
|
519
|
+
<>
|
|
520
|
+
<div style={listStyle}>
|
|
521
|
+
{filtered.length === 0 ? (
|
|
522
|
+
<div style={statusStyle}>没有匹配的技能</div>
|
|
523
|
+
) : (() => {
|
|
524
|
+
let itemIndex = 0
|
|
525
|
+
const renderItem = (skill, index) => (
|
|
526
|
+
<button
|
|
527
|
+
key={skill.name}
|
|
528
|
+
type="button"
|
|
529
|
+
ref={(el) => {
|
|
530
|
+
itemRefs.current[index] = el
|
|
531
|
+
}}
|
|
532
|
+
onClick={() => pick(skill.name)}
|
|
533
|
+
onMouseEnter={(event) => {
|
|
534
|
+
setActive(index)
|
|
535
|
+
event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))'
|
|
536
|
+
}}
|
|
537
|
+
onMouseLeave={(event) => {
|
|
538
|
+
event.currentTarget.style.background = 'transparent'
|
|
539
|
+
}}
|
|
540
|
+
style={{
|
|
541
|
+
...itemStyle,
|
|
542
|
+
flexDirection: 'row',
|
|
543
|
+
alignItems: 'center',
|
|
544
|
+
...(index === active
|
|
545
|
+
? { background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))' }
|
|
546
|
+
: {}),
|
|
547
|
+
}}
|
|
548
|
+
>
|
|
549
|
+
<span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '2px', flex: '1', minWidth: '0' }}>
|
|
550
|
+
<span style={nameStyle}>{`/${skill.name}`}</span>
|
|
551
|
+
<span style={descStyle}>{skill.description ?? ''}</span>
|
|
552
|
+
</span>
|
|
553
|
+
<span
|
|
554
|
+
role="button"
|
|
555
|
+
tabIndex={-1}
|
|
556
|
+
title={pinned.includes(skill.name) ? '取消置顶' : '置顶到列表顶部'}
|
|
557
|
+
aria-label={pinned.includes(skill.name) ? '取消置顶' : '置顶'}
|
|
558
|
+
onClick={(event) => {
|
|
559
|
+
event.stopPropagation()
|
|
560
|
+
togglePin(skill.name)
|
|
561
|
+
}}
|
|
562
|
+
style={{
|
|
563
|
+
flex: 'none',
|
|
564
|
+
marginLeft: '6px',
|
|
565
|
+
padding: '2px 4px',
|
|
566
|
+
borderRadius: '6px',
|
|
567
|
+
fontSize: '12px',
|
|
568
|
+
lineHeight: '16px',
|
|
569
|
+
cursor: 'pointer',
|
|
570
|
+
color: pinned.includes(skill.name)
|
|
571
|
+
? 'var(--dsw-alias-label-primary-bluish, #4cc9f0)'
|
|
572
|
+
: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
573
|
+
opacity: pinned.includes(skill.name) ? 1 : 0.55,
|
|
574
|
+
userSelect: 'none',
|
|
575
|
+
}}
|
|
576
|
+
>
|
|
577
|
+
{pinned.includes(skill.name) ? '📌' : '📍'}
|
|
578
|
+
</span>
|
|
579
|
+
</button>
|
|
580
|
+
)
|
|
581
|
+
if (!showTitles) {
|
|
582
|
+
return filtered.map((skill) => renderItem(skill, itemIndex++))
|
|
583
|
+
}
|
|
584
|
+
return groups.map((group) => {
|
|
585
|
+
const items = group.items.filter((skill) => filteredNames.has(skill.name))
|
|
586
|
+
if (items.length === 0) return null
|
|
587
|
+
return (
|
|
588
|
+
<Fragment key={group.title}>
|
|
589
|
+
<div
|
|
590
|
+
style={{
|
|
591
|
+
display: 'flex',
|
|
592
|
+
alignItems: 'center',
|
|
593
|
+
justifyContent: 'space-between',
|
|
594
|
+
padding: '6px 10px 2px',
|
|
595
|
+
color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
|
|
596
|
+
fontSize: '11px',
|
|
597
|
+
fontWeight: 600,
|
|
598
|
+
letterSpacing: '0.04em',
|
|
599
|
+
}}
|
|
600
|
+
>
|
|
601
|
+
<span>{group.title}</span>
|
|
602
|
+
<span style={{ opacity: 0.7 }}>{items.length}</span>
|
|
603
|
+
</div>
|
|
604
|
+
{items.map((skill) => renderItem(skill, itemIndex++))}
|
|
605
|
+
</Fragment>
|
|
606
|
+
)
|
|
607
|
+
})
|
|
608
|
+
})()}
|
|
609
|
+
</div>
|
|
610
|
+
{source === 'host' && (
|
|
611
|
+
<div style={sourceBadgeStyle} title="官方技能 API 不可用,列表来自本地目录扫描(与官方 / 补全同源)">
|
|
612
|
+
<span style={sourceBadgeTextStyle}>本地扫描</span>
|
|
613
|
+
</div>
|
|
614
|
+
)}
|
|
615
|
+
</>
|
|
616
|
+
)}
|
|
617
|
+
</div>
|
|
618
|
+
)}
|
|
619
|
+
</div>
|
|
620
|
+
)
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Apply the browser half: register the picker into the composer tool row. */
|
|
624
|
+
export function apply(ctx) {
|
|
625
|
+
// Primary skill source: the official host skills API. In DSH 0.1.2-alpha.x
|
|
626
|
+
// the RPC moved from `connection.api.skills` (rc.x) to `remote.skills`
|
|
627
|
+
// (used by the official ui-skill plugin); try both before falling back.
|
|
628
|
+
const listSkills = async (sessionId) => {
|
|
629
|
+
const remoteSkills = ctx.remote?.skills
|
|
630
|
+
const connectionSkills = ctx.connection?.api?.skills
|
|
631
|
+
const skills = remoteSkills ?? connectionSkills
|
|
632
|
+
if (skills === undefined || typeof skills.list !== 'function') {
|
|
633
|
+
throw new Error('skills RPC unavailable (remote.skills / connection.api.skills)')
|
|
634
|
+
}
|
|
635
|
+
const controller = new AbortController()
|
|
636
|
+
const { result } = await skills.list({ sessionId }, controller.signal)
|
|
637
|
+
if (!result.ok) throw new Error(`skill.list failed: ${result.error?.code}: ${result.error?.message}`)
|
|
638
|
+
const raw = result.value?.skills ?? []
|
|
639
|
+
// The host already filters user-invocable skills out of this payload; keep
|
|
640
|
+
// the guard here too so the panel can never offer a skill the official `/`
|
|
641
|
+
// menu hides, whatever shape a future kernel ships on the wire (issue #10).
|
|
642
|
+
return raw.filter(isUserFacingSkill).map((skill) => ({ name: skill.name, description: skill.description ?? '' }))
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Legacy workspace-cwd source: the Session list snapshot still carried a
|
|
646
|
+
// `current` cursor up to the 0.1.5 client line, and on those kernels the slot
|
|
647
|
+
// props may not hand us a `useSessions` seat at all. Keeping this as a
|
|
648
|
+
// fallback means issue #11's fix does not silently regress the older kernels
|
|
649
|
+
// the compatibility table still declares.
|
|
650
|
+
let legacyCwd = ''
|
|
651
|
+
const syncLegacyCwd = () => {
|
|
652
|
+
try {
|
|
653
|
+
const snapshot = ctx.sessions.list.getSnapshot()
|
|
654
|
+
const sessionId = snapshot.current
|
|
655
|
+
const cwd = sessionId === undefined ? undefined : snapshot.byId[sessionId]?.cwd
|
|
656
|
+
legacyCwd = typeof cwd === 'string' ? cwd : ''
|
|
657
|
+
} catch {
|
|
658
|
+
legacyCwd = ''
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
syncLegacyCwd()
|
|
662
|
+
const unsubscribeLegacyCwd = ctx.sessions.list.subscribe(syncLegacyCwd)
|
|
663
|
+
|
|
664
|
+
ctx.effect(() => {
|
|
665
|
+
// Wrap the component so framework props pass through untouched and the
|
|
666
|
+
// live workspace cwd + official skills fetcher are attached — never
|
|
667
|
+
// swallow the composed props.
|
|
668
|
+
//
|
|
669
|
+
// The cwd is read from the slot's own standard props (see
|
|
670
|
+
// ./session-view.js): the Session list snapshot has no "current" cursor
|
|
671
|
+
// since the 0.1.7 client, so resolving the active Session through
|
|
672
|
+
// `ctx.sessions.list` yielded `''` and the host fallback scan never
|
|
673
|
+
// received the workspace. The legacy source above is consulted only when
|
|
674
|
+
// the standard props resolve to nothing, so both kernel lines work.
|
|
675
|
+
const PickerWithCwd = (props) => {
|
|
676
|
+
const cwd = useWorkspaceCwd(props)
|
|
677
|
+
return React.createElement(SkillPickerButton, {
|
|
678
|
+
...props,
|
|
679
|
+
cwd: cwd === '' ? legacyCwd : cwd,
|
|
680
|
+
listSkills,
|
|
681
|
+
})
|
|
682
|
+
}
|
|
683
|
+
const dispose = ctx.slots.inject('conversation.input.right', () =>
|
|
684
|
+
ctx.slots.register(
|
|
685
|
+
{ name: 'conversation.input.right', id: 'skill-picker', order: 100, label: 'Skill picker' },
|
|
686
|
+
PickerWithCwd,
|
|
687
|
+
),
|
|
688
|
+
)
|
|
689
|
+
return () => {
|
|
690
|
+
dispose()
|
|
691
|
+
unsubscribeLegacyCwd()
|
|
692
|
+
}
|
|
693
|
+
}, 'dsh-skill-picker: composer input slot')
|
|
694
|
+
|
|
695
|
+
// Fuzzy `/` completion: instead of registering a parallel source group
|
|
696
|
+
// (which would appear as a second list next to the official one), expose a
|
|
697
|
+
// global matcher that the patched official ui-skill candidates calls. The
|
|
698
|
+
// official group stays THE single `/` list; only its matching behaviour is
|
|
699
|
+
// upgraded to fuzzy + pinyin (name AND description, subsequence scoring).
|
|
700
|
+
ctx.effect(() => {
|
|
701
|
+
// Mirror the ⚡ panel's ordering: pinned first, then recently/frequently
|
|
702
|
+
// used skills, then the untouched rest — so both stay in sync.
|
|
703
|
+
const fuzzyMatch = (skills, query = '') => {
|
|
704
|
+
// Same visibility rule as the ⚡ panel: never let the `/` list surface a
|
|
705
|
+
// skill the user may not invoke (issue #10).
|
|
706
|
+
const visible = (Array.isArray(skills) ? skills : []).filter(isUserFacingSkill)
|
|
707
|
+
const ordered = groupByPinned(visible, loadUsage(), loadPinned()).flatMap((group) => group.items)
|
|
708
|
+
const q = String(query).trim().toLowerCase()
|
|
709
|
+
if (q === '') return ordered
|
|
710
|
+
// Rank by the ⚡ panel's exact order (pinned → recent → frequent → rest)
|
|
711
|
+
// so both lists stay in sync: fuzzysort only decides WHO matches, not
|
|
712
|
+
// the display order. Without this, a slash query re-sorts matches by
|
|
713
|
+
// match score and the two menus diverge for the same skill.
|
|
714
|
+
const rankByName = new Map(ordered.map((skill, index) => [skill.name, index]))
|
|
715
|
+
const targets = ordered.map((s) => ({
|
|
716
|
+
s,
|
|
717
|
+
search: `${s.name} ${s.description ?? ''} ${skillPinyinText(s.name, s.description ?? '')}`,
|
|
718
|
+
}))
|
|
719
|
+
const results = fuzzysort.go(q, targets, {
|
|
720
|
+
key: 'search',
|
|
721
|
+
limit: 30,
|
|
722
|
+
threshold: -10000,
|
|
723
|
+
})
|
|
724
|
+
// Relevance first (name-startsWith > name-contains > description >
|
|
725
|
+
// pinyin), then the ⚡ panel's pinned/usage order as the tiebreak —
|
|
726
|
+
// the shared rule with the bolt panel, so a name-exact skill like
|
|
727
|
+
// svg-diagram for "svg" surfaces above merely-recently-used ones.
|
|
728
|
+
return results
|
|
729
|
+
.filter((r) => r.score > 0)
|
|
730
|
+
.map((r) => r.obj.s)
|
|
731
|
+
// Drop pure subsequence noise (dispersed letters that never form an
|
|
732
|
+
// actual substring): keep only name/description/pinyin hits.
|
|
733
|
+
.filter((s) => matchRank(s, q) < 4)
|
|
734
|
+
.sort((a, b) => matchRank(a, q) - matchRank(b, q) || (rankByName.get(a.name) ?? 0) - (rankByName.get(b.name) ?? 0))
|
|
735
|
+
}
|
|
736
|
+
window.__dshSkillPickerFuzzy = fuzzyMatch
|
|
737
|
+
// Usage tracking for picks made from the official `/` menu: the patched
|
|
738
|
+
// ui-skill onPick calls this so a slash pick ranks like a bolt-panel pick.
|
|
739
|
+
const trackPick = (name) => {
|
|
740
|
+
const usage = loadUsage()
|
|
741
|
+
const nextUsage = { ...usage, [name]: { count: (usage[name]?.count ?? 0) + 1, lastUsed: Date.now() } }
|
|
742
|
+
saveUsage(nextUsage)
|
|
743
|
+
// Notify the bolt panel (and any other listeners) to re-read storage so
|
|
744
|
+
// a slash pick ranks as "recently used" there too, not only in the
|
|
745
|
+
// official / menu.
|
|
746
|
+
try {
|
|
747
|
+
window.dispatchEvent(new CustomEvent('dsh-skill-picker:usage-updated'))
|
|
748
|
+
} catch {
|
|
749
|
+
/* best-effort */
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
window.__dshSkillPickerTrack = trackPick
|
|
753
|
+
return () => {
|
|
754
|
+
if (window.__dshSkillPickerFuzzy === fuzzyMatch) delete window.__dshSkillPickerFuzzy
|
|
755
|
+
if (window.__dshSkillPickerTrack === trackPick) delete window.__dshSkillPickerTrack
|
|
756
|
+
}
|
|
757
|
+
}, 'dsh-skill-picker: fuzzy matcher for official / source')
|
|
758
|
+
}
|