dsh-skill-picker 0.5.21 → 0.5.23

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.
@@ -20,6 +20,7 @@ import fuzzysort from 'fuzzysort'
20
20
  import { pinyin } from 'pinyin-pro'
21
21
 
22
22
  import { sessionIdOf, useWorkspaceCwd } from './session-view.js'
23
+ import { SHARED_STATE_EVENT, pushSharedState, syncSharedState } from './shared-state.js'
23
24
  import { installSlashFuzzy, slashEnhancementMode, warmSlashSkill } from './slash-source.js'
24
25
 
25
26
  /** Required services: slot registry, host connection (official skills API), sessions (workspace cwd fallback), input triggers (/ fuzzy source). */
@@ -43,13 +44,14 @@ function loadPinned() {
43
44
  }
44
45
  }
45
46
 
46
- /** Persist the pinned list; never throws. */
47
+ /** Persist the pinned list; never throws. Also pushes the shared copy. */
47
48
  function savePinned(pinned) {
48
49
  try {
49
50
  localStorage.setItem(PINNED_KEY, JSON.stringify(pinned))
50
51
  } catch {
51
52
  /* storage unavailable — pinning just won't persist */
52
53
  }
54
+ void pushSharedState()
53
55
  }
54
56
 
55
57
  /** Read the usage history {name: {count, lastUsed}}; never throws. */
@@ -64,13 +66,45 @@ function loadUsage() {
64
66
  }
65
67
  }
66
68
 
67
- /** Persist the usage history; never throws. */
69
+ /** Persist the usage history; never throws. Also pushes the shared copy. */
68
70
  function saveUsage(usage) {
69
71
  try {
70
72
  localStorage.setItem(USAGE_KEY, JSON.stringify(usage))
71
73
  } catch {
72
74
  /* storage unavailable — ordering just won't persist */
73
75
  }
76
+ void pushSharedState()
77
+ }
78
+
79
+ /**
80
+ * Manage one skill through the host: disable / enable / reveal / uninstall /
81
+ * restore.
82
+ *
83
+ * The request carries `x-dsh-skill-picker: 1` on purpose. Plugin routes are
84
+ * reachable without authentication (`/` needs a token, these do not), and these
85
+ * actions rename and move files — so they require a header that a cross-site
86
+ * form POST cannot set. See `handleSkillOp` in the host half.
87
+ *
88
+ * @param action - disable | enable | reveal | uninstall | restore | backups
89
+ * @param payload - { name } for skill actions, { id } for restore.
90
+ * @returns `{ ok, error?, ... }` — never throws.
91
+ */
92
+ async function skillOp(action, payload = {}) {
93
+ try {
94
+ const cwd = typeof payload.cwd === 'string' && payload.cwd !== '' ? `?cwd=${encodeURIComponent(payload.cwd)}` : ''
95
+ const response = await fetch(`/dsh-skill-picker/skill${cwd}`, {
96
+ method: 'POST',
97
+ headers: { 'content-type': 'application/json', 'x-dsh-skill-picker': '1' },
98
+ body: JSON.stringify({ action, ...payload }),
99
+ })
100
+ const body = await response.json().catch(() => undefined)
101
+ if (body === undefined || body.ok !== true) {
102
+ return { ok: false, error: body?.error ?? `host returned ${response.status}` }
103
+ }
104
+ return body
105
+ } catch (cause) {
106
+ return { ok: false, error: String(cause?.message ?? cause) }
107
+ }
74
108
  }
75
109
 
76
110
  /**
@@ -312,6 +346,69 @@ const statusStyle = {
312
346
  fontSize: '13px',
313
347
  }
314
348
 
349
+ /** One row action (关闭 / 定位 / 卸载); matches the pin button's weight. */
350
+ const actionSpanStyle = {
351
+ flex: 'none',
352
+ marginLeft: '6px',
353
+ padding: '2px 4px',
354
+ borderRadius: '6px',
355
+ fontSize: '12px',
356
+ lineHeight: '16px',
357
+ cursor: 'pointer',
358
+ color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
359
+ opacity: 0.55,
360
+ userSelect: 'none',
361
+ }
362
+
363
+ /** Result line under the list, with an optional undo for uninstall. */
364
+ const noticeStyle = {
365
+ display: 'flex',
366
+ alignItems: 'center',
367
+ gap: '8px',
368
+ margin: '0 8px 8px',
369
+ padding: '6px 10px',
370
+ borderRadius: '8px',
371
+ fontSize: '12px',
372
+ lineHeight: '18px',
373
+ background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))',
374
+ color: 'var(--dsw-alias-label-secondary, #b6bfcc)',
375
+ }
376
+
377
+ /** The right-click / long-press actions menu, positioned against the viewport. */
378
+ const menuStyle = {
379
+ position: 'fixed',
380
+ zIndex: 2147483000,
381
+ minWidth: '196px',
382
+ padding: '4px',
383
+ borderRadius: '10px',
384
+ border: '1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,0.24))',
385
+ background: 'var(--dsw-alias-bg-elevated, #23262e)',
386
+ boxShadow: '0 8px 28px rgba(0,0,0,0.36)',
387
+ color: 'var(--dsw-alias-label-primary, #e6e9ef)',
388
+ fontSize: '13px',
389
+ userSelect: 'none',
390
+ }
391
+
392
+ /** The skill name at the top of the menu (the thing being acted on). */
393
+ const menuHeaderStyle = {
394
+ padding: '6px 10px 4px',
395
+ fontSize: '11px',
396
+ color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
397
+ borderBottom: '1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,0.18))',
398
+ marginBottom: '4px',
399
+ overflow: 'hidden',
400
+ textOverflow: 'ellipsis',
401
+ whiteSpace: 'nowrap',
402
+ }
403
+
404
+ /** One menu row. */
405
+ const menuItemStyle = {
406
+ padding: '6px 10px',
407
+ borderRadius: '6px',
408
+ cursor: 'pointer',
409
+ whiteSpace: 'nowrap',
410
+ }
411
+
315
412
  /** Lightweight source badge shown only when the list came from the host scan fallback (official API unavailable). */
316
413
  const sourceBadgeStyle = {
317
414
  display: 'inline-flex',
@@ -363,12 +460,32 @@ function SkillPickerButton(props) {
363
460
  const [skills, setSkills] = useState(undefined)
364
461
  const [error, setError] = useState(undefined)
365
462
  const [source, setSource] = useState(undefined)
463
+ // Skills whose entry file is renamed to `SKILL.md.disabled`. The official
464
+ // `skills/list` RPC cannot report them (the provider does not see them at
465
+ // all), so they come from the host scan — without this a disabled skill would
466
+ // simply disappear and there would be no way to switch it back on.
467
+ const [disabledSkills, setDisabledSkills] = useState([])
468
+ /** Name of the skill an action is currently running on. */
469
+ const [busySkill, setBusySkill] = useState('')
470
+ /** Result line under the list: { kind, text, undo? }. */
471
+ const [notice, setNotice] = useState(undefined)
472
+ /**
473
+ * The right-click menu: { x, y, skill, disabled }.
474
+ *
475
+ * Actions live here instead of on the row so the list stays a list — one
476
+ * glyph per row (the pin, which doubles as the pinned/not state) and
477
+ * everything else behind the familiar right-click. Long-press opens the same
478
+ * menu, because a phone has no right button.
479
+ */
480
+ const [menu, setMenu] = useState(undefined)
366
481
  const [query, setQuery] = useState('')
367
482
  const [usage, setUsage] = useState(() => loadUsage())
368
483
  const [pinned, setPinned] = useState(() => loadPinned())
369
484
  const [active, setActive] = useState(0)
370
485
  const boxRef = useRef(null)
371
486
  const itemRefs = useRef([])
487
+ /** Pending long-press timer for the touch path (see the menu below). */
488
+ const longPressRef = useRef(0)
372
489
 
373
490
  // The usage store is shared with the official `/` menu: picks made there go
374
491
  // through window.__dshSkillPickerTrack (localStorage only). Refresh this
@@ -380,6 +497,20 @@ function SkillPickerButton(props) {
380
497
  return () => window.removeEventListener('dsh-skill-picker:usage-updated', onUsageUpdated)
381
498
  }, [])
382
499
 
500
+ // Pinned list and usage history live in one shared file on the host, so the
501
+ // desktop app, the web UI and a phone all show the same ordering. The mirror
502
+ // in localStorage is what the synchronous ranking reads; pull once on mount
503
+ // and again whenever a slash pick or another client changes the shared copy.
504
+ useEffect(() => {
505
+ const onSharedState = () => {
506
+ setUsage(loadUsage())
507
+ setPinned(loadPinned())
508
+ }
509
+ window.addEventListener(SHARED_STATE_EVENT, onSharedState)
510
+ void syncSharedState()
511
+ return () => window.removeEventListener(SHARED_STATE_EVENT, onSharedState)
512
+ }, [])
513
+
383
514
  // Prime the official `/` catalogue for this Session as soon as it is known.
384
515
  //
385
516
  // The slash menu highlights the FIRST group that settles and scrolls it into
@@ -412,8 +543,8 @@ function SkillPickerButton(props) {
412
543
  draftRef.current = props.input.draft
413
544
  }
414
545
 
415
- const load = useCallback(async () => {
416
- if (skills !== undefined || error !== undefined) return
546
+ const load = useCallback(async (force = false) => {
547
+ if (!force && (skills !== undefined || error !== undefined)) return
417
548
  // Session identity comes from the slot's standard props; older kernels
418
549
  // carried it as `props.session` (see ./session-view.js).
419
550
  const sessionId = sessionIdOf(props)
@@ -442,10 +573,112 @@ function SkillPickerButton(props) {
442
573
  }
443
574
  }, [skills, error, props.listSkills, props.sessionId, props.session, props.cwd])
444
575
 
576
+ /**
577
+ * Load the disabled skills from the host's own scan.
578
+ *
579
+ * They are invisible to the official `skills/list` RPC, so this is the only
580
+ * way the panel can offer to switch one back on. Best-effort: without it the
581
+ * panel still works, it just cannot re-enable.
582
+ */
583
+ const loadDisabled = useCallback(async () => {
584
+ try {
585
+ const cwd = typeof props.cwd === 'string' && props.cwd !== '' ? `?cwd=${encodeURIComponent(props.cwd)}` : ''
586
+ const res = await fetch(`/dsh-skill-picker/skills${cwd}`, { headers: { accept: 'application/json' } })
587
+ const json = await res.json()
588
+ if (!json.ok) return
589
+ setDisabledSkills((Array.isArray(json.skills) ? json.skills : []).filter((skill) => skill.disabled === true))
590
+ } catch {
591
+ /* the disabled list is a convenience; the panel works without it */
592
+ }
593
+ }, [props.cwd])
594
+
595
+ /**
596
+ * Run one management action and report it under the list.
597
+ *
598
+ * Reloads both lists afterwards: a disabled skill moves out of 「全部」 into
599
+ * 「已关闭」, and an uninstall takes it out of both.
600
+ */
601
+ const runSkillOp = async (action, skill, extra = {}) => {
602
+ const name = typeof skill?.name === 'string' ? skill.name : String(skill ?? '')
603
+ setBusySkill(name)
604
+ setNotice(undefined)
605
+ const result = await skillOp(action, { name, cwd: props.cwd, ...extra })
606
+ setBusySkill('')
607
+ if (result.ok !== true) {
608
+ setNotice({ kind: 'err', text: result.error ?? '操作失败' })
609
+ return
610
+ }
611
+ if (action === 'uninstall') {
612
+ const id = String(result.backupPath ?? '').split(/[\\/]/).filter(Boolean).pop()
613
+ setNotice({ kind: 'ok', text: `已卸载 /${name} —— 只是移进备份目录,没删`, undo: id })
614
+ } else if (action === 'disable') {
615
+ setNotice({ kind: 'ok', text: `已关闭 /${name} —— agent 也不会再加载它了` })
616
+ } else if (action === 'enable') {
617
+ setNotice({ kind: 'ok', text: `已开启 /${name}` })
618
+ } else if (action === 'reveal') {
619
+ setNotice({ kind: 'ok', text: `已在文件管理器里定位 /${name}` })
620
+ }
621
+ await Promise.all([load(true), loadDisabled()])
622
+ }
623
+
624
+ const undoUninstall = async (id) => {
625
+ if (typeof id !== 'string' || id === '') return
626
+ setBusySkill('(undo)')
627
+ const result = await skillOp('restore', { id, cwd: props.cwd })
628
+ setBusySkill('')
629
+ if (result.ok !== true) {
630
+ setNotice({ kind: 'err', text: result.error ?? '撤回失败' })
631
+ return
632
+ }
633
+ setNotice({ kind: 'ok', text: '已撤回,技能回到原位置' })
634
+ await Promise.all([load(true), loadDisabled()])
635
+ }
636
+
637
+ /**
638
+ * Open the context menu at a viewport point, kept fully on screen.
639
+ * `event` may be a mouse or a touch event; both carry clientX/clientY.
640
+ */
641
+ const openMenu = (event, skill, disabled) => {
642
+ const width = 208
643
+ const height = disabled ? 84 : 168
644
+ const x = Math.max(8, Math.min(event.clientX, (window.innerWidth || 1024) - width - 8))
645
+ const y = Math.max(8, Math.min(event.clientY, (window.innerHeight || 768) - height - 8))
646
+ setMenu({ x, y, skill, disabled })
647
+ }
648
+
649
+ // Close the menu on any outside interaction — the same contract as a native
650
+ // context menu, so it never lingers over the composer.
651
+ //
652
+ // BUBBLE phase on purpose, and the menu stops the event itself: a capturing
653
+ // listener would run before the clicked item's own handler and close the menu
654
+ // out from under the click, so every action would silently do nothing.
655
+ useEffect(() => {
656
+ if (menu === undefined) return undefined
657
+ const close = () => setMenu(undefined)
658
+ const onKey = (event) => {
659
+ if (event.key === 'Escape') close()
660
+ }
661
+ document.addEventListener('pointerdown', close)
662
+ document.addEventListener('keydown', onKey)
663
+ window.addEventListener('blur', close)
664
+ window.addEventListener('resize', close)
665
+ return () => {
666
+ document.removeEventListener('pointerdown', close)
667
+ document.removeEventListener('keydown', onKey)
668
+ window.removeEventListener('blur', close)
669
+ window.removeEventListener('resize', close)
670
+ }
671
+ }, [menu])
672
+
445
673
  const toggle = () => {
446
674
  if (!open) {
675
+ // Pull first: a pin or a pick made on another client (desktop ↔ web ↔
676
+ // phone) should be visible the moment this panel opens. The pull fires
677
+ // SHARED_STATE_EVENT, which is what refreshes `usage`/`pinned` here.
678
+ void syncSharedState()
447
679
  setUsage(loadUsage())
448
680
  void load()
681
+ void loadDisabled()
449
682
  }
450
683
  setOpen(!open)
451
684
  }
@@ -587,6 +820,27 @@ function SkillPickerButton(props) {
587
820
  itemRefs.current[index] = el
588
821
  }}
589
822
  onClick={() => pick(skill.name)}
823
+ onContextMenu={(event) => {
824
+ // Right-click opens the actions menu; see the menu block
825
+ // at the end of the panel. One glyph per row keeps the
826
+ // list scannable.
827
+ event.preventDefault()
828
+ event.stopPropagation()
829
+ openMenu(event, skill, false)
830
+ }}
831
+ onTouchStart={(event) => {
832
+ // A phone has no right button: long-press opens the same
833
+ // menu. Cancelled by any movement, so scrolling a long
834
+ // list never pops it up by accident.
835
+ const touch = event.touches?.[0]
836
+ if (touch === undefined) return
837
+ longPressRef.current = window.setTimeout(
838
+ () => openMenu({ clientX: touch.clientX, clientY: touch.clientY }, skill, false),
839
+ 500,
840
+ )
841
+ }}
842
+ onTouchEnd={() => window.clearTimeout(longPressRef.current)}
843
+ onTouchMove={() => window.clearTimeout(longPressRef.current)}
590
844
  onMouseEnter={(event) => {
591
845
  setActive(index)
592
846
  event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.12))'
@@ -664,6 +918,86 @@ function SkillPickerButton(props) {
664
918
  })
665
919
  })()}
666
920
  </div>
921
+ {/* Result of the last management action, with an undo for uninstall. */}
922
+ {notice !== undefined && (
923
+ <div style={noticeStyle}>
924
+ <span style={{ flex: '1', color: notice.kind === 'err' ? 'var(--dsw-alias-label-error, #ff7b72)' : undefined }}>
925
+ {notice.text}
926
+ </span>
927
+ {typeof notice.undo === 'string' && notice.undo !== '' && (
928
+ <span
929
+ role="button"
930
+ tabIndex={-1}
931
+ onClick={() => void undoUninstall(notice.undo)}
932
+ style={{ cursor: 'pointer', textDecoration: 'underline', flex: 'none' }}
933
+ >
934
+ 撤回
935
+ </span>
936
+ )}
937
+ </div>
938
+ )}
939
+ {/* Skills switched off. The official RPC never reports these, so
940
+ without this section a disabled skill would be unreachable. */}
941
+ {disabledSkills.length > 0 && (
942
+ <div style={{ marginTop: '4px' }}>
943
+ <div
944
+ style={{
945
+ display: 'flex',
946
+ alignItems: 'center',
947
+ justifyContent: 'space-between',
948
+ padding: '6px 10px 2px',
949
+ color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
950
+ fontSize: '11px',
951
+ fontWeight: 600,
952
+ letterSpacing: '0.04em',
953
+ }}
954
+ >
955
+ <span>⏻ 已关闭</span>
956
+ <span style={{ opacity: 0.7 }}>{disabledSkills.length}</span>
957
+ </div>
958
+ {disabledSkills.map((skill) => (
959
+ <div
960
+ key={`off-${skill.name}`}
961
+ onContextMenu={(event) => {
962
+ event.preventDefault()
963
+ event.stopPropagation()
964
+ openMenu(event, skill, true)
965
+ }}
966
+ style={{
967
+ ...itemStyle,
968
+ flexDirection: 'row',
969
+ alignItems: 'center',
970
+ opacity: 0.55,
971
+ }}
972
+ >
973
+ <span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '2px', flex: '1', minWidth: '0' }}>
974
+ <span style={nameStyle}>{`/${skill.name}`}</span>
975
+ <span style={descStyle}>{skill.description ?? ''}</span>
976
+ </span>
977
+ <span
978
+ role="button"
979
+ tabIndex={-1}
980
+ title="重新开启(把 SKILL.md.disabled 改回 SKILL.md)"
981
+ aria-label="重新开启"
982
+ onClick={() => void runSkillOp('enable', skill)}
983
+ style={actionSpanStyle}
984
+ >
985
+ {busySkill === skill.name ? '…' : '⏻ 开启'}
986
+ </span>
987
+ <span
988
+ role="button"
989
+ tabIndex={-1}
990
+ title="在文件管理器中定位"
991
+ aria-label="在文件管理器中定位"
992
+ onClick={() => void runSkillOp('reveal', skill)}
993
+ style={actionSpanStyle}
994
+ >
995
+ 📂
996
+ </span>
997
+ </div>
998
+ ))}
999
+ </div>
1000
+ )}
667
1001
  {source === 'host' && (
668
1002
  <div style={sourceBadgeStyle} title="官方技能 API 不可用,列表来自本地目录扫描(与官方 / 补全同源)">
669
1003
  <span style={sourceBadgeTextStyle}>本地扫描</span>
@@ -677,6 +1011,73 @@ function SkillPickerButton(props) {
677
1011
  <span style={sourceBadgeTextStyle}>/ 增强:运行时接管</span>
678
1012
  </div>
679
1013
  )}
1014
+ <div style={{ margin: '0 10px 8px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary, #8a94a6)' }}>
1015
+ 右键技能 = 置顶 / 关闭 / 定位 / 卸载(手机长按)
1016
+ </div>
1017
+ {/* The actions menu. Rendered here (not inside the row) so it can
1018
+ be positioned against the viewport and never clipped by the
1019
+ list's own scrolling. */}
1020
+ {menu !== undefined && (
1021
+ <div
1022
+ style={{ ...menuStyle, left: `${menu.x}px`, top: `${menu.y}px` }}
1023
+ onPointerDown={(event) => event.stopPropagation()}
1024
+ >
1025
+ <div style={menuHeaderStyle}>{`/${menu.skill.name}`}</div>
1026
+ {!menu.disabled && (
1027
+ <div
1028
+ style={menuItemStyle}
1029
+ role="button"
1030
+ tabIndex={-1}
1031
+ onClick={() => {
1032
+ setMenu(undefined)
1033
+ togglePin(menu.skill.name)
1034
+ }}
1035
+ >
1036
+ {pinned.includes(menu.skill.name) ? '📌 取消置顶' : '📌 置顶到顶部'}
1037
+ </div>
1038
+ )}
1039
+ <div
1040
+ style={menuItemStyle}
1041
+ role="button"
1042
+ tabIndex={-1}
1043
+ onClick={() => {
1044
+ const target = menu.skill
1045
+ const turningOff = !menu.disabled
1046
+ setMenu(undefined)
1047
+ void runSkillOp(turningOff ? 'disable' : 'enable', target)
1048
+ }}
1049
+ >
1050
+ {menu.disabled ? '⏻ 开启' : '⏻ 关闭(agent 也不再加载)'}
1051
+ </div>
1052
+ <div
1053
+ style={menuItemStyle}
1054
+ role="button"
1055
+ tabIndex={-1}
1056
+ onClick={() => {
1057
+ const target = menu.skill
1058
+ setMenu(undefined)
1059
+ void runSkillOp('reveal', target)
1060
+ }}
1061
+ >
1062
+ 📂 在文件管理器中定位
1063
+ </div>
1064
+ {!menu.disabled && (
1065
+ <div
1066
+ style={{ ...menuItemStyle, color: 'var(--dsw-alias-label-error, #ff7b72)' }}
1067
+ role="button"
1068
+ tabIndex={-1}
1069
+ onClick={() => {
1070
+ const target = menu.skill
1071
+ setMenu(undefined)
1072
+ if (!window.confirm(`卸载 /${target.name}?\n\n它会被移进备份目录(不是删除),面板里可以撤回。`)) return
1073
+ void runSkillOp('uninstall', target)
1074
+ }}
1075
+ >
1076
+ 🗑 卸载(移入备份,可撤回)
1077
+ </div>
1078
+ )}
1079
+ </div>
1080
+ )}
680
1081
  </>
681
1082
  )}
682
1083
  </div>
@@ -687,6 +1088,12 @@ function SkillPickerButton(props) {
687
1088
 
688
1089
  /** Apply the browser half: register the picker into the composer tool row. */
689
1090
  export function apply(ctx) {
1091
+ // Pull the shared pinned/usage state as early as possible: the `/` menu ranks
1092
+ // during render from the localStorage mirror, so the mirror has to be filled
1093
+ // before the user can type a slash. Best-effort — on failure the picker keeps
1094
+ // working from whatever this browser already had.
1095
+ void syncSharedState()
1096
+
690
1097
  // Primary skill source: the official host skills API. In DSH 0.1.2-alpha.x
691
1098
  // the RPC moved from `connection.api.skills` (rc.x) to `remote.skills`
692
1099
  // (used by the official ui-skill plugin); try both before falling back.