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.
- package/README.md +3 -1
- package/lib/client.js +437 -8
- package/lib/client.js.map +4 -4
- package/lib/index.js +322 -14
- package/lib/index.js.map +4 -4
- package/package.json +1 -1
- package/src/client/index.jsx +411 -4
- package/src/client/shared-state.js +189 -0
- package/src/index.js +248 -5
- package/src/skill-ops.js +233 -0
package/src/client/index.jsx
CHANGED
|
@@ -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.
|