dsh-skill-picker 0.5.24 → 0.5.26

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.
@@ -107,6 +107,71 @@ async function skillOp(action, payload = {}) {
107
107
  }
108
108
  }
109
109
 
110
+ /**
111
+ * Hand the caret back to the composer after a pick, at the END of the text.
112
+ *
113
+ * Picking from this panel used to leave the input filled but WITHOUT a caret:
114
+ * the click focused the row, the panel then unmounted, and focus fell back to
115
+ * the body — so the user had to click the input again before typing. Picking
116
+ * from the official `/` menu never has that problem, because the composer keeps
117
+ * focus there. 国豪 reported exactly this difference.
118
+ *
119
+ * The first fix focused the editor but stopped there, and the caret landed at
120
+ * the START of the line: focusing a `contenteditable` with no selection is
121
+ * specified to put the caret at the beginning. So the caret is seated
122
+ * explicitly here.
123
+ *
124
+ * Seating a collapsed Range at the end is deliberately the *same* thing a click
125
+ * at the end of the line does, which is how the official editor normally
126
+ * receives a caret — it syncs its internal selection from the DOM. Merely
127
+ * calling `setDraft` again would not help: it early-returns when the text is
128
+ * unchanged, so the caret would never move.
129
+ *
130
+ * Best-effort: a failure here must never break the pick itself.
131
+ *
132
+ * @param from - a node inside the composer (our own button).
133
+ * @param onlyIfFocused - re-seat the caret only when the composer already holds
134
+ * focus. Used by the delayed second call, so a fast click somewhere else is
135
+ * never overridden.
136
+ * @returns whether an editor was found and focused.
137
+ */
138
+ function focusComposer(from, onlyIfFocused = false) {
139
+ try {
140
+ let node = from
141
+ for (let depth = 0; depth < 8 && node !== null && node !== undefined; depth += 1) {
142
+ const editor = typeof node.querySelector === 'function'
143
+ ? node.querySelector('[contenteditable="true"], textarea')
144
+ : null
145
+ if (editor !== null && editor !== undefined) {
146
+ if (onlyIfFocused && document.activeElement !== editor) return false
147
+ if (typeof editor.focus === 'function') editor.focus()
148
+ try {
149
+ if (editor.isContentEditable === true) {
150
+ const range = document.createRange()
151
+ range.selectNodeContents(editor)
152
+ range.collapse(false)
153
+ const selection = window.getSelection()
154
+ if (selection !== null && selection !== undefined) {
155
+ selection.removeAllRanges()
156
+ selection.addRange(range)
157
+ }
158
+ } else if (typeof editor.setSelectionRange === 'function') {
159
+ const end = String(editor.value ?? '').length
160
+ editor.setSelectionRange(end, end)
161
+ }
162
+ } catch {
163
+ /* the focus alone is still an improvement over no caret at all */
164
+ }
165
+ return true
166
+ }
167
+ node = node.parentElement
168
+ }
169
+ } catch {
170
+ /* focus is a convenience, never a requirement */
171
+ }
172
+ return false
173
+ }
174
+
110
175
  /**
111
176
  * Whether a skill entry may be offered by a human-facing surface (this panel
112
177
  * and the `/` completion it feeds). The official `skills/list` DTO
@@ -157,9 +222,12 @@ function groupByPinned(skills, usage, pinned) {
157
222
  else rest.push(skill)
158
223
  }
159
224
  return [
160
- { title: '📌 置顶', items: pinnedList },
161
- { title: '🔥 最近使用', items: recent },
162
- { title: '🗂️ 全部', items: rest },
225
+ // Plain titles, no emoji: these are section headings, and DSH's own
226
+ // headings (侧栏的「工作区」/「会话」) are plain text. 国豪: "加图标,那前面
227
+ // 一团火,这种图标看着不像 DSH 的风格".
228
+ { title: '置顶', items: pinnedList },
229
+ { title: '最近使用', items: recent },
230
+ { title: '全部', items: rest },
163
231
  ].filter((group) => group.items.length > 0)
164
232
  }
165
233
 
@@ -274,10 +342,34 @@ const buttonStyle = {
274
342
  flex: 'none',
275
343
  }
276
344
 
345
+ /**
346
+ * DSH's own UI font, so the panel reads as part of the harness rather than as a
347
+ * bolted-on popup.
348
+ *
349
+ * The skill NAME used to be rendered in `--ds-font-family-code` (the monospace
350
+ * face) because a `/skill` reads like a command — 国豪 spotted it immediately:
351
+ * "我想你用和右下角选择模型里的英文和中文一样的字体". The model picker uses
352
+ * `--dsw-font-family`, so that is what everything here uses now, and the sizes
353
+ * follow the composer (14px / 24px) instead of the browser's default button size
354
+ * (13.3333px), which is what the rows had been falling back to.
355
+ */
356
+ const FONT_FAMILY = 'var(--dsw-font-family, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Helvetica, Arial, sans-serif)'
357
+
358
+ /**
359
+ * Panel placement.
360
+ *
361
+ * Fixed (not absolute) and anchored to the RIGHT EDGE OF THE WINDOW, with the
362
+ * vertical offset measured from the ⚡ button so it still opens just above the
363
+ * composer.
364
+ *
365
+ * Why not `absolute; right: 0` on the button's own wrapper — which is what it
366
+ * used to be: the ⚡ sits next to the model picker, well left of the composer's
367
+ * right edge, so the panel was right-aligned to *that* and threw itself across
368
+ * the middle of the conversation, covering the text. 国豪 spotted it.
369
+ */
277
370
  const popoverStyle = {
278
- position: 'absolute',
279
- bottom: 'calc(100% + 8px)',
280
- right: '0',
371
+ position: 'fixed',
372
+ right: '24px',
281
373
  width: '340px',
282
374
  maxHeight: '320px',
283
375
  display: 'flex',
@@ -288,21 +380,57 @@ const popoverStyle = {
288
380
  boxShadow: '0 8px 28px rgba(0,0,0,0.35)',
289
381
  overflow: 'hidden',
290
382
  zIndex: 1000,
383
+ fontFamily: FONT_FAMILY,
384
+ fontSize: '14px',
385
+ lineHeight: '24px',
291
386
  }
292
387
 
388
+ /**
389
+ * The search field, styled like the composer's own pickers.
390
+ *
391
+ * 国豪: "我这个搜索框能不能变成这个模型搜索的样式?我感觉确实可以跟 DSH 去统一一下".
392
+ * The model picker's field (which is not an <input>, so its computed style could
393
+ * not be read) is visibly BORDERLESS with a faint fill; this one had a 1px
394
+ * border. So: a transparent border (keeps the geometry identical), the same
395
+ * faint fill, and — because a borderless field needs some focus affordance — a
396
+ * thin ring while focused, applied from `searchFocus` below since inline styles
397
+ * cannot express `:focus`.
398
+ *
399
+ * Height: it used to be 36px (padding 6+6 + line-height 22 + border 1+1). 国豪
400
+ * tuned it by eye: "改成30看看,就是行高减个六" → "再加回3吧" → "32吧", so 32px,
401
+ * as an explicit border-box height with the text centred.
402
+ *
403
+ * Worth remembering: the first two numbers never actually rendered — the panel
404
+ * is a column flex container with a max-height, so the field was being shrunk to
405
+ * 21px regardless of what the style said. Only after pinning `flex: none` did
406
+ * the declared height take effect at all.
407
+ */
293
408
  const searchStyle = {
294
409
  boxSizing: 'border-box',
295
410
  width: 'calc(100% - 16px)',
411
+ height: '32px',
412
+ // The panel is a column flex container with a max-height, so any child that
413
+ // can shrink will: the field measured 21px instead of the declared height
414
+ // until this was pinned. The list below is the thing that should scroll.
415
+ flex: 'none',
296
416
  margin: '8px',
297
- padding: '6px 10px',
298
- border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3))',
417
+ padding: '0 10px',
418
+ border: '1px solid transparent',
299
419
  borderRadius: '8px',
300
420
  background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.1))',
301
421
  color: 'var(--dsw-alias-label-primary, #e6ebf2)',
302
- fontSize: '13px',
422
+ fontFamily: FONT_FAMILY,
423
+ fontSize: '14px',
424
+ lineHeight: '20px',
303
425
  outline: 'none',
304
426
  }
305
427
 
428
+ /** Focus state for the borderless search field. */
429
+ const searchFocusStyle = {
430
+ background: 'var(--dsw-alias-interactive-bg-active, var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16)))',
431
+ boxShadow: '0 0 0 1px var(--dsw-alias-border-l2, rgba(128,128,128,0.35))',
432
+ }
433
+
306
434
  const listStyle = {
307
435
  overflowY: 'auto',
308
436
  flex: 'auto',
@@ -322,16 +450,23 @@ const itemStyle = {
322
450
  color: 'var(--dsw-alias-label-primary, #e6ebf2)',
323
451
  cursor: 'pointer',
324
452
  textAlign: 'left',
453
+ // Explicit: a bare <button> keeps the browser's own 13.3333px / Arial.
454
+ fontFamily: FONT_FAMILY,
455
+ fontSize: '14px',
456
+ lineHeight: '22px',
457
+ fontWeight: 400,
325
458
  }
326
459
 
327
460
  const nameStyle = {
328
- fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)',
329
- fontSize: '13px',
330
- fontWeight: 500,
461
+ fontFamily: FONT_FAMILY,
462
+ fontSize: '14px',
463
+ fontWeight: 400,
464
+ lineHeight: '22px',
331
465
  }
332
466
 
333
467
  const descStyle = {
334
468
  color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
469
+ fontFamily: FONT_FAMILY,
335
470
  fontSize: '12px',
336
471
  lineHeight: '16px',
337
472
  overflow: 'hidden',
@@ -343,23 +478,10 @@ const descStyle = {
343
478
  const statusStyle = {
344
479
  padding: '12px',
345
480
  color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
481
+ fontFamily: FONT_FAMILY,
346
482
  fontSize: '13px',
347
483
  }
348
484
 
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
485
  /** Result line under the list, with an optional undo for uninstall. */
364
486
  const noticeStyle = {
365
487
  display: 'flex',
@@ -374,27 +496,52 @@ const noticeStyle = {
374
496
  color: 'var(--dsw-alias-label-secondary, #b6bfcc)',
375
497
  }
376
498
 
377
- /** The right-click / long-press actions menu, positioned against the viewport. */
499
+ /**
500
+ * The right-click / long-press actions menu, positioned against the viewport.
501
+ *
502
+ * Colours are DSH theme variables, and the background is deliberately the SAME
503
+ * one the panel above it already uses (`--dsw-specific-tip`):
504
+ *
505
+ * panel + menu --dsw-specific-tip (opaque in both themes)
506
+ * text --dsw-alias-label-primary (flips with the theme)
507
+ * hover --dsw-alias-interactive-bg-hover
508
+ *
509
+ * Two wrong turns here, both caught by 国豪:
510
+ *
511
+ * 1. `--dsw-alias-bg-elevated` does not exist in DSH at all, so a hardcoded
512
+ * dark fallback always won while the text kept following the theme —
513
+ * dark-on-dark, a measured contrast ratio of 1.25 on the light theme.
514
+ * 2. `--dsw-specific-menu` does exist, but on the desktop's dark theme it is
515
+ * `#43454a73`: alpha 0.45, meant to sit behind DSH's own backdrop blur.
516
+ * With no blur the conversation showed straight through the menu.
517
+ *
518
+ * `--dsw-specific-tip` is opaque in both themes and is exactly what the panel
519
+ * beside it uses, so the two match. `backdropFilter` stays as a safety net in
520
+ * case a kernel ever ships a translucent value there.
521
+ */
378
522
  const menuStyle = {
379
523
  position: 'fixed',
380
524
  zIndex: 2147483000,
381
525
  minWidth: '196px',
382
526
  padding: '4px',
383
527
  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)',
528
+ border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.35))',
529
+ background: 'var(--dsw-specific-tip, #23262e)',
530
+ backdropFilter: 'blur(8px)',
386
531
  boxShadow: '0 8px 28px rgba(0,0,0,0.36)',
387
- color: 'var(--dsw-alias-label-primary, #e6e9ef)',
388
- fontSize: '13px',
532
+ color: 'var(--dsw-alias-label-primary, #e6ebf2)',
533
+ fontFamily: FONT_FAMILY,
534
+ fontSize: '14px',
535
+ lineHeight: '24px',
389
536
  userSelect: 'none',
390
537
  }
391
538
 
392
539
  /** The skill name at the top of the menu (the thing being acted on). */
393
540
  const menuHeaderStyle = {
394
541
  padding: '6px 10px 4px',
395
- fontSize: '11px',
542
+ fontSize: '12px',
396
543
  color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
397
- borderBottom: '1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,0.18))',
544
+ borderBottom: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.28))',
398
545
  marginBottom: '4px',
399
546
  overflow: 'hidden',
400
547
  textOverflow: 'ellipsis',
@@ -409,6 +556,19 @@ const menuItemStyle = {
409
556
  whiteSpace: 'nowrap',
410
557
  }
411
558
 
559
+ /**
560
+ * Hover feedback for a menu row. Inline styles cannot express `:hover`, and the
561
+ * panel's own rows already use this same mutate-on-hover approach.
562
+ */
563
+ const menuItemHandlers = {
564
+ onMouseEnter: (event) => {
565
+ event.currentTarget.style.background = 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.16))'
566
+ },
567
+ onMouseLeave: (event) => {
568
+ event.currentTarget.style.background = 'transparent'
569
+ },
570
+ }
571
+
412
572
  /** The picker's bolt glyph: DeepSeek palette gradient + slim stroke. */
413
573
  function BoltIcon() {
414
574
  return (
@@ -449,6 +609,18 @@ function SkillPickerButton(props) {
449
609
  const [busySkill, setBusySkill] = useState('')
450
610
  /** Result line under the list: { kind, text, undo? }. */
451
611
  const [notice, setNotice] = useState(undefined)
612
+ /**
613
+ * The notice is a toast, not a fixture — it dismisses itself.
614
+ *
615
+ * 国豪 caught it sitting on screen indefinitely after a trivial "located it in
616
+ * the file manager" action. Anything carrying an undo gets a much longer
617
+ * window, because dismissing it also removes the only way back.
618
+ */
619
+ useEffect(() => {
620
+ if (notice === undefined) return undefined
621
+ const timer = window.setTimeout(() => setNotice(undefined), notice.undo === undefined ? 2600 : 10000)
622
+ return () => window.clearTimeout(timer)
623
+ }, [notice])
452
624
  /**
453
625
  * The right-click menu: { x, y, skill, disabled }.
454
626
  *
@@ -458,6 +630,16 @@ function SkillPickerButton(props) {
458
630
  * menu, because a phone has no right button.
459
631
  */
460
632
  const [menu, setMenu] = useState(undefined)
633
+ /**
634
+ * Which list the panel is showing: 'all' or the closed-skills view ('off').
635
+ *
636
+ * The closed skills used to be a permanent section under the list; 国豪 asked
637
+ * for them behind a small entry at the bottom-right instead, because a
638
+ * section that is almost never used should not hold vertical space forever.
639
+ */
640
+ const [view, setView] = useState('all')
641
+ /** The search field is borderless now, so focus needs its own affordance. */
642
+ const [searchFocus, setSearchFocus] = useState(false)
461
643
  const [query, setQuery] = useState('')
462
644
  const [usage, setUsage] = useState(() => loadUsage())
463
645
  const [pinned, setPinned] = useState(() => loadPinned())
@@ -466,6 +648,12 @@ function SkillPickerButton(props) {
466
648
  const itemRefs = useRef([])
467
649
  /** Pending long-press timer for the touch path (see the menu below). */
468
650
  const longPressRef = useRef(0)
651
+ /**
652
+ * Distance from the window bottom to the panel's bottom edge, measured from
653
+ * the ⚡ button each time the panel opens. See `popoverStyle`: the horizontal
654
+ * anchor is the window's right edge, only the height follows the button.
655
+ */
656
+ const [panelBottom, setPanelBottom] = useState(88)
469
657
 
470
658
  // The usage store is shared with the official `/` menu: picks made there go
471
659
  // through window.__dshSkillPickerTrack (localStorage only). Refresh this
@@ -546,7 +734,10 @@ function SkillPickerButton(props) {
546
734
  const res = await fetch(`/dsh-skill-picker/skills${cwd}`, { headers: { accept: 'application/json' } })
547
735
  const json = await res.json()
548
736
  if (!json.ok) throw new Error(json.error || 'bad response')
549
- setSkills((Array.isArray(json.skills) ? json.skills : []).filter(isUserFacingSkill))
737
+ const listed = Array.isArray(json.skills) ? json.skills : []
738
+ // The scan deliberately reports switched-off skills too (that is how the
739
+ // closed view can list them), but they must never enter THIS list.
740
+ setSkills(listed.filter((skill) => isUserFacingSkill(skill) && skill.disabled !== true))
550
741
  setSource('host')
551
742
  } catch (cause) {
552
743
  setError(String(cause?.message ?? cause))
@@ -671,11 +862,22 @@ function SkillPickerButton(props) {
671
862
 
672
863
  const toggle = () => {
673
864
  if (!open) {
865
+ // Measure before opening so the panel is already in place on its first
866
+ // paint (no visible jump).
867
+ const el = boxRef.current
868
+ if (el !== null) {
869
+ const rect = el.getBoundingClientRect()
870
+ setPanelBottom(Math.max(64, Math.round(window.innerHeight - rect.top + 8)))
871
+ }
674
872
  // Pull first: a pin or a pick made on another client (desktop ↔ web ↔
675
873
  // phone) should be visible the moment this panel opens. The pull fires
676
874
  // SHARED_STATE_EVENT, which is what refreshes `usage`/`pinned` here.
677
875
  void syncSharedState()
678
876
  setUsage(loadUsage())
877
+ // A fresh open starts clean: a toast from last time is stale by then, and
878
+ // the panel should not reopen inside the closed-skills view.
879
+ setNotice(undefined)
880
+ setView('all')
679
881
  void load()
680
882
  void loadDisabled()
681
883
  }
@@ -706,6 +908,18 @@ function SkillPickerButton(props) {
706
908
 
707
909
  setOpen(false)
708
910
  setQuery('')
911
+
912
+ // Put the caret back in the composer (see `focusComposer`). In a rAF so the
913
+ // panel has unmounted and React has flushed the draft write first.
914
+ const from = boxRef.current
915
+ window.requestAnimationFrame(() => {
916
+ focusComposer(from)
917
+ // Second, guarded seat: a rich editor may re-apply its own selection
918
+ // right after being focused, which would drop the caret back to the
919
+ // start. Re-assert the end caret shortly after — but only if the composer
920
+ // still holds focus, so a fast click elsewhere is never overridden.
921
+ window.setTimeout(() => focusComposer(from, true), 60)
922
+ })
709
923
  }
710
924
 
711
925
  const togglePin = (name) => {
@@ -726,7 +940,23 @@ function SkillPickerButton(props) {
726
940
 
727
941
  // Grouped ordering: pinned first (manual), then usage-ranked (recent then
728
942
  // frequent then untouched by name). Shared rule with the `/` completion.
729
- const groups = groupByPinned(skills ?? [], usage, pinned)
943
+ /**
944
+ * The main list, with switched-off skills removed BY NAME.
945
+ *
946
+ * The official RPC should not report a disabled skill at all (the official
947
+ * provider keys on the entry file name), and the host scan reports them on
948
+ * purpose so the closed view can work — but this is the one place the main
949
+ * list is actually built, so the invariant is enforced here no matter which
950
+ * source produced the data.
951
+ *
952
+ * 国豪 caught the leak: a skill he had closed was still listed under 全部.
953
+ */
954
+ const disabledNames = new Set(disabledSkills.map((skill) => skill.name))
955
+ const groups = groupByPinned(
956
+ (skills ?? []).filter((skill) => !disabledNames.has(skill.name)),
957
+ usage,
958
+ pinned,
959
+ )
730
960
  const flat = groups.flatMap((group) => group.items)
731
961
 
732
962
  const filtered = (() => {
@@ -745,6 +975,17 @@ function SkillPickerButton(props) {
745
975
  const showTitles = query.trim() === '' && groups.length > 1
746
976
  const filteredNames = new Set(filtered.map((skill) => skill.name))
747
977
 
978
+ /**
979
+ * The closed-skills view, filtered by the same search box. The official RPC
980
+ * never reports these, so without this view a disabled skill would be
981
+ * unreachable — that is still true, it just lives one click away now.
982
+ */
983
+ const offFiltered = (() => {
984
+ const q = query.trim().toLowerCase()
985
+ if (q === '') return disabledSkills
986
+ return disabledSkills.filter((skill) => matchRank(skill, q) < 4)
987
+ })()
988
+
748
989
  // Keyboard navigation (#1): reset highlight when the query changes, keep it
749
990
  // in range when the result list shrinks, and keep the highlighted row visible.
750
991
  useEffect(() => {
@@ -777,7 +1018,12 @@ function SkillPickerButton(props) {
777
1018
  }
778
1019
 
779
1020
  return (
780
- <div ref={boxRef} style={{ position: 'relative', display: 'inline-flex', flex: 'none' }}>
1021
+ // The marker is how dsh-pocket's mobile "file open guard" learns to leave
1022
+ // this panel alone: its rows are <button>s whose text starts with "/技能名",
1023
+ // which its looksLikeFilePath() reads as a Windows path, so it swallowed the
1024
+ // click and popped "手机上无法直接打开电脑上的文件" (see the pocket patch of
1025
+ // 2026-10-02). Guarded there, marked here.
1026
+ <div ref={boxRef} data-dsh-skill-picker="1" style={{ position: 'relative', display: 'inline-flex', flex: 'none' }}>
781
1027
  <button
782
1028
  type="button"
783
1029
  onClick={toggle}
@@ -791,13 +1037,15 @@ function SkillPickerButton(props) {
791
1037
  <BoltIcon />
792
1038
  </button>
793
1039
  {open && (
794
- <div style={popoverStyle}>
1040
+ <div style={{ ...popoverStyle, bottom: `${panelBottom}px` }}>
795
1041
  <input
796
1042
  value={query}
797
1043
  onChange={(event) => setQuery(event.target.value)}
798
1044
  onKeyDown={onKeyDown}
1045
+ onFocus={() => setSearchFocus(true)}
1046
+ onBlur={() => setSearchFocus(false)}
799
1047
  placeholder="搜索技能…(↑↓ 选择,Enter 插入)"
800
- style={searchStyle}
1048
+ style={{ ...searchStyle, ...(searchFocus ? searchFocusStyle : {}) }}
801
1049
  autoFocus
802
1050
  />
803
1051
  {error !== undefined ? (
@@ -807,7 +1055,38 @@ function SkillPickerButton(props) {
807
1055
  ) : (
808
1056
  <>
809
1057
  <div style={listStyle}>
810
- {filtered.length === 0 ? (
1058
+ {view === 'off' ? (
1059
+ offFiltered.length === 0 ? (
1060
+ <div style={statusStyle}>
1061
+ {query.trim() === '' ? '没有被关闭的技能' : '没有匹配的已关闭技能'}
1062
+ </div>
1063
+ ) : (
1064
+ offFiltered.map((skill) => (
1065
+ <div
1066
+ key={`off-${skill.name}`}
1067
+ onContextMenu={(event) => {
1068
+ event.preventDefault()
1069
+ event.stopPropagation()
1070
+ openMenu(event, skill, true)
1071
+ }}
1072
+ onTouchStart={(event) => {
1073
+ const touch = event.touches?.[0]
1074
+ if (touch === undefined) return
1075
+ longPressRef.current = window.setTimeout(
1076
+ () => openMenu({ clientX: touch.clientX, clientY: touch.clientY }, skill, true),
1077
+ 500,
1078
+ )
1079
+ }}
1080
+ onTouchEnd={() => window.clearTimeout(longPressRef.current)}
1081
+ onTouchMove={() => window.clearTimeout(longPressRef.current)}
1082
+ style={{ ...itemStyle, flexDirection: 'column', alignItems: 'flex-start', opacity: 0.6 }}
1083
+ >
1084
+ <span style={nameStyle}>{`/${skill.name}`}</span>
1085
+ <span style={descStyle}>{skill.description ?? ''}</span>
1086
+ </div>
1087
+ ))
1088
+ )
1089
+ ) : filtered.length === 0 ? (
811
1090
  <div style={statusStyle}>没有匹配的技能</div>
812
1091
  ) : (() => {
813
1092
  let itemIndex = 0
@@ -860,32 +1139,10 @@ function SkillPickerButton(props) {
860
1139
  <span style={nameStyle}>{`/${skill.name}`}</span>
861
1140
  <span style={descStyle}>{skill.description ?? ''}</span>
862
1141
  </span>
863
- <span
864
- role="button"
865
- tabIndex={-1}
866
- title={pinned.includes(skill.name) ? '取消置顶' : '置顶到列表顶部'}
867
- aria-label={pinned.includes(skill.name) ? '取消置顶' : '置顶'}
868
- onClick={(event) => {
869
- event.stopPropagation()
870
- togglePin(skill.name)
871
- }}
872
- style={{
873
- flex: 'none',
874
- marginLeft: '6px',
875
- padding: '2px 4px',
876
- borderRadius: '6px',
877
- fontSize: '12px',
878
- lineHeight: '16px',
879
- cursor: 'pointer',
880
- color: pinned.includes(skill.name)
881
- ? 'var(--dsw-alias-label-primary-bluish, #4cc9f0)'
882
- : 'var(--dsw-alias-label-tertiary, #8a94a6)',
883
- opacity: pinned.includes(skill.name) ? 1 : 0.55,
884
- userSelect: 'none',
885
- }}
886
- >
887
- {pinned.includes(skill.name) ? '📌' : '📍'}
888
- </span>
1142
+ {/* No pin glyph on the row any more: 置顶/取消置顶 lives in
1143
+ the right-click menu, and the 置顶 group already shows
1144
+ which skills are pinned. 国豪: "那个置顶的按钮也不需要了
1145
+ 吧,毕竟右键它就能有置顶". */}
889
1146
  </button>
890
1147
  )
891
1148
  if (!showTitles) {
@@ -903,9 +1160,14 @@ function SkillPickerButton(props) {
903
1160
  justifyContent: 'space-between',
904
1161
  padding: '6px 10px 2px',
905
1162
  color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
906
- fontSize: '11px',
907
- fontWeight: 600,
908
- letterSpacing: '0.04em',
1163
+ // Measured against DSH's own sidebar heading
1164
+ // (「工作区」): it is 14px / weight 400 / normal
1165
+ // letter-spacing. Mine had been 11px / 600 / 0.04em,
1166
+ // which is exactly what made it look foreign.
1167
+ fontSize: '14px',
1168
+ fontWeight: 400,
1169
+ letterSpacing: 'normal',
1170
+ lineHeight: '24px',
909
1171
  }}
910
1172
  >
911
1173
  <span>{group.title}</span>
@@ -935,70 +1197,52 @@ function SkillPickerButton(props) {
935
1197
  )}
936
1198
  </div>
937
1199
  )}
938
- {/* Skills switched off. The official RPC never reports these, so
939
- without this section a disabled skill would be unreachable. */}
940
- {disabledSkills.length > 0 && (
941
- <div style={{ marginTop: '4px' }}>
942
- <div
943
- style={{
944
- display: 'flex',
945
- alignItems: 'center',
946
- justifyContent: 'space-between',
947
- padding: '6px 10px 2px',
948
- color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
949
- fontSize: '11px',
950
- fontWeight: 600,
951
- letterSpacing: '0.04em',
952
- }}
953
- >
954
- <span>⏻ 已关闭</span>
955
- <span style={{ opacity: 0.7 }}>{disabledSkills.length}</span>
956
- </div>
957
- {disabledSkills.map((skill) => (
958
- <div
959
- key={`off-${skill.name}`}
960
- onContextMenu={(event) => {
961
- event.preventDefault()
962
- event.stopPropagation()
963
- openMenu(event, skill, true)
964
- }}
965
- style={{
966
- ...itemStyle,
967
- flexDirection: 'row',
968
- alignItems: 'center',
969
- opacity: 0.55,
970
- }}
971
- >
972
- <span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '2px', flex: '1', minWidth: '0' }}>
973
- <span style={nameStyle}>{`/${skill.name}`}</span>
974
- <span style={descStyle}>{skill.description ?? ''}</span>
975
- </span>
976
- <span
977
- role="button"
978
- tabIndex={-1}
979
- title="重新开启(把 SKILL.md.disabled 改回 SKILL.md)"
980
- aria-label="重新开启"
981
- onClick={() => void runSkillOp('enable', skill)}
982
- style={actionSpanStyle}
983
- >
984
- {busySkill === skill.name ? '…' : '⏻ 开启'}
985
- </span>
986
- <span
987
- role="button"
988
- tabIndex={-1}
989
- title="在文件管理器中定位"
990
- aria-label="在文件管理器中定位"
991
- onClick={() => void runSkillOp('reveal', skill)}
992
- style={actionSpanStyle}
993
- >
994
- 📂
995
- </span>
996
- </div>
997
- ))}
998
- </div>
999
- )}
1000
- <div style={{ margin: '0 10px 8px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary, #8a94a6)' }}>
1001
- 右键技能 = 置顶 / 关闭 / 定位 / 卸载(手机长按)
1200
+ {/* The footer: a short hint on the left, the 「已关闭」 entry on the
1201
+ right. It used to be a full section listing every disabled
1202
+ skill — too much room for something reached rarely (国豪's
1203
+ call), so it is a small chip that opens its own view.
1204
+ The hint itself: first written long ("右键技能 = 置顶 / 关闭 /
1205
+ 定位 / 卸载(手机长按)"), which did not line up with the rows
1206
+ and got clipped to "手机…" — then shortened to this and kept
1207
+ permanently, because at this length it is quiet and fits
1208
+ (国豪: "我感觉你变成这一行就不错,那你就可以永久留着了").
1209
+ Its left margin matches the row text (list padding 6 + row
1210
+ padding 10) so it lines up with everything above it. */}
1211
+ <div
1212
+ style={{
1213
+ display: 'flex',
1214
+ alignItems: 'center',
1215
+ justifyContent: 'flex-end',
1216
+ gap: '8px',
1217
+ margin: '0 12px 8px 16px',
1218
+ fontSize: '12px',
1219
+ lineHeight: '18px',
1220
+ color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
1221
+ }}
1222
+ >
1223
+ <span style={{ minWidth: '0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
1224
+ {view === 'off' ? '右键可开启 · 手机长按' : '右键可管理 · 手机长按'}
1225
+ </span>
1226
+ <span
1227
+ role="button"
1228
+ tabIndex={-1}
1229
+ title={view === 'off' ? '回到全部技能' : '查看被关闭的技能'}
1230
+ onClick={() => {
1231
+ setView(view === 'off' ? 'all' : 'off')
1232
+ setQuery('')
1233
+ }}
1234
+ style={{
1235
+ flex: 'none',
1236
+ marginLeft: 'auto',
1237
+ padding: '2px 8px',
1238
+ borderRadius: '999px',
1239
+ border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.35))',
1240
+ cursor: 'pointer',
1241
+ whiteSpace: 'nowrap',
1242
+ }}
1243
+ >
1244
+ {view === 'off' ? '← 全部技能' : `已关闭 ${disabledSkills.length}`}
1245
+ </span>
1002
1246
  </div>
1003
1247
  {/* The actions menu. Rendered here (not inside the row) so it can
1004
1248
  be positioned against the viewport and never clipped by the
@@ -1014,18 +1258,20 @@ function SkillPickerButton(props) {
1014
1258
  style={menuItemStyle}
1015
1259
  role="button"
1016
1260
  tabIndex={-1}
1261
+ {...menuItemHandlers}
1017
1262
  onClick={() => {
1018
1263
  setMenu(undefined)
1019
1264
  togglePin(menu.skill.name)
1020
1265
  }}
1021
1266
  >
1022
- {pinned.includes(menu.skill.name) ? '📌 取消置顶' : '📌 置顶到顶部'}
1267
+ {pinned.includes(menu.skill.name) ? '取消置顶' : '置顶到顶部'}
1023
1268
  </div>
1024
1269
  )}
1025
1270
  <div
1026
1271
  style={menuItemStyle}
1027
1272
  role="button"
1028
1273
  tabIndex={-1}
1274
+ {...menuItemHandlers}
1029
1275
  onClick={() => {
1030
1276
  const target = menu.skill
1031
1277
  const turningOff = !menu.disabled
@@ -1033,25 +1279,27 @@ function SkillPickerButton(props) {
1033
1279
  void runSkillOp(turningOff ? 'disable' : 'enable', target)
1034
1280
  }}
1035
1281
  >
1036
- {menu.disabled ? '⏻ 开启' : '⏻ 关闭(agent 也不再加载)'}
1282
+ {menu.disabled ? '开启' : '关闭(agent 也不再加载)'}
1037
1283
  </div>
1038
1284
  <div
1039
1285
  style={menuItemStyle}
1040
1286
  role="button"
1041
1287
  tabIndex={-1}
1288
+ {...menuItemHandlers}
1042
1289
  onClick={() => {
1043
1290
  const target = menu.skill
1044
1291
  setMenu(undefined)
1045
1292
  void runSkillOp('reveal', target)
1046
1293
  }}
1047
1294
  >
1048
- 📂 在文件管理器中定位
1295
+ 在文件管理器中定位
1049
1296
  </div>
1050
1297
  {!menu.disabled && (
1051
1298
  <div
1052
- style={{ ...menuItemStyle, color: 'var(--dsw-alias-label-error, #ff7b72)' }}
1299
+ style={{ ...menuItemStyle, color: 'var(--dsw-alias-label-error, #e5534b)' }}
1053
1300
  role="button"
1054
1301
  tabIndex={-1}
1302
+ {...menuItemHandlers}
1055
1303
  onClick={() => {
1056
1304
  const target = menu.skill
1057
1305
  setMenu(undefined)
@@ -1059,7 +1307,7 @@ function SkillPickerButton(props) {
1059
1307
  void runSkillOp('uninstall', target)
1060
1308
  }}
1061
1309
  >
1062
- 🗑 卸载(移入备份,可撤回)
1310
+ 卸载(移入备份,可撤回)
1063
1311
  </div>
1064
1312
  )}
1065
1313
  </div>