dsh-skill-picker 0.5.25 → 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,6 +342,19 @@ 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
+
277
358
  /**
278
359
  * Panel placement.
279
360
  *
@@ -299,21 +380,57 @@ const popoverStyle = {
299
380
  boxShadow: '0 8px 28px rgba(0,0,0,0.35)',
300
381
  overflow: 'hidden',
301
382
  zIndex: 1000,
383
+ fontFamily: FONT_FAMILY,
384
+ fontSize: '14px',
385
+ lineHeight: '24px',
302
386
  }
303
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
+ */
304
408
  const searchStyle = {
305
409
  boxSizing: 'border-box',
306
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',
307
416
  margin: '8px',
308
- padding: '6px 10px',
309
- border: '1px solid var(--dsw-alias-border-l1, rgba(128,128,128,0.3))',
417
+ padding: '0 10px',
418
+ border: '1px solid transparent',
310
419
  borderRadius: '8px',
311
420
  background: 'var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,0.1))',
312
421
  color: 'var(--dsw-alias-label-primary, #e6ebf2)',
313
- fontSize: '13px',
422
+ fontFamily: FONT_FAMILY,
423
+ fontSize: '14px',
424
+ lineHeight: '20px',
314
425
  outline: 'none',
315
426
  }
316
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
+
317
434
  const listStyle = {
318
435
  overflowY: 'auto',
319
436
  flex: 'auto',
@@ -333,16 +450,23 @@ const itemStyle = {
333
450
  color: 'var(--dsw-alias-label-primary, #e6ebf2)',
334
451
  cursor: 'pointer',
335
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,
336
458
  }
337
459
 
338
460
  const nameStyle = {
339
- fontFamily: 'var(--ds-font-family-code, ui-monospace, monospace)',
340
- fontSize: '13px',
341
- fontWeight: 500,
461
+ fontFamily: FONT_FAMILY,
462
+ fontSize: '14px',
463
+ fontWeight: 400,
464
+ lineHeight: '22px',
342
465
  }
343
466
 
344
467
  const descStyle = {
345
468
  color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
469
+ fontFamily: FONT_FAMILY,
346
470
  fontSize: '12px',
347
471
  lineHeight: '16px',
348
472
  overflow: 'hidden',
@@ -354,23 +478,10 @@ const descStyle = {
354
478
  const statusStyle = {
355
479
  padding: '12px',
356
480
  color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
481
+ fontFamily: FONT_FAMILY,
357
482
  fontSize: '13px',
358
483
  }
359
484
 
360
- /** One row action (关闭 / 定位 / 卸载); matches the pin button's weight. */
361
- const actionSpanStyle = {
362
- flex: 'none',
363
- marginLeft: '6px',
364
- padding: '2px 4px',
365
- borderRadius: '6px',
366
- fontSize: '12px',
367
- lineHeight: '16px',
368
- cursor: 'pointer',
369
- color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
370
- opacity: 0.55,
371
- userSelect: 'none',
372
- }
373
-
374
485
  /** Result line under the list, with an optional undo for uninstall. */
375
486
  const noticeStyle = {
376
487
  display: 'flex',
@@ -385,27 +496,52 @@ const noticeStyle = {
385
496
  color: 'var(--dsw-alias-label-secondary, #b6bfcc)',
386
497
  }
387
498
 
388
- /** 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
+ */
389
522
  const menuStyle = {
390
523
  position: 'fixed',
391
524
  zIndex: 2147483000,
392
525
  minWidth: '196px',
393
526
  padding: '4px',
394
527
  borderRadius: '10px',
395
- border: '1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,0.24))',
396
- 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)',
397
531
  boxShadow: '0 8px 28px rgba(0,0,0,0.36)',
398
- color: 'var(--dsw-alias-label-primary, #e6e9ef)',
399
- fontSize: '13px',
532
+ color: 'var(--dsw-alias-label-primary, #e6ebf2)',
533
+ fontFamily: FONT_FAMILY,
534
+ fontSize: '14px',
535
+ lineHeight: '24px',
400
536
  userSelect: 'none',
401
537
  }
402
538
 
403
539
  /** The skill name at the top of the menu (the thing being acted on). */
404
540
  const menuHeaderStyle = {
405
541
  padding: '6px 10px 4px',
406
- fontSize: '11px',
542
+ fontSize: '12px',
407
543
  color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
408
- 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))',
409
545
  marginBottom: '4px',
410
546
  overflow: 'hidden',
411
547
  textOverflow: 'ellipsis',
@@ -420,6 +556,19 @@ const menuItemStyle = {
420
556
  whiteSpace: 'nowrap',
421
557
  }
422
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
+
423
572
  /** The picker's bolt glyph: DeepSeek palette gradient + slim stroke. */
424
573
  function BoltIcon() {
425
574
  return (
@@ -460,6 +609,18 @@ function SkillPickerButton(props) {
460
609
  const [busySkill, setBusySkill] = useState('')
461
610
  /** Result line under the list: { kind, text, undo? }. */
462
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])
463
624
  /**
464
625
  * The right-click menu: { x, y, skill, disabled }.
465
626
  *
@@ -469,6 +630,16 @@ function SkillPickerButton(props) {
469
630
  * menu, because a phone has no right button.
470
631
  */
471
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)
472
643
  const [query, setQuery] = useState('')
473
644
  const [usage, setUsage] = useState(() => loadUsage())
474
645
  const [pinned, setPinned] = useState(() => loadPinned())
@@ -563,7 +734,10 @@ function SkillPickerButton(props) {
563
734
  const res = await fetch(`/dsh-skill-picker/skills${cwd}`, { headers: { accept: 'application/json' } })
564
735
  const json = await res.json()
565
736
  if (!json.ok) throw new Error(json.error || 'bad response')
566
- 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))
567
741
  setSource('host')
568
742
  } catch (cause) {
569
743
  setError(String(cause?.message ?? cause))
@@ -700,6 +874,10 @@ function SkillPickerButton(props) {
700
874
  // SHARED_STATE_EVENT, which is what refreshes `usage`/`pinned` here.
701
875
  void syncSharedState()
702
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')
703
881
  void load()
704
882
  void loadDisabled()
705
883
  }
@@ -730,6 +908,18 @@ function SkillPickerButton(props) {
730
908
 
731
909
  setOpen(false)
732
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
+ })
733
923
  }
734
924
 
735
925
  const togglePin = (name) => {
@@ -750,7 +940,23 @@ function SkillPickerButton(props) {
750
940
 
751
941
  // Grouped ordering: pinned first (manual), then usage-ranked (recent then
752
942
  // frequent then untouched by name). Shared rule with the `/` completion.
753
- 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
+ )
754
960
  const flat = groups.flatMap((group) => group.items)
755
961
 
756
962
  const filtered = (() => {
@@ -769,6 +975,17 @@ function SkillPickerButton(props) {
769
975
  const showTitles = query.trim() === '' && groups.length > 1
770
976
  const filteredNames = new Set(filtered.map((skill) => skill.name))
771
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
+
772
989
  // Keyboard navigation (#1): reset highlight when the query changes, keep it
773
990
  // in range when the result list shrinks, and keep the highlighted row visible.
774
991
  useEffect(() => {
@@ -801,7 +1018,12 @@ function SkillPickerButton(props) {
801
1018
  }
802
1019
 
803
1020
  return (
804
- <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' }}>
805
1027
  <button
806
1028
  type="button"
807
1029
  onClick={toggle}
@@ -820,8 +1042,10 @@ function SkillPickerButton(props) {
820
1042
  value={query}
821
1043
  onChange={(event) => setQuery(event.target.value)}
822
1044
  onKeyDown={onKeyDown}
1045
+ onFocus={() => setSearchFocus(true)}
1046
+ onBlur={() => setSearchFocus(false)}
823
1047
  placeholder="搜索技能…(↑↓ 选择,Enter 插入)"
824
- style={searchStyle}
1048
+ style={{ ...searchStyle, ...(searchFocus ? searchFocusStyle : {}) }}
825
1049
  autoFocus
826
1050
  />
827
1051
  {error !== undefined ? (
@@ -831,7 +1055,38 @@ function SkillPickerButton(props) {
831
1055
  ) : (
832
1056
  <>
833
1057
  <div style={listStyle}>
834
- {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 ? (
835
1090
  <div style={statusStyle}>没有匹配的技能</div>
836
1091
  ) : (() => {
837
1092
  let itemIndex = 0
@@ -884,32 +1139,10 @@ function SkillPickerButton(props) {
884
1139
  <span style={nameStyle}>{`/${skill.name}`}</span>
885
1140
  <span style={descStyle}>{skill.description ?? ''}</span>
886
1141
  </span>
887
- <span
888
- role="button"
889
- tabIndex={-1}
890
- title={pinned.includes(skill.name) ? '取消置顶' : '置顶到列表顶部'}
891
- aria-label={pinned.includes(skill.name) ? '取消置顶' : '置顶'}
892
- onClick={(event) => {
893
- event.stopPropagation()
894
- togglePin(skill.name)
895
- }}
896
- style={{
897
- flex: 'none',
898
- marginLeft: '6px',
899
- padding: '2px 4px',
900
- borderRadius: '6px',
901
- fontSize: '12px',
902
- lineHeight: '16px',
903
- cursor: 'pointer',
904
- color: pinned.includes(skill.name)
905
- ? 'var(--dsw-alias-label-primary-bluish, #4cc9f0)'
906
- : 'var(--dsw-alias-label-tertiary, #8a94a6)',
907
- opacity: pinned.includes(skill.name) ? 1 : 0.55,
908
- userSelect: 'none',
909
- }}
910
- >
911
- {pinned.includes(skill.name) ? '📌' : '📍'}
912
- </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
+ 吧,毕竟右键它就能有置顶". */}
913
1146
  </button>
914
1147
  )
915
1148
  if (!showTitles) {
@@ -927,9 +1160,14 @@ function SkillPickerButton(props) {
927
1160
  justifyContent: 'space-between',
928
1161
  padding: '6px 10px 2px',
929
1162
  color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
930
- fontSize: '11px',
931
- fontWeight: 600,
932
- 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',
933
1171
  }}
934
1172
  >
935
1173
  <span>{group.title}</span>
@@ -959,70 +1197,52 @@ function SkillPickerButton(props) {
959
1197
  )}
960
1198
  </div>
961
1199
  )}
962
- {/* Skills switched off. The official RPC never reports these, so
963
- without this section a disabled skill would be unreachable. */}
964
- {disabledSkills.length > 0 && (
965
- <div style={{ marginTop: '4px' }}>
966
- <div
967
- style={{
968
- display: 'flex',
969
- alignItems: 'center',
970
- justifyContent: 'space-between',
971
- padding: '6px 10px 2px',
972
- color: 'var(--dsw-alias-label-tertiary, #8a94a6)',
973
- fontSize: '11px',
974
- fontWeight: 600,
975
- letterSpacing: '0.04em',
976
- }}
977
- >
978
- <span>⏻ 已关闭</span>
979
- <span style={{ opacity: 0.7 }}>{disabledSkills.length}</span>
980
- </div>
981
- {disabledSkills.map((skill) => (
982
- <div
983
- key={`off-${skill.name}`}
984
- onContextMenu={(event) => {
985
- event.preventDefault()
986
- event.stopPropagation()
987
- openMenu(event, skill, true)
988
- }}
989
- style={{
990
- ...itemStyle,
991
- flexDirection: 'row',
992
- alignItems: 'center',
993
- opacity: 0.55,
994
- }}
995
- >
996
- <span style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '2px', flex: '1', minWidth: '0' }}>
997
- <span style={nameStyle}>{`/${skill.name}`}</span>
998
- <span style={descStyle}>{skill.description ?? ''}</span>
999
- </span>
1000
- <span
1001
- role="button"
1002
- tabIndex={-1}
1003
- title="重新开启(把 SKILL.md.disabled 改回 SKILL.md)"
1004
- aria-label="重新开启"
1005
- onClick={() => void runSkillOp('enable', skill)}
1006
- style={actionSpanStyle}
1007
- >
1008
- {busySkill === skill.name ? '…' : '⏻ 开启'}
1009
- </span>
1010
- <span
1011
- role="button"
1012
- tabIndex={-1}
1013
- title="在文件管理器中定位"
1014
- aria-label="在文件管理器中定位"
1015
- onClick={() => void runSkillOp('reveal', skill)}
1016
- style={actionSpanStyle}
1017
- >
1018
- 📂
1019
- </span>
1020
- </div>
1021
- ))}
1022
- </div>
1023
- )}
1024
- <div style={{ margin: '0 10px 8px', fontSize: '11px', color: 'var(--dsw-alias-label-tertiary, #8a94a6)' }}>
1025
- 右键技能 = 置顶 / 关闭 / 定位 / 卸载(手机长按)
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>
1026
1246
  </div>
1027
1247
  {/* The actions menu. Rendered here (not inside the row) so it can
1028
1248
  be positioned against the viewport and never clipped by the
@@ -1038,18 +1258,20 @@ function SkillPickerButton(props) {
1038
1258
  style={menuItemStyle}
1039
1259
  role="button"
1040
1260
  tabIndex={-1}
1261
+ {...menuItemHandlers}
1041
1262
  onClick={() => {
1042
1263
  setMenu(undefined)
1043
1264
  togglePin(menu.skill.name)
1044
1265
  }}
1045
1266
  >
1046
- {pinned.includes(menu.skill.name) ? '📌 取消置顶' : '📌 置顶到顶部'}
1267
+ {pinned.includes(menu.skill.name) ? '取消置顶' : '置顶到顶部'}
1047
1268
  </div>
1048
1269
  )}
1049
1270
  <div
1050
1271
  style={menuItemStyle}
1051
1272
  role="button"
1052
1273
  tabIndex={-1}
1274
+ {...menuItemHandlers}
1053
1275
  onClick={() => {
1054
1276
  const target = menu.skill
1055
1277
  const turningOff = !menu.disabled
@@ -1057,25 +1279,27 @@ function SkillPickerButton(props) {
1057
1279
  void runSkillOp(turningOff ? 'disable' : 'enable', target)
1058
1280
  }}
1059
1281
  >
1060
- {menu.disabled ? '⏻ 开启' : '⏻ 关闭(agent 也不再加载)'}
1282
+ {menu.disabled ? '开启' : '关闭(agent 也不再加载)'}
1061
1283
  </div>
1062
1284
  <div
1063
1285
  style={menuItemStyle}
1064
1286
  role="button"
1065
1287
  tabIndex={-1}
1288
+ {...menuItemHandlers}
1066
1289
  onClick={() => {
1067
1290
  const target = menu.skill
1068
1291
  setMenu(undefined)
1069
1292
  void runSkillOp('reveal', target)
1070
1293
  }}
1071
1294
  >
1072
- 📂 在文件管理器中定位
1295
+ 在文件管理器中定位
1073
1296
  </div>
1074
1297
  {!menu.disabled && (
1075
1298
  <div
1076
- style={{ ...menuItemStyle, color: 'var(--dsw-alias-label-error, #ff7b72)' }}
1299
+ style={{ ...menuItemStyle, color: 'var(--dsw-alias-label-error, #e5534b)' }}
1077
1300
  role="button"
1078
1301
  tabIndex={-1}
1302
+ {...menuItemHandlers}
1079
1303
  onClick={() => {
1080
1304
  const target = menu.skill
1081
1305
  setMenu(undefined)
@@ -1083,7 +1307,7 @@ function SkillPickerButton(props) {
1083
1307
  void runSkillOp('uninstall', target)
1084
1308
  }}
1085
1309
  >
1086
- 🗑 卸载(移入备份,可撤回)
1310
+ 卸载(移入备份,可撤回)
1087
1311
  </div>
1088
1312
  )}
1089
1313
  </div>