@v587d/capital-generation 2.7.0 → 2.7.2

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/LICENSE +1 -1
  3. package/README.md +10 -9
  4. package/THIRD_PARTY_NOTICES.md +14 -4
  5. package/capital-config/client.js +50 -10
  6. package/capital-config/client.src.cjs +376 -62
  7. package/capital-config/index.js +168 -5
  8. package/capital-config/package.json +1 -1
  9. package/capital-watchlist/package.json +1 -1
  10. package/chart-ui/package.json +1 -1
  11. package/lib/agents/selected-skills-provider.js +5 -5
  12. package/lib/index.d.ts +4 -28
  13. package/lib/index.js +8 -18
  14. package/lib/web-retriever/local-fetch.d.ts +1 -1
  15. package/lib/web-retriever/local-fetch.js +1 -1
  16. package/lib/web-retriever/wind-client.d.ts +1 -1
  17. package/lib/web-retriever/wind-client.js +1 -1
  18. package/package.json +3 -1
  19. package/selected-skills/InvestSkill/ADDING-NEW-SKILLS.md +546 -0
  20. package/selected-skills/InvestSkill/CHANGELOG.md +373 -0
  21. package/selected-skills/InvestSkill/CI-CD-GUIDE.md +432 -0
  22. package/selected-skills/InvestSkill/CLAUDE.md +148 -0
  23. package/selected-skills/InvestSkill/CONTRIBUTING.md +291 -0
  24. package/selected-skills/InvestSkill/DEPLOYMENTS.md +321 -0
  25. package/selected-skills/InvestSkill/FAQ.md +509 -0
  26. package/selected-skills/InvestSkill/GEMINI.md +419 -0
  27. package/selected-skills/InvestSkill/LICENSE +21 -0
  28. package/selected-skills/InvestSkill/PLATFORM-COMPATIBILITY.md +529 -0
  29. package/selected-skills/InvestSkill/README-claude-code.md +435 -0
  30. package/selected-skills/InvestSkill/README-cursor.md +475 -0
  31. package/selected-skills/InvestSkill/README-gemini.md +532 -0
  32. package/selected-skills/InvestSkill/README-ollama.md +106 -0
  33. package/selected-skills/InvestSkill/README-zh-TW.md +461 -0
  34. package/selected-skills/InvestSkill/README.md +311 -0
  35. package/selected-skills/InvestSkill/RELEASE-QUICK-REFERENCE.md +230 -0
  36. package/selected-skills/InvestSkill/RELEASE_NOTES.md +121 -0
  37. package/selected-skills/InvestSkill/SECURITY.md +232 -0
  38. package/selected-skills/InvestSkill/TODO.md +28 -0
  39. package/selected-skills/InvestSkill/UPSTREAM.md +62 -0
  40. package/selected-skills/InvestSkill/install.sh +212 -0
  41. package/selected-skills/InvestSkill/package.json +64 -0
  42. package/selected-skills/InvestSkill/plugins/us-stock-analysis/README.md +119 -0
  43. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/10k-digest/SKILL.md +338 -0
  44. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/bear-case/SKILL.md +233 -0
  45. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/catalyst-calendar/SKILL.md +327 -0
  46. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/chart-master/SKILL.md +648 -0
  47. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/competitor-analysis/SKILL.md +478 -0
  48. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/dcf-valuation/SKILL.md +109 -0
  49. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/dividend-analysis/SKILL.md +684 -0
  50. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/earnings-call-analysis/SKILL.md +456 -0
  51. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/earnings-preview/SKILL.md +277 -0
  52. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/economics-analysis/SKILL.md +349 -0
  53. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/etf-analysis/SKILL.md +279 -0
  54. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/fact-check/SKILL.md +288 -0
  55. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/financial-report-analyst/SKILL.md +544 -0
  56. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/full-report/SKILL.md +301 -0
  57. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/fundamental-analysis/SKILL.md +67 -0
  58. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/industry-map/SKILL.md +336 -0
  59. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/insider-trading/SKILL.md +502 -0
  60. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/institutional-ownership/SKILL.md +548 -0
  61. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/learning-coach/SKILL.md +311 -0
  62. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/options-analysis/SKILL.md +495 -0
  63. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/portfolio-review/SKILL.md +465 -0
  64. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/position-ladder/SKILL.md +429 -0
  65. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/report-generator/SKILL.md +857 -0
  66. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/research-bundle/SKILL.md +94 -0
  67. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/result-validator/SKILL.md +174 -0
  68. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/risk-stress-test/SKILL.md +293 -0
  69. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/sector-analysis/SKILL.md +426 -0
  70. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/short-interest/SKILL.md +397 -0
  71. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-eval/SKILL.md +645 -0
  72. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-screener/SKILL.md +244 -0
  73. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/stock-valuation/SKILL.md +640 -0
  74. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/tax-lens/SKILL.md +351 -0
  75. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/technical-analysis/SKILL.md +654 -0
  76. package/selected-skills/InvestSkill/plugins/us-stock-analysis/skills/thesis-tracker/SKILL.md +317 -0
  77. package/selected-skills/README.md +15 -5
  78. package/selected-skills/catalog.js +589 -14
  79. package/selected-skills/investment-skills/UPSTREAM.md +1 -1
  80. package/selected-skills/investment-skills/skills/buffett-investment-framework/SKILL.md +1 -1
  81. package/selected-skills/peter-lynch-skill/LICENSE +21 -0
  82. package/selected-skills/peter-lynch-skill/README.md +305 -0
  83. package/selected-skills/peter-lynch-skill/UPSTREAM.md +71 -0
  84. package/selected-skills/peter-lynch-skill/skills/peter-lynch-skill/SKILL.md +142 -0
  85. package/selected-skills/peter-lynch-skill/skills/peter-lynch-skill/references/deep/methodology.md +587 -0
  86. package/selected-skills/peter-lynch-skill/skills/peter-lynch-skill/references/deep/system-prompt.md +102 -0
  87. package/selected-skills/peter-lynch-skill/skills/peter-lynch-skill/references/deep/web-research.md +471 -0
  88. package/selected-skills/peter-lynch-skill/skills/peter-lynch-skill/references/disclaimer.md +2 -0
  89. package/selected-skills/peter-lynch-skill/skills/peter-lynch-skill/references/filing-rules.md +64 -0
  90. package/selected-skills/peter-lynch-skill/skills/peter-lynch-skill/references/quick/methodology.md +195 -0
  91. package/selected-skills/peter-lynch-skill/skills/peter-lynch-skill/references/quick/system-prompt.md +72 -0
  92. package/selected-skills/peter-lynch-skill/skills/peter-lynch-skill/references/quick/web-research.md +379 -0
@@ -4,7 +4,10 @@ const {
4
4
  SettingsForm,
5
5
  SettingsSecretField,
6
6
  SettingsFormModel,
7
+ SegmentedControl,
7
8
  Switch,
9
+ Modal,
10
+ writeClipboard,
8
11
  IconRightUpOutlineRegular,
9
12
  } = require('@deepseek-ai/dsh-client-ui-primitives')
10
13
  const { SELECTED_SKILL_CATALOG, SELECTED_SKILL_TAG_LABELS } = require('../selected-skills/catalog.js')
@@ -17,6 +20,8 @@ const { SELECTED_SKILL_CATALOG, SELECTED_SKILL_TAG_LABELS } = require('../select
17
20
  * `src/index.ts` 的 `CAPITAL_CONFIG_ENTRY_ID`、这里。
18
21
  * - `BUNDLE_NAME` 是槽 `plugins.bundle.config` 的 key:页面按**组合包的包名**取该包的配置段
19
22
  * (`renderSlot(..., { entryKey: pkg.name })`),所以它必须逐字等于根 `package.json` 的 `name`。
23
+ * 它还被 `installStyles` 插进那两条限定"只在我们自己那一页动宿主的组件段"的选择器里 ⇒
24
+ * 包名这条链现在管着**三处**(槽 key、页面选择器、拼出来的 npm 地址)。
20
25
  *
21
26
  * 漂移的表现都不是报错,而是**卡片静默消失**;两处对齐由 `test/capital-config.test.mjs` 钉住。
22
27
  *
@@ -48,6 +53,18 @@ const FUYAO_DEFAULT_REF = 'FUYAO_API_KEY'
48
53
  const ANYSEARCH_DEFAULT_REF = 'ANYSEARCH_API_KEY'
49
54
  const WIND_DEFAULT_REF = 'WIND_API_KEY'
50
55
  const PADDLE_OCR_DEFAULT_REF = 'PADDLE_OCR_TOKEN'
56
+ /**
57
+ * 三格 tab 的基 id:官方 `SegmentedControl` 按 `<id>-<value>` 画 tab、按 `<id>-<value>-panel`
58
+ * 发 `aria-controls`(契约写在它的 `.d.ts` 里),所以三块面板的 `id` 必须逐字跟着这两个模板长。
59
+ */
60
+ const TABS_ID = 'capital-config-tabs'
61
+ /**
62
+ * 「详情」悬浮里那份 `SKILL.md` 原文的来路:host 半边(`capital-config/index.js`)注册的
63
+ * 只读旁路,按 catalog 的 skill 名寻址——闭集在服务器那侧,这里只拼地址。
64
+ * 这条字符串与 host 的 `SKILL_DOC_ROUTE` 同字(客户端 import 不到那一层,所以由测试对着
65
+ * 两侧产物比):漂移的表现是「详情」永远读不到,且不报错。
66
+ */
67
+ const SKILL_DOC_ROUTE = '/capital-skills'
51
68
  const DOC_URLS = {
52
69
  fuyao: 'https://fuyao.aicubes.cn/docs/',
53
70
  anysearch: 'https://www.anysearch.com/docs/auth',
@@ -72,7 +89,11 @@ const freshStatus = () => Object.fromEntries(
72
89
  )
73
90
 
74
91
  const zh = {
92
+ tabsLabel: '配置分区',
75
93
  title: '数据源',
94
+ titleUnsaved: '数据源 未保存',
95
+ componentsTitle: '包含的组件',
96
+ componentsHint: '组件清单由 DSH 绘制,显示在下方。',
76
97
  fuyaoLabel: 'Fuyao API Key',
77
98
  fuyaoHint: '同花顺 Fuyao 结构化数据接口。保存后新 Capital 模式会话生效。',
78
99
  anysearchLabel: 'AnySearch API Key',
@@ -86,9 +107,13 @@ const zh = {
86
107
  selectedSkillsTitle: '精选 Skills',
87
108
  selectedSkillsFilterLabel: '按能力筛选',
88
109
  selectedSkillsEmpty: '没有匹配这个标签的 Skill。',
89
- selectedSkillsCopyName: '复制技能',
90
- selectedSkillsCopyAction: '复制技能',
110
+ selectedSkillsCopyName: '复制技能名',
91
111
  selectedSkillsCopied: '已复制',
112
+ selectedSkillsDetail: '详情',
113
+ selectedSkillsDetailClose: '关闭',
114
+ selectedSkillsDocLoading: '原文读取中…',
115
+ selectedSkillsDocFailed: '原文读不到:这份快照没随包落地,或暂时读不出来。',
116
+ selectedSkillsDocStale: '原文读不到:宿主进程里还没有这条读原文的通道。升级插件后要重启 DSH,重启前刷新页面也没有用。',
92
117
  selectedSkillsOpenRepo: '打开上游仓库',
93
118
  openDocs: '官方文档',
94
119
  configured: '已配置密钥。',
@@ -103,7 +128,11 @@ const zh = {
103
128
  }
104
129
 
105
130
  const en = {
131
+ tabsLabel: 'Settings sections',
106
132
  title: 'Data Sources',
133
+ titleUnsaved: 'Data Sources unsaved',
134
+ componentsTitle: 'Components',
135
+ componentsHint: 'The component list is drawn by DSH and appears below.',
107
136
  fuyaoLabel: 'Fuyao API Key',
108
137
  fuyaoHint: 'Tonghuashun Fuyao structured-data API. Takes effect in new Capital mode sessions.',
109
138
  anysearchLabel: 'AnySearch API Key',
@@ -117,9 +146,13 @@ const en = {
117
146
  selectedSkillsTitle: 'Selected Skills',
118
147
  selectedSkillsFilterLabel: 'Filter by capability',
119
148
  selectedSkillsEmpty: 'No skills match this tag.',
120
- selectedSkillsCopyName: 'Copy skill',
121
- selectedSkillsCopyAction: 'Copy skill',
149
+ selectedSkillsCopyName: 'Copy skill name',
122
150
  selectedSkillsCopied: 'Copied',
151
+ selectedSkillsDetail: 'Details',
152
+ selectedSkillsDetailClose: 'Close',
153
+ selectedSkillsDocLoading: 'Loading the source…',
154
+ selectedSkillsDocFailed: 'The source could not be read: this snapshot is not in the installed package, or it cannot be opened right now.',
155
+ selectedSkillsDocStale: 'The source could not be read: this host process has no route for it yet. After upgrading the plugin, restart DSH — reloading the page alone will not help.',
123
156
  selectedSkillsOpenRepo: 'Open upstream repository',
124
157
  openDocs: 'Official docs',
125
158
  configured: 'A key is configured.',
@@ -151,28 +184,68 @@ function installStyles() {
151
184
  style.textContent = `
152
185
  .capital-config-card { display: flex; flex-direction: column; gap: 12px; }
153
186
  .capital-config-title { margin: 0; font-size: 14px; font-weight: 500; line-height: 20px; }
187
+ /* 面板的 display 只在「没被 hidden」时声明:UA 的 [hidden]{display:none} 会输给任何作者
188
+ display,写在 .capital-config-panel 上就等于 hidden 失效——35 颗开关与密钥输入框全留在
189
+ Tab 序里,控件对自己说谎。两条各管一边,谁日后加 display 也漏不出来。 */
190
+ .capital-config-panel { flex-direction: column; gap: 12px; }
191
+ .capital-config-panel:not([hidden]) { display: flex; }
192
+ .capital-config-panel[hidden] { display: none; }
193
+ /* 「包含的组件」那一格是宿主的(section[data-plugin-rows]),我们没有能替换它的座位,只能收起/放行。
194
+ 只写正面式、两条各自成规则:任何一条失配(宿主改名、老宿主没这段、无 :has() 的引擎、卡片没被
195
+ whileServed 放行、available 为假、属性值意外)都落在**宿主段落照常可见**那一侧——宁可重复,
196
+ 不能把用户的数据藏丢。写成 :not([data-capital-config-tab="components"]) 就是把一个拼错的
197
+ 属性值变成事故。回到可见一侧的结构保证:tab 条不在任何面板里,永远收不到 hidden。 */
198
+ [data-plugin-detail="${BUNDLE_NAME}"]:has(.capital-config-card[data-capital-config-tab="sources"]) section[data-plugin-rows] { display: none; }
199
+ [data-plugin-detail="${BUNDLE_NAME}"]:has(.capital-config-card[data-capital-config-tab="skills"]) section[data-plugin-rows] { display: none; }
154
200
  .capital-config-secret-row { display: flex; flex-direction: column; gap: 2px; }
155
201
  /* 文档链接 portal 进官方 head 行之后靠这两条摆位:官方 label 是 flex:1(会吃掉整行),
156
202
  收成 0 1 auto 才让链接紧贴其右;配置徽标拿 order + margin-left:auto 留在行尾。 */
157
203
  .capital-config-secret-row label { flex: 0 1 auto; }
158
204
  .capital-config-secret-row label ~ span { order: 1; margin-left: auto; }
159
205
  .capital-config-field { display: flex; flex-direction: column; gap: 6px; padding: 12px 0; border-top: 0.5px solid var(--dsw-alias-border-l2); }
160
- .capital-config-selected-skills { display: flex; flex-direction: column; gap: 8px; padding-top: 12px; border-top: 0.5px solid var(--dsw-alias-border-l2); }
161
- .capital-config-selected-filter { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
162
- .capital-config-selected-filter-label { flex: 0 0 100%; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
163
- .capital-config-selected-filter-button { min-height: 26px; padding: 3px 8px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12px; line-height: 18px; cursor: pointer; }
206
+ /* 第二格是左右布局:筛选器在左、清单在右。不是为了好看——35 行的清单里往下滚,顶部横排的
207
+ 二十几个标签早就滚出视野,筛选器等于失效,所以它还要 position: sticky 跟着滚。
208
+ padding-top: 12px 把左列第一颗按钮顶到 tab 条下方 24px,与第一格「Fuyao API Key」同一档:
209
+ 面板只有一个子元素时 flex gap 不发生作用,不补这一档就是 12px,切格子版面就跳一次。
210
+ ⚠️ sticky 的参照是页面滚动容器、top 是它顶端还要让出的高度,两者只能在浏览器里量。 */
211
+ .capital-config-selected-layout { display: flex; align-items: flex-start; gap: 16px; padding-top: 12px; }
212
+ .capital-config-selected-filter { position: sticky; top: 12px; flex: 0 0 132px; display: flex; flex-direction: column; align-items: stretch; gap: 3px; }
213
+ .capital-config-selected-filter-button { display: flex; align-items: baseline; justify-content: space-between; gap: 6px; min-height: 26px; padding: 3px 8px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 12px; line-height: 18px; text-align: left; cursor: pointer; }
164
214
  .capital-config-selected-filter-button:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
165
215
  .capital-config-selected-filter-button[aria-pressed="true"] { border-color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 12%, transparent); color: var(--dsw-alias-label-primary); }
216
+ /* 标签后面那个数字 = "点它会显示几行",按 catalog 现算;不是"已开启几颗"(那是 tab 上 2/35 的口径)。 */
217
+ .capital-config-selected-filter-count { flex: none; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; }
218
+ .capital-config-selected-filter-button[aria-pressed="true"] .capital-config-selected-filter-count { color: inherit; }
219
+ .capital-config-selected-list { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
166
220
  .capital-config-skill-tags { display: flex; flex-wrap: wrap; gap: 4px; }
167
221
  .capital-config-skill-tag { padding: 1px 5px; border-radius: 4px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; }
168
222
  .capital-config-skill-heading { min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
169
- .capital-config-skill-slug { min-width: 0; overflow-wrap: anywhere; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; }
170
- .capital-config-skill-copy { flex: none; padding: 2px 6px; border: 0; border-radius: 4px; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 11px; line-height: 16px; cursor: pointer; }
171
- .capital-config-skill-copy:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
172
- .capital-config-skill-copy:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 1px; }
223
+ .capital-config-skill-actions { flex: none; display: inline-flex; align-items: center; gap: 6px; }
224
+ /* 两个动作以前是无边框的 tertiary 灰,落在标题后面像一行说明文字,看不出可点。 */
225
+ .capital-config-skill-action { flex: none; min-height: 22px; padding: 2px 8px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 6px; background: color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent); color: var(--dsw-alias-brand-primary); font: inherit; font-size: 11px; line-height: 16px; white-space: nowrap; cursor: pointer; }
226
+ .capital-config-skill-action:hover { border-color: var(--dsw-alias-brand-primary); background: color-mix(in srgb, var(--dsw-alias-brand-primary) 18%, transparent); }
227
+ .capital-config-skill-action:focus-visible { outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 1px; }
173
228
  .capital-config-skill-repo { display: inline-flex; align-items: center; gap: 3px; color: var(--dsw-alias-label-tertiary); font-size: 11px; line-height: 16px; text-decoration: none; }
174
229
  .capital-config-skill-repo:hover { color: var(--dsw-alias-brand-primary); text-decoration: underline; }
175
230
  .capital-config-skill-description { margin: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
231
+ /* 「详情」点开的是官方 Modal(portal 到 body,遮罩、Esc、右上关闭按钮与焦点归还都归它,见 §5.3)。
232
+ 它默认只有 380px——装不下一份 markdown,所以这里给宽度。选择器写成 div. 是为了在同权重竞争里
233
+ 稳赢官方散列类 .dialog:我们的样式表后注入,但"后注入"是巧合不是契约,别押上去。 */
234
+ div.capital-config-skill-modal { width: min(820px, 100%); max-height: 100%; }
235
+ /* 矮窗口里的保险:正文整体可滚,避免被 .dialog 的 overflow:hidden 裁掉半行。 */
236
+ .capital-config-skill-modal-content { min-height: 0; overflow: auto; }
237
+ /* 原文是 markdown 就按源码呈现,不做渲染——这块 UI 说的是"SKILL.md 原文"。滚动的是这一份,
238
+ 标题与关闭按钮因此一直留在原位。 */
239
+ .capital-config-skill-doc { min-height: 0; max-height: 58vh; margin: 0; padding: 12px 14px; overflow: auto; border-radius: 8px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11.5px; line-height: 17px; white-space: pre-wrap; overflow-wrap: anywhere; }
240
+ .capital-config-skill-doc-state { margin: 0; padding: 12px 14px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 18px; }
241
+ /* 读屏用的一句(第三格那句"清单在下方"):画面上删掉了,但 aria-controls 指的这块面板不许是
242
+ 空的——空面板不报错,只有读屏用户付账。position:absolute ⇒ 它对布局一点贡献都没有。 */
243
+ .capital-config-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
244
+ /* 第三格不留可见内容(宿主那段清单就在卡片下方),这条负 margin 把它的首行文字顶到与另两格
245
+ 等距。数是这样来的(浏览器实测,别照抄注释改):面板 0 高时宿主 h4 落在 tab 条下方 44px
246
+ = 卡片到面板的 12 + 两格之间的 32;第一格那一档是 24 ⇒ 要收回 44 − 20 = 24。
247
+ 面板 hidden 时 display: none,这条 margin 根本不参与布局,所以只有那一格在屏时才生效。 */
248
+ .capital-config-panel[data-capital-config-panel="components"] { margin-bottom: -20px; }
176
249
  .capital-config-doc-link { display: inline-flex; align-items: center; gap: 3px; color: var(--dsw-alias-label-tertiary); font-size: 12px; line-height: 20px; white-space: nowrap; text-decoration: none; }
177
250
  .capital-config-doc-link:hover { color: var(--dsw-alias-brand-primary); text-decoration: underline; }
178
251
  .capital-config-doc-link:focus-visible { border-radius: 3px; outline: var(--dsw-focus-ring-width, 2px) solid var(--dsw-focus-ring-color, var(--dsw-alias-brand-primary)); outline-offset: 2px; }
@@ -205,6 +278,12 @@ class CapitalCardController {
205
278
  this.localFetchFailed = false
206
279
  this.selectedSkillWriting = new Set()
207
280
  this.selectedSkillFailed = new Set()
281
+ // 「包含的组件」的行数是**外部事实**(profile 里装了哪些行),null = 还没取到 ⇒ tab 上不画数字,
282
+ // 绝不把"读不到"渲染成 0。rowsRequested 记的是"这张卡片真的上过屏":没上屏就不去要这份
283
+ // 逐个 bundle 读 manifest 的清单,事件来了也只是"该重取了",不是"必须现在取"。
284
+ this.bundleRows = null
285
+ this.rowsRequested = false
286
+ this.rowsInFlight = false
208
287
  // 密钥通过 credentials 域独立写入(write-only,值从不随响应出网),所以普通字段 specs 为空。
209
288
  // 本机回退与选用 Skill 两类开关直接 mutate volatile 路径,不进入密钥草稿流。
210
289
  this.model = new SettingsFormModel(scope, [], CREDENTIAL_FIELDS.map((field) => ({
@@ -247,6 +326,8 @@ class CapitalCardController {
247
326
  writing: this.selectedSkillWriting.has(field),
248
327
  failed: this.selectedSkillFailed.has(field),
249
328
  }])),
329
+ // `count === null` 是"还不知道",不是"没有":卡片据此决定 tab 文案里画不画数字。
330
+ components: { count: this.bundleRows },
250
331
  }
251
332
  }
252
333
 
@@ -347,6 +428,45 @@ class CapitalCardController {
347
428
  this.store.set(this.projection())
348
429
  }
349
430
 
431
+ /**
432
+ * 「包含的组件」的行数:唯一真相是宿主的 `pluginManager.listBundles()`(页面上那一格也是从它渲染的),
433
+ * 我们只取 `rows.length` 当 tab 上的数字,**不重画那张清单**。
434
+ *
435
+ * - 只在卡片真的上过屏之后取,且在途合并:这份清单要逐个 bundle 读 manifest,不是廉价读。
436
+ * - 失败(远端抛错、`ok: false`、清单里没有我们这个包)一律不记不缓存 ⇒ 下一次切到那一格会再试
437
+ * (§9.7 ⑤ 的另一半:失败也要留可重试的余地,把一次抢跑失败 memoize 住就等于钉到进程结束)。
438
+ * - 只在数字真的变了才推 store,保住 snapshot 的引用稳定。
439
+ */
440
+ async ensureBundleRows() {
441
+ this.rowsRequested = true
442
+ if (this.bundleRows !== null) return
443
+ await this.readBundleRows()
444
+ }
445
+
446
+ /** 变更事件走这条:还没上过屏就不取,上过屏才值得重取。 */
447
+ invalidateBundleRows() {
448
+ if (this.rowsRequested) this.readBundleRows()
449
+ }
450
+
451
+ async readBundleRows() {
452
+ if (this.rowsInFlight) return
453
+ this.rowsInFlight = true
454
+ let count = this.bundleRows
455
+ try {
456
+ const answer = await this.ctx.remote.pluginManager.listBundles()
457
+ const bundle = answer?.ok === true
458
+ ? (answer.value ?? []).find((item) => item?.name === BUNDLE_NAME)
459
+ : undefined
460
+ if (Array.isArray(bundle?.rows)) count = bundle.rows.length
461
+ } catch {
462
+ // 读不到就维持原值(多半还是 null ⇒ tab 上不画数字),重试留给下一次切格或事件。
463
+ }
464
+ this.rowsInFlight = false
465
+ if (count === this.bundleRows) return
466
+ this.bundleRows = count
467
+ this.store.set(this.projection())
468
+ }
469
+
350
470
  inject() {
351
471
  return {
352
472
  hooks: {
@@ -355,6 +475,7 @@ class CapitalCardController {
355
475
  ...this.model.actions(),
356
476
  toggleLocalFetch: (value) => this.toggleLocalFetch(value),
357
477
  toggleSelectedSkill: (field, value) => this.toggleSelectedSkill(field, value),
478
+ ensureBundleRows: () => this.ensureBundleRows(),
358
479
  }
359
480
  }
360
481
  }
@@ -403,34 +524,137 @@ function skillLocale() {
403
524
  return typeof language === 'string' && language.toLowerCase().startsWith('en') ? 'en' : 'zh'
404
525
  }
405
526
 
527
+ /**
528
+ * `SKILL.md` 原文的按需取数(「详情」悬浮用)。
529
+ *
530
+ * - **只缓存成功**:失败缓存等于把一次抖动钉到这次会话结束(AGENTS.md §9.7 ⑤ 的后半边),
531
+ * 下一次悬停还得能重试。
532
+ * - 在途的那颗 Promise 用来合并重复悬停:同一行来回进出不会打两遍同一条本机路由。
533
+ * - 失败要分**两类**说(`error.code` 带出去给行面选文案):回包里有我们那份 `{ error }` ⇒ 路由活着、
534
+ * 是这条 skill 的快照读不到;回包根本不是我们的形状 ⇒ **宿主进程里就没有这条路由**(客户端 bundle
535
+ * 每次打开页面重取,host 半边却要重启才重新 import——升级后正是这个状态)。把后者说成"刷新页面
536
+ * 再试"是指错方向:刷新拿不到一条不存在的路由,只有重启宿主能。
537
+ */
538
+ const skillDocs = new Map()
539
+ const skillDocRequests = new Map()
540
+
541
+ function skillDocUrl(name) {
542
+ return `${SKILL_DOC_ROUTE}/${encodeURIComponent(name)}.md`
543
+ }
544
+
545
+ function loadSkillDoc(name) {
546
+ const cached = skillDocs.get(name)
547
+ if (cached !== undefined) return Promise.resolve(cached)
548
+ let pending = skillDocRequests.get(name)
549
+ if (pending === undefined) {
550
+ pending = fetch(skillDocUrl(name), { method: 'GET', cache: 'no-store', credentials: 'same-origin' })
551
+ .then(async (response) => {
552
+ if (!response.ok) {
553
+ const detail = await response.json().catch(() => ({}))
554
+ const error = new Error(typeof detail?.error === 'string' ? detail.error : `http_${response.status}`)
555
+ error.code = typeof detail?.error === 'string'
556
+ ? detail.error
557
+ : (response.status === 401 || response.status === 403) ? 'unauthorized' : 'route_missing'
558
+ throw error
559
+ }
560
+ const text = await response.text()
561
+ skillDocs.set(name, text)
562
+ return text
563
+ })
564
+ .finally(() => skillDocRequests.delete(name))
565
+ skillDocRequests.set(name, pending)
566
+ }
567
+ return pending
568
+ }
569
+
570
+ /** 「已复制」亮多久:要够读完,又不能亮到让人以为它是个状态开关。 */
571
+ const COPIED_FLASH_MS = 1600
572
+
406
573
  function SelectedSkillRow({ entry, state, disabled, onChange, t }) {
407
574
  const locale = skillLocale()
408
575
  const label = entry.label[locale]
409
576
  const repositoryUrl = `https://github.com/${entry.owner}/${entry.repository}`
577
+ const copyTimer = useRef(null)
410
578
  const [copied, setCopied] = useState(false)
579
+ const [detailOpen, setDetailOpen] = useState(false)
580
+ // null = 还没取过;'ready' 之后不再重取,'failed' / 'loading' 允许下一次悬停再来一遍。
581
+ const [doc, setDoc] = useState(null)
582
+ useEffect(() => () => {
583
+ if (copyTimer.current !== null) clearTimeout(copyTimer.current)
584
+ }, [])
411
585
  const copyName = async () => {
412
- const clipboard = globalThis.navigator?.clipboard
413
- if (typeof clipboard?.writeText !== 'function') return
414
- try {
415
- await clipboard.writeText(entry.name)
416
- setCopied(true)
417
- } catch {
418
- // Clipboard access can be denied by the browser; keep the row usable.
586
+ const accepted = await writeClipboard(entry.name)
587
+ // 浏览器拒了剪贴板就不改文案:按钮说自己"已复制"而剪贴板是空的,是控件在说谎。
588
+ if (!accepted) return
589
+ setCopied(true)
590
+ if (copyTimer.current !== null) clearTimeout(copyTimer.current)
591
+ copyTimer.current = setTimeout(() => setCopied(false), COPIED_FLASH_MS)
592
+ }
593
+ const prefetchDoc = () => {
594
+ if (doc?.status === 'ready' || doc?.status === 'loading') return
595
+ if (skillDocs.has(entry.name)) {
596
+ setDoc({ status: 'ready', text: skillDocs.get(entry.name) })
597
+ return
419
598
  }
599
+ setDoc({ status: 'loading' })
600
+ loadSkillDoc(entry.name).then(
601
+ (text) => setDoc({ status: 'ready', text }),
602
+ (error) => {
603
+ // 码只留在这里(卡片上那句人话不背 `skill_doc_unreadable` 这种内部名)。
604
+ globalThis.console?.warn?.(`capital-config: SKILL.md 原文读取失败 ${entry.name}: ${String(error?.code ?? error?.message ?? error)}`)
605
+ setDoc({ status: 'failed', code: error?.code === 'route_missing' ? 'route_missing' : 'unreadable' })
606
+ },
607
+ )
420
608
  }
609
+ const detailContent = doc?.status === 'ready'
610
+ ? h('pre', { className: 'capital-config-skill-doc' }, doc.text)
611
+ : doc?.status === 'failed'
612
+ ? h('p', { className: 'capital-config-skill-doc-state' }, t(doc.code === 'route_missing' ? 'selectedSkillsDocStale' : 'selectedSkillsDocFailed'))
613
+ : h('p', { className: 'capital-config-skill-doc-state' }, t('selectedSkillsDocLoading'))
421
614
  return h('div', { className: 'capital-config-field' },
422
615
  h('div', { className: 'capital-config-switch-row' },
423
616
  h('div', { className: 'capital-config-switch-text' },
424
617
  h('div', { className: 'capital-config-skill-heading' },
425
618
  h('span', { className: 'capital-config-title' }, label),
426
- h('code', { className: 'capital-config-skill-slug' }, entry.name),
427
- state?.on === true ? h('button', {
428
- type: 'button',
429
- className: 'capital-config-skill-copy',
430
- title: t('selectedSkillsCopyName'),
431
- 'aria-label': t('selectedSkillsCopyName'),
432
- onClick: copyName,
433
- }, copied ? t('selectedSkillsCopied') : t('selectedSkillsCopyAction')) : null,
619
+ h('div', { className: 'capital-config-skill-actions' },
620
+ // 复制技能名**只给已开启的行**:那串名字是给会话用的(`/技能名`),没开的技能复制了
621
+ // 也用不上,摆在那儿只是 35 行噪音。「详情」不留这一道闸门——开之前正是要读原文的时候。
622
+ state?.on === true ? h('button', {
623
+ type: 'button',
624
+ className: 'capital-config-skill-action',
625
+ // slug 从行面上删掉了,所以无障碍名与原生提示都得带着真名字:用户要能在按下去之前
626
+ // 知道"复制的到底是哪一串",否则删掉那行 `code` 就是把信息藏了而不是省了。
627
+ title: `${t('selectedSkillsCopyName')}: ${entry.name}`,
628
+ 'aria-label': `${t('selectedSkillsCopyName')}: ${entry.name}`,
629
+ onClick: copyName,
630
+ }, copied ? t('selectedSkillsCopied') : t('selectedSkillsCopyName')) : null,
631
+ h('button', {
632
+ type: 'button',
633
+ className: 'capital-config-skill-action',
634
+ 'aria-label': `${t('selectedSkillsDetail')}: ${entry.name}`,
635
+ 'aria-haspopup': 'dialog',
636
+ // 悬停就把原文要回来:点开那一瞬应该已经有内容,而不是先看见一句「原文读取中」。
637
+ // 点击才是这条动作的正解——官方 HoverCard 只认 pointerenter 计时(500ms),且**点锚点
638
+ // 会把它关掉**,所以"点详情没反应"不是故障而是那个控件的语义。
639
+ onPointerEnter: prefetchDoc,
640
+ onFocus: prefetchDoc,
641
+ onClick: () => {
642
+ prefetchDoc()
643
+ setDetailOpen(true)
644
+ },
645
+ }, t('selectedSkillsDetail')),
646
+ h(Modal, {
647
+ open: detailOpen,
648
+ onClose: () => setDetailOpen(false),
649
+ title: label,
650
+ // slug 从行面上删了,但"我在读哪一份"这个问题在打开的这一刻最该被回答:
651
+ // 对话框的副标题就是它,也是复制要照抄的那一串。
652
+ description: entry.name,
653
+ closeLabel: t('selectedSkillsDetailClose'),
654
+ className: 'capital-config-skill-modal',
655
+ contentClassName: 'capital-config-skill-modal-content',
656
+ }, detailContent),
657
+ ),
434
658
  ),
435
659
  h('p', { className: 'capital-config-skill-description' }, entry.description[locale]),
436
660
  h('a', {
@@ -455,42 +679,55 @@ function SelectedSkillRow({ entry, state, disabled, onChange, t }) {
455
679
  }
456
680
 
457
681
  /**
458
- * 组合包页面的配置段:页面只提供 `<section data-plugin-config>` 的容器与间距,小节标题由卡片
459
- * 自己画(与页面自绘的「包含的组件」同一字号层级)。`plugins.bundle.config` 只以 `view: 'page'`
460
- * 渲染,没有 summary 一说。
682
+ * 组合包页面的配置段:页面只提供 `<section data-plugin-config>` 的容器与间距,标题体系由卡片自己给。
683
+ * 这里是**三格 tab**(官方 `SegmentedControl`:tablist 语义、roving tabindex、←→/Home/End 走位、
684
+ * 焦点跟随都在它里,我们一律自己实现 tablist 都不要),它**取代**当年 `h4 数据源` / `h5 精选 Skills`
685
+ * 两级标题——面板靠 `role=tabpanel + aria-labelledby` 拿名字,名字就是用户眼里 tab 上那几个字。
686
+ * `plugins.bundle.config` 只以 `view: 'page'` 渲染,没有 summary 一说。
687
+ *
688
+ * ⛔ 三块面板**全部挂载**,不在屏的那块用 `hidden` 收起而不是不渲染:`SettingsForm` 卸载即丢弃草稿
689
+ * (填了密钥切一格再回来,密钥就没了)。宿主自己画的「包含的组件」段随 tab 收起/放行,判据是卡片根上
690
+ * 的 `data-capital-config-tab`——所以 `available` 为假时**不画 tab 条、也不写这个属性**:属性缺席时
691
+ * 那两条 `:has()` 认不出卡片,宿主的清单照常可见,我们藏不掉任何用户能看到的东西。
692
+ *
693
+ * 三格的「tab 条 → 首行文字」等距(间距真值见 installStyles 那几条 padding / margin,浏览器里量出来的):
694
+ * 不等距就是每切一格版面跳一次,用户会以为内容被换了。
461
695
  */
462
696
  function CapitalCard(props) {
463
697
  const t = props.t
464
698
  const state = props.useCapitalCard((snapshot) => snapshot)
465
699
  const disabled = !state.writable
466
700
  const locale = skillLocale()
701
+ const [tab, setTab] = useState('sources')
467
702
  const [tagFilter, setTagFilter] = useState('all')
468
- const tagLabels = SELECTED_SKILL_TAG_LABELS[locale]
469
- const tags = ['all', ...Array.from(new Set(SELECTED_SKILL_CATALOG.flatMap((entry) => entry.tags)))]
470
- const visibleSkills = SELECTED_SKILL_CATALOG.filter((entry) => tagFilter === 'all' || entry.tags.includes(tagFilter))
471
- const selectedSkillsSection = h('section', { className: 'capital-config-selected-skills' },
472
- h('h5', { className: 'capital-config-title' }, t('selectedSkillsTitle')),
473
- h('div', { className: 'capital-config-selected-filter', role: 'group', 'aria-label': t('selectedSkillsFilterLabel') },
474
- h('span', { className: 'capital-config-selected-filter-label' }, t('selectedSkillsFilterLabel')),
475
- ...tags.map((tag) => h('button', {
476
- key: tag,
477
- type: 'button',
478
- className: 'capital-config-selected-filter-button',
479
- 'aria-pressed': tagFilter === tag ? 'true' : 'false',
480
- onClick: () => setTagFilter(tag),
481
- }, tagLabels[tag] ?? tag)),
482
- ),
483
- visibleSkills.length > 0
484
- ? visibleSkills.map((entry) => h(SelectedSkillRow, {
485
- key: entry.key,
486
- entry,
487
- state: state.selectedSkills?.[entry.key],
488
- disabled: disabled || state.saving,
489
- onChange: (value) => props.toggleSelectedSkill(entry.key, value),
490
- t,
491
- }))
492
- : h('p', { className: 'capital-config-hint' }, t('selectedSkillsEmpty')),
493
- )
703
+ // tab 文案从一开始就带着数字,所以取数不等点击:挂载即问一次(已取到 / 在途都不会重复要)。
704
+ // ⚠️ 所有 hook 必须排在 `if (!state.available) return` **之前**——早退在后面会让两分支的
705
+ // hook 颗数不同,React 直接报 "Rendered fewer hooks than expected"。
706
+ useEffect(() => { props.ensureBundleRows() }, [])
707
+ // 已开启的行排在清单最前面,组内保持 catalog 顺序(`Array#sort` 稳定)。但这份"谁在最前"**只在
708
+ // 进入这一格的那一刻定一次**:当场挪行等于把用户手底下那一行抽走,而"我开了哪几颗"是下次进来时
709
+ // 才要抬头看的事。所以这里存的是一刻的名单(一组 key),不是开关的实时状态;tab 上那个
710
+ // `已开启/总数` 仍然实时——数字当场不说谎,位置不必当场动。
711
+ const enabledKeys = () => new Set(SELECTED_SKILL_CATALOG
712
+ .filter((entry) => state.selectedSkills?.[entry.key]?.on === true)
713
+ .map((entry) => entry.key))
714
+ const [pinnedSkills, setPinnedSkills] = useState(enabledKeys)
715
+ // 首帧可能还没读到配置文档(`available` 为假 ⇒ 那一格压根没画),那一刻量不到谁开着;补一次。
716
+ useEffect(() => { if (state.available) setPinnedSkills(enabledKeys()) }, [state.available])
717
+ // 筛选条按"这一档有几行"从高到低排(同数保持 catalog 里的出现顺序,Array#sort 是稳定的):
718
+ // 眼睛先落到的位置留给最常用的那一档。「全部」不参与排序、恒在第一格——它是"不加筛选",
719
+ // 不是与别的标签竞争的一个筛选值。
720
+ const tagCounts = new Map()
721
+ for (const entry of SELECTED_SKILL_CATALOG) {
722
+ for (const tag of entry.tags) tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1)
723
+ }
724
+ tagCounts.set('all', SELECTED_SKILL_CATALOG.length)
725
+ const tags = ['all', ...Array.from(tagCounts.keys())
726
+ .filter((tag) => tag !== 'all')
727
+ .sort((left, right) => tagCounts.get(right) - tagCounts.get(left))]
728
+ const visibleSkills = SELECTED_SKILL_CATALOG
729
+ .filter((entry) => tagFilter === 'all' || entry.tags.includes(tagFilter))
730
+ .sort((left, right) => (pinnedSkills.has(left.key) ? 0 : 1) - (pinnedSkills.has(right.key) ? 0 : 1))
494
731
  const form = h(SettingsForm, {
495
732
  labels: {
496
733
  unavailable: t('unavailable'),
@@ -508,6 +745,7 @@ function CapitalCard(props) {
508
745
  h(SecretRow, { field: 'wind', label: t('windLabel'), hint: t('windHint'), docsUrl: DOC_URLS.wind, state: state.wind, disabled: disabled || state.saving || !state.wind.writable, onEdit: (value) => props.edit('wind', value), t }),
509
746
  h(SecretRow, { field: 'paddleocr', label: t('paddleocrLabel'), hint: t('paddleocrHint'), docsUrl: DOC_URLS.paddleocr, state: state.paddleocr, disabled: disabled || state.saving || !state.paddleocr.writable, onEdit: (value) => props.edit('paddleocr', value), t }),
510
747
  )
748
+ if (!state.available) return h('div', { className: 'capital-config-card' }, form)
511
749
  const localFetchSection = h('div', { className: 'capital-config-field' },
512
750
  h('div', { className: 'capital-config-switch-row' },
513
751
  h('div', { className: 'capital-config-switch-text' },
@@ -523,11 +761,74 @@ function CapitalCard(props) {
523
761
  ),
524
762
  state.localFetch?.failed ? h('p', { role: 'status', className: 'capital-config-write-failed' }, t('saveFailed')) : null,
525
763
  )
526
- return h('div', { className: 'capital-config-card' },
527
- h('h4', { className: 'capital-config-title' }, t('title')),
528
- form,
529
- state.available ? localFetchSection : null,
530
- state.available ? selectedSkillsSection : null,
764
+ // 筛选条上的数字 = "点它会显示几行",与右侧清单当场数出来的行数同一口径(都按 catalog 现算)。
765
+ // 「按能力筛选」那句字面提示删了:这一列本来就长在清单左边,形状已经说明它是干什么的,
766
+ // 而名字仍挂在 role=group 的 aria-label 上,读屏用户拿得到。
767
+ const tagLabels = SELECTED_SKILL_TAG_LABELS[locale]
768
+ const skillsFilter = h('div', { className: 'capital-config-selected-filter', role: 'group', 'aria-label': t('selectedSkillsFilterLabel') },
769
+ ...tags.map((tag) => h('button', {
770
+ key: tag,
771
+ type: 'button',
772
+ className: 'capital-config-selected-filter-button',
773
+ 'aria-pressed': tagFilter === tag ? 'true' : 'false',
774
+ onClick: () => setTagFilter(tag),
775
+ }, tagLabels[tag] ?? tag, h('span', { className: 'capital-config-selected-filter-count' }, String(tagCounts.get(tag))))),
776
+ )
777
+ const skillRows = visibleSkills.length > 0
778
+ ? visibleSkills.map((entry) => h(SelectedSkillRow, {
779
+ key: entry.key,
780
+ entry,
781
+ state: state.selectedSkills?.[entry.key],
782
+ disabled: disabled || state.saving,
783
+ onChange: (value) => props.toggleSelectedSkill(entry.key, value),
784
+ t,
785
+ }))
786
+ : [h('p', { key: 'empty', className: 'capital-config-hint' }, t('selectedSkillsEmpty'))]
787
+ const enabledSkills = SELECTED_SKILL_CATALOG.filter(
788
+ (entry) => state.selectedSkills?.[entry.key]?.on === true,
789
+ ).length
790
+ // 行数取不到就**只留标题**:把"还不知道"画成 `0` 是控件对自己的状态说谎(数字缺席本身就是信号)。
791
+ const withCount = (label, count) => (Number.isInteger(count) ? `${label} ${count}` : label)
792
+ const options = [
793
+ // 密钥草稿活在 DOM 里但保存按钮此刻看不见 ⇒ 在 tab 上说出去,别让用户以为填丢了。
794
+ { value: 'sources', label: state.dirty ? t('titleUnsaved') : t('title') },
795
+ { value: 'skills', label: `${t('selectedSkillsTitle')} ${enabledSkills}/${SELECTED_SKILL_CATALOG.length}` },
796
+ { value: 'components', label: withCount(t('componentsTitle'), state.components?.count) },
797
+ ]
798
+ const panel = (value, children) => h('section', {
799
+ id: `${TABS_ID}-${value}-panel`,
800
+ role: 'tabpanel',
801
+ 'aria-labelledby': `${TABS_ID}-${value}`,
802
+ className: 'capital-config-panel',
803
+ 'data-capital-config-panel': value,
804
+ hidden: tab !== value,
805
+ }, ...children)
806
+ return h('div', { className: 'capital-config-card', 'data-capital-config-tab': tab },
807
+ h(SegmentedControl, {
808
+ id: TABS_ID,
809
+ value: tab,
810
+ options,
811
+ label: t('tabsLabel'),
812
+ // 不锁:一格里的写是逐字段、逐开关的,切格子不会把它们半路丢下。
813
+ disabled: false,
814
+ onChange: (next) => {
815
+ setTab(next)
816
+ // 每次切到那一格都问一次:行数已取到就什么也不做,一次失败也总能这样重试。
817
+ if (next === 'components') props.ensureBundleRows()
818
+ // 进「精选 Skills」这一格才重排:这一列的顺序 = 此刻开着的那些在上面。刚拨完开关再切回来,
819
+ // 它才升到最前(切走的那一下不动,避免把手底下那一行抽走)。
820
+ if (next === 'skills') setPinnedSkills(enabledKeys())
821
+ },
822
+ }),
823
+ panel('sources', [form, localFetchSection]),
824
+ panel('skills', [h('div', { className: 'capital-config-selected-layout' },
825
+ skillsFilter,
826
+ h('div', { className: 'capital-config-selected-list' }, ...skillRows),
827
+ )]),
828
+ // 第三块的内容是宿主自己画的那段清单(切过来才在下方显形)。画面上不再写那句"清单在下方"——
829
+ // 用户点这一格看到的就是清单,多余的一行字只是噪音。但 `aria-controls` 指的这块面板不许是空的
830
+ // (空面板不报错,只有读屏用户付账),所以那句话改挂在 sr-only 上:零布局贡献、读得到。
831
+ panel('components', [h('span', { className: 'capital-config-sr-only' }, t('componentsHint'))]),
531
832
  )
532
833
  }
533
834
 
@@ -578,6 +879,19 @@ function apply(ctx) {
578
879
  const card = new CapitalCardController(ctx.configForms.get(ENTRY_ID), ctx)
579
880
  ctx.effect(() => () => card.dispose(), 'capital-config: form subscription')
580
881
  ctx.effect(() => ctx.remote.$on('credentials/reference-updated', (ref) => card.refresh(ref)), 'capital-config: credential invalidations')
882
+ // 组件行数的失效:`plugin-manager/changed` 是"装了/停了哪些行"的权威信号,重连后也重取一次
883
+ // (卡片是模块级单例,缓存的数字活过页面来回)。disposer 必须自己合并成一颗——`ctx.effect`
884
+ // 只认一个返回的清理函数,返回数组等于什么都没清理。
885
+ ctx.effect(() => {
886
+ const refresh = () => card.invalidateBundleRows()
887
+ const disposers = [
888
+ ctx.remote.$on('plugin-manager/changed', refresh),
889
+ ctx.on('connection/reset', refresh),
890
+ ]
891
+ return () => {
892
+ for (const dispose of disposers) dispose()
893
+ }
894
+ }, 'capital-config: bundle row invalidations')
581
895
  // 只有 Host 真的在服务这个条目时才注册卡片:没装配 capital-config 的部署里不留痕迹。
582
896
  // 座位是**组合包自己的页面**,key 是包名而非 `<包名>#<行 id>`——条目 id 与槽 key 因此是两个
583
897
  // 不同的字符串,各自的同字链见文件头。
@@ -598,5 +912,5 @@ function apply(ctx) {
598
912
  }
599
913
 
600
914
  exports.name = 'capital-config'
601
- exports.inject = ['slots', 'locale', 'remote', 'remote.credentials', 'configForms']
915
+ exports.inject = ['slots', 'locale', 'remote', 'remote.credentials', 'remote.pluginManager', 'configForms']
602
916
  exports.apply = apply