dsh-loadout 0.12.4 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -35,8 +35,9 @@ window.__ModuleLoader__.load({
35
35
  /** This plugin's own tools never belong inside a kit. */
36
36
  const OWN_TOOLS = ['kit_list', 'kit_apply', 'kit_create', 'kit_set_tools', 'kit_set_default', 'kit_delete']
37
37
 
38
- /** The exact sentence the spec fixes for the panel footer. */
39
- const FOOTER = '装备栏只决定「这个对话能用哪些工具」:切换后下一条消息即按新清单组装,随时可以切回「全部」;新对话则从「设置 → 装备栏」的默认套装开始。它作用于工具(tool)这一层——界面面板、宿主服务类插件不注册工具,因此不受影响。'
38
+ /** The panel footer: what this menu does, then the one thing worth acting on. */
39
+ const FOOTER = '装备栏只决定「这个对话能用哪些工具」:切换后下一条消息即按新清单组装,随时可以切回「全部」;装备栏作用于工具(tool)这一层——界面面板、宿主服务类插件不注册工具,因此不受影响。'
40
+ const FOOTER_WARN = '建议新建对话时直接选择合适套装!对话中途切换套装会改变插件清单,下一条对话要按未命中价重新计算一次,之后恢复至缓存价。长对话影响较大,短对话基本无影响;空闲对话、刚压缩的对话、重启后再切换无影响。'
40
41
 
41
42
  /** A kit's one-line reminder; keep in step with NOTE_MAX in index.js. */
42
43
  const NOTE_MAX = 20
@@ -86,6 +87,53 @@ window.__ModuleLoader__.load({
86
87
  return `${(n / 1000).toFixed(n < 10000 ? 1 : 0)}k`
87
88
  }
88
89
 
90
+ /** The one binding value that is not a kit: "Harness built-ins only". */
91
+ const DEFAULT_DSH = '@dsh'
92
+
93
+ // The cost of a switch is quoted at provider list prices, in ¥ per million
94
+ // tokens. It is a setting, not a constant: prices move, and a wrong number
95
+ // here can only make an estimate wrong — it never changes what a switch does.
96
+ const PRICE_KEY = 'dsh-loadout:pricing'
97
+ const PRICE_DEFAULT = { miss: 2, hit: 0.5 }
98
+ /** Below this the switch is lost in the noise, and a warning would be noise. */
99
+ const PRICE_MIN_TOKENS = 20000
100
+
101
+ function readPrices() {
102
+ try {
103
+ const raw = window.localStorage?.getItem(PRICE_KEY)
104
+ if (raw === null || raw === undefined) return { ...PRICE_DEFAULT }
105
+ const parsed = JSON.parse(raw)
106
+ const miss = Number(parsed?.miss)
107
+ const hit = Number(parsed?.hit)
108
+ return {
109
+ miss: Number.isFinite(miss) && miss >= 0 ? miss : PRICE_DEFAULT.miss,
110
+ hit: Number.isFinite(hit) && hit >= 0 ? hit : PRICE_DEFAULT.hit,
111
+ }
112
+ } catch {
113
+ return { ...PRICE_DEFAULT }
114
+ }
115
+ }
116
+
117
+ function writePrices(next) {
118
+ try {
119
+ window.localStorage?.setItem(PRICE_KEY, JSON.stringify(next))
120
+ } catch {
121
+ // A browser without storage just keeps the defaults for this page.
122
+ }
123
+ }
124
+
125
+ /**
126
+ * The bill for one switch. The floor that kept row labels quiet is gone —
127
+ * rows no longer quote money at all (they quote tool tokens), so the footer
128
+ * is the only place the number appears and it always appears there.
129
+ */
130
+ function switchCost(tokens, prices) {
131
+ const n = Number(tokens)
132
+ if (!Number.isFinite(n) || n <= 0) return ''
133
+ const yuan = (n / 1e6) * Math.max(0, prices.miss - prices.hit)
134
+ return `≈¥${yuan < 0.1 ? yuan.toFixed(3) : yuan.toFixed(2)}`
135
+ }
136
+
89
137
  // Every mutation fans out to the other panel. The chip and the settings page
90
138
  // are separate React trees with separate snapshots, so a switch made in one
91
139
  // used to leave the other showing the old numbers until it remounted.
@@ -195,6 +243,12 @@ window.__ModuleLoader__.load({
195
243
  fontSize: 11, opacity: 0.62, marginTop: 8, lineHeight: 1.6,
196
244
  borderTop: '1px solid var(--ds-border, #e5e7eb)', paddingTop: 6,
197
245
  },
246
+ // The one paragraph in the panel that asks for a decision before the
247
+ // first message, so it is not dimmed with the rest of the footer.
248
+ warnLine: { marginTop: 6, color: '#b45309', opacity: 0.95, lineHeight: 1.6 },
249
+ // The bill, stated once under the reminder: quieter than the warning
250
+ // because it explains the numbers rather than asking for anything.
251
+ billLine: { marginTop: 4, fontWeight: 600, color: '#111827', lineHeight: 1.6 },
198
252
  error: { fontSize: 11, color: '#dc2626', marginTop: 6, lineHeight: 1.5 },
199
253
  notice: { fontSize: 11, color: '#2563eb', marginTop: 6, lineHeight: 1.5 },
200
254
  input: {
@@ -202,6 +256,12 @@ window.__ModuleLoader__.load({
202
256
  border: '1px solid var(--ds-border, #d8dbe0)', background: 'transparent', color: 'inherit',
203
257
  font: 'inherit', fontSize: 12,
204
258
  },
259
+ // A price is four characters wide, not the width of the panel.
260
+ priceInput: {
261
+ width: 72, boxSizing: 'border-box', padding: '3px 6px', borderRadius: 6,
262
+ border: '1px solid var(--ds-border, #d8dbe0)', background: 'transparent', color: 'inherit',
263
+ font: 'inherit', fontSize: 12,
264
+ },
205
265
  iconButton: {
206
266
  border: 'none', background: 'transparent', color: 'inherit', cursor: 'pointer',
207
267
  fontSize: 13, lineHeight: 1, padding: '2px 4px', opacity: 0.7, flex: 'none',
@@ -231,6 +291,19 @@ window.__ModuleLoader__.load({
231
291
  background: 'rgba(37, 99, 235, 0.05)',
232
292
  },
233
293
  tag: { fontSize: 10, padding: '0 5px', borderRadius: 4, border: '1px solid currentColor', opacity: 0.7 },
294
+ // The new-conversation picker, sitting in the composer's tool row next to
295
+ // 权限/计划. It is one compact chip; the list floats above the row (below
296
+ // is the window edge), and opening it never reflows the page.
297
+ pickWrap: { position: 'relative', display: 'inline-flex', alignItems: 'center' },
298
+ chipMuted: { opacity: 0.55 },
299
+ chipStrong: { fontWeight: 600 },
300
+ pickPanel: {
301
+ position: 'absolute', bottom: 'calc(100% + 6px)', left: 0, zIndex: 60,
302
+ display: 'flex', flexDirection: 'column', gap: 2, padding: 6, borderRadius: 10,
303
+ border: '1px solid var(--ds-border, #d8dbe0)', background: 'var(--ds-surface, #fff)',
304
+ boxShadow: '0 12px 32px rgba(0,0,0,.16)', width: 260, maxHeight: 320, overflowY: 'auto',
305
+ fontSize: 12,
306
+ },
234
307
  }
235
308
 
236
309
  // ---- small pieces -------------------------------------------------------
@@ -291,7 +364,10 @@ window.__ModuleLoader__.load({
291
364
  }, toolName)))
292
365
  }
293
366
 
294
- /** A selectable line: ✓ marker, label (with an optional tooltip), hint. */
367
+ /**
368
+ * A selectable line: ✓ marker, label (with an optional tooltip), hint, and
369
+ * an optional `aside` — a quiet secondary action that stops the click.
370
+ */
295
371
  function MenuRow(props) {
296
372
  const [hover, setHover] = useState(false)
297
373
  const disabled = props.disabled === true
@@ -327,7 +403,9 @@ window.__ModuleLoader__.load({
327
403
  ? h('span', null, props.label)
328
404
  : h(Tip, { text: props.tip }, h('span', null, props.label))),
329
405
  h('span', { style: { flex: 1 } }),
330
- props.hint === undefined || props.hint === '' ? null : h('span', { style: styles.meta }, props.hint))
406
+ props.hint === undefined || props.hint === '' ? null : (
407
+ h('span', { style: styles.meta }, props.hint)
408
+ ))
331
409
  }
332
410
 
333
411
  /** A collapsible group header: caret, title with its count, optional action. */
@@ -365,6 +443,7 @@ window.__ModuleLoader__.load({
365
443
  const {
366
444
  catalog, draft, onChange, busy, onCancel, onSave, saveLabel,
367
445
  title, onSaveAs, saveAsLabel, nameField, namePlaceholder, clash,
446
+ onSaveKeep, saveKeepLabel, saveTip,
368
447
  } = props
369
448
  // Both lists start collapsed: the builder opens with a name field and a
370
449
  // summary, not with forty checkboxes.
@@ -446,14 +525,247 @@ window.__ModuleLoader__.load({
446
525
  onSaveAs === undefined
447
526
  ? null
448
527
  : h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: onSaveAs }, saveAsLabel ?? '另存为新套装'),
528
+ // Saving and using are two decisions. The menu may do both, so it
529
+ // offers both; the settings page only ever saves a kit for later.
530
+ onSaveKeep === undefined
531
+ ? null
532
+ : h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: onSaveKeep }, saveKeepLabel ?? '保存'),
449
533
  h('button', {
450
534
  type: 'button',
451
535
  style: { ...styles.primary, ...(typeof clash === 'string' && clash !== '' ? { opacity: 0.5 } : null) },
452
536
  disabled: busy || (typeof clash === 'string' && clash !== ''),
537
+ // `title`, not Tip: this button sits inside the menu's scrolling
538
+ // body, so a floating bubble would be clipped by the same overflow
539
+ // that keeps the panel short. A native tooltip always fits.
540
+ title: saveTip,
453
541
  onClick: onSave,
454
542
  }, saveLabel ?? '保存')))
455
543
  }
456
544
 
545
+ // ---- the new-conversation picker ---------------------------------------
546
+ /**
547
+ * The kit picker a brand-new conversation shows in the hero row.
548
+ *
549
+ * A conversation that has not sent a message usually has no agent yet, so
550
+ * there is nothing to "switch": the pick is stored as this Session's binding
551
+ * and installed the moment the agent appears — before the first request
552
+ * builds a prompt, which makes it the one switch that never re-bills the
553
+ * prompt cache. The row leaves with the hero at the first send.
554
+ *
555
+ * Chips instead of a popover on purpose: the hero area is empty by
556
+ * definition, so an inline row needs no measuring and cannot be clipped the
557
+ * way the header's panel would be.
558
+ */
559
+ function HeroKit(props) {
560
+ const sessionId = props.sessionId
561
+ // `preview` is a test seam for scripts/client-smoke.mjs, same as HeaderMenu.
562
+ const preview = props.preview ?? null
563
+ const [data, setData] = useState(preview?.data ?? null)
564
+ const [busy, setBusy] = useState(false)
565
+ // What the hover sentence says once a pick has landed (or why it failed);
566
+ // the row itself never grows a message line.
567
+ const [picked, setPicked] = useState(false)
568
+ const [failed, setFailed] = useState('')
569
+ // Collapsed by default: the picker answers "which kit will this
570
+ // conversation start with", and the list is what you open to change it.
571
+ const [open, setOpen] = useState(preview?.open === true)
572
+ const [groups, setGroups] = useState(preview?.groups ?? { presets: false, kits: false })
573
+ const box = useRef(null)
574
+ // One visit, one first look: a draft left by an earlier visit is dropped
575
+ // before it is ever shown, so the screen starts where the host's own mode
576
+ // starts — at the default.
577
+ const firstLook = useRef(true)
578
+ const lastSession = useRef(sessionId)
579
+ // `useSession` is a standard prop for session-scoped slots; a host that
580
+ // does not hand it over still only renders this slot in the hero phase.
581
+ const useSession = props.useSession
582
+ const blank = preview?.blank ?? (typeof useSession === 'function'
583
+ ? (useSession((snapshot) => snapshot?.blank) ?? true)
584
+ : true)
585
+
586
+ const refresh = useCallback(async () => {
587
+ if (typeof sessionId !== 'string' || sessionId === '') return
588
+ // A different conversation in the same slot is a different visit.
589
+ if (lastSession.current !== sessionId) {
590
+ lastSession.current = sessionId
591
+ firstLook.current = true
592
+ }
593
+ try {
594
+ let next = await load(sessionId)
595
+ // A draft pick left on this welcome screen by a previous visit is not a
596
+ // decision: ask the host to drop it before showing anything, so the
597
+ // user sees the same clean slate the host gives its own mode. This has
598
+ // to cover a welcome screen that already has an agent behind it — that
599
+ // is the common case, and skipping it there is what let a draft from
600
+ // the last visit outlive the restart that should have cleared it.
601
+ if (firstLook.current === true && next.draft === true) {
602
+ next = await request({ action: 'release', sessionId })
603
+ }
604
+ // A blank screen has no history, so nothing is lost by agreeing with
605
+ // it: whatever the chip shows is what the next message must use. That
606
+ // covers a conversation created while a different default was set —
607
+ // the store still carries that kit, and it used to be shown as if it
608
+ // had been chosen here. `follow` keeps the correction borrowed (no
609
+ // draft), so a later default change moves it again.
610
+ if (blank === true) {
611
+ const wanted = next.draft === true
612
+ ? String(next.binding ?? '')
613
+ : (typeof next.defaultKit === 'string' ? next.defaultKit : '')
614
+ if (String(next.binding ?? '') !== wanted) {
615
+ try {
616
+ next = await request({ action: 'bind', sessionId, name: wanted, follow: true })
617
+ } catch {
618
+ // Following is a courtesy, not the answer: if the host refuses,
619
+ // `load` already told the truth about what this screen uses.
620
+ }
621
+ }
622
+ }
623
+ firstLook.current = false
624
+ setData(next)
625
+ } catch {
626
+ // "No agent yet" is a normal answer for this route, not something to
627
+ // paint red above the composer.
628
+ }
629
+ }, [sessionId])
630
+
631
+ useEffect(() => {
632
+ refresh()
633
+ }, [refresh])
634
+
635
+ // The picker outlives the settings page: the default kit it shows when
636
+ // nothing is bound can change while this visit stays open, and coming back
637
+ // to the composer must not show the kit that was current at mount. Same
638
+ // sources the header menu listens to, plus focus for changes made in
639
+ // another window.
640
+ useEffect(() => {
641
+ const sync = () => { refresh() }
642
+ window.addEventListener(CHANGED_EVENT, sync)
643
+ window.addEventListener('focus', sync)
644
+ return () => {
645
+ window.removeEventListener(CHANGED_EVENT, sync)
646
+ window.removeEventListener('focus', sync)
647
+ }
648
+ }, [refresh])
649
+
650
+ // The list floats over the composer instead of pushing it down: a select
651
+ // that resizes the page every time it opens is worse than a covered input.
652
+ useEffect(() => {
653
+ if (!open) return undefined
654
+ const away = (event) => {
655
+ if (box.current !== null && !box.current.contains(event.target)) setOpen(false)
656
+ }
657
+ document.addEventListener('mousedown', away)
658
+ return () => document.removeEventListener('mousedown', away)
659
+ }, [open])
660
+
661
+ // The name a pick shows: the kit's title, or the two base names — `@dsh`
662
+ // is this plugin's wire value for 仅内置 and must never surface as text.
663
+ const labelOf = (name) => {
664
+ if (name === '') return '全部'
665
+ if (name === DEFAULT_DSH) return '仅内置'
666
+ const found = [...(data?.kits ?? []), ...(data?.presets ?? [])].find((kit) => kit.name === name)
667
+ return found?.title ?? found?.name ?? name
668
+ }
669
+
670
+ const pick = async (name) => {
671
+ if (busy || typeof sessionId !== 'string' || sessionId === '') return
672
+ setBusy(true)
673
+ setFailed('')
674
+ try {
675
+ setData(await request({ action: 'bind', sessionId, name }))
676
+ setOpen(false)
677
+ setPicked(true)
678
+ } catch (error) {
679
+ setFailed(String(error?.message ?? error))
680
+ } finally {
681
+ setBusy(false)
682
+ }
683
+ }
684
+
685
+ if (!blank || data === null || typeof sessionId !== 'string' || sessionId === '') return null
686
+ const kits = data.kits ?? []
687
+ const presets = data.presets ?? []
688
+ // `bound` separates "this conversation chose 全部" from "nothing chosen
689
+ // yet": without it, a fresh conversation would claim 全部 even when the
690
+ // default kit is what will actually start it.
691
+ const current = data.bound === true
692
+ ? (typeof data.binding === 'string' ? data.binding : '')
693
+ : (typeof data.defaultKit === 'string' ? data.defaultKit : '')
694
+ // The list marks the default kit the way the header menu does — after the
695
+ // name. The collapsed chip stays plain (`选择对话套装:idea`): there it is
696
+ // the current pick, and "default" is not what the sentence says.
697
+ const isDefaultName = (name) => typeof data.defaultKit === 'string' && data.defaultKit === name
698
+ const marked = (label, name) => (isDefaultName(name) ? `${label}(默认)` : label)
699
+ const defaultKitRow = kits.find((kit) => kit.name === data.defaultKit)
700
+ const defaultPresetRow = presets.find((preset) => preset.name === data.defaultKit)
701
+ const row = (name, label, tip) => h(MenuRow, {
702
+ key: `pick-${name === '' ? 'all' : name}`,
703
+ label,
704
+ tip,
705
+ active: current === name,
706
+ indent: true,
707
+ disabled: busy,
708
+ onClick: () => pick(name),
709
+ })
710
+ const kitRow = (kit) => h(MenuRow, {
711
+ key: `pick-${kit.name}`,
712
+ label: marked(kitLabel(kit), kit.name),
713
+ active: current === kit.name,
714
+ indent: true,
715
+ disabled: busy,
716
+ onClick: () => pick(kit.name),
717
+ })
718
+
719
+ // The sentence lives on hover, not on the row: the composer line is shared
720
+ // with the permission and plan chips, so the control only says what it is
721
+ // plus the current pick.
722
+ const tipText = failed !== ''
723
+ ? failed
724
+ : picked
725
+ ? `这个对话会用「${labelOf(current)}」套装,下一条消息生效`
726
+ : `这个新对话会用「${labelOf(current)}」套装`
727
+
728
+ return h('span', { ref: box, style: styles.pickWrap, 'data-loadout': 'kit-pick' },
729
+ h('button', {
730
+ type: 'button',
731
+ style: { ...styles.chip, ...(open ? styles.chipActive : null) },
732
+ 'aria-expanded': open ? 'true' : 'false',
733
+ 'aria-label': tipText,
734
+ title: tipText,
735
+ onClick: () => setOpen(!open),
736
+ },
737
+ h('span', { style: styles.chipMuted }, '选择对话套装'),
738
+ h('span', { style: styles.chipStrong }, labelOf(current)),
739
+ h('span', { style: styles.caret }, open ? '▾' : '▸')),
740
+ open
741
+ ? h('div', { style: styles.pickPanel },
742
+ row('', marked('全部', ''), '不裁剪:Harness 自带工具和所有已启用插件的工具都能用。'),
743
+ row(DEFAULT_DSH, marked('仅内置', DEFAULT_DSH), '只保留 Harness 自带的工具,下载插件的工具全部隐藏。'),
744
+ h(GroupHead, {
745
+ title: '预设套装',
746
+ count: presets.length,
747
+ open: groups.presets,
748
+ tip: '出厂自带的只读套装,点一下即可套用。',
749
+ onToggle: () => setGroups({ ...groups, presets: !groups.presets }),
750
+ }),
751
+ // A collapsed group still shows the default kit: the picker is how
752
+ // you see what this conversation will start with, and hiding it
753
+ // behind a fold would answer "nothing chosen" on a fresh screen.
754
+ groups.presets === true
755
+ ? presets.map(kitRow)
756
+ : (defaultPresetRow ? kitRow(defaultPresetRow) : null),
757
+ h(GroupHead, {
758
+ title: '我的套装',
759
+ count: kits.length,
760
+ open: groups.kits,
761
+ tip: '你自己保存的套装,点一下即可套用。',
762
+ onToggle: () => setGroups({ ...groups, kits: !groups.kits }),
763
+ }),
764
+ groups.kits === true ? kits.map(kitRow) : (defaultKitRow ? kitRow(defaultKitRow) : null))
765
+ : null,
766
+ )
767
+ }
768
+
457
769
  // ---- the header control -------------------------------------------------
458
770
  function HeaderMenu(props) {
459
771
  const sessionId = props.sessionId
@@ -462,6 +774,9 @@ window.__ModuleLoader__.load({
462
774
  const preview = props.preview ?? null
463
775
  const [open, setOpen] = useState(preview?.open === true)
464
776
  const [data, setData] = useState(preview?.data ?? null)
777
+ // Prices live in this browser, so the chip reads them at mount and again
778
+ // whenever the settings page announces a change.
779
+ const [prices, setPrices] = useState(readPrices)
465
780
  // Both kit groups start collapsed. The menu is a switcher, not a directory:
466
781
  // it has to show which set is on, not enumerate every set every time. A
467
782
  // collapsed group still shows the one active row, and clicking the head is
@@ -521,9 +836,12 @@ window.__ModuleLoader__.load({
521
836
  }, [refresh])
522
837
 
523
838
  // Another surface (the settings page, the /kit command) may have changed
524
- // this conversation's binding while the chip sat idle.
839
+ // this conversation's binding — or the price settings — while the chip sat idle.
525
840
  useEffect(() => {
526
- const sync = () => { refresh() }
841
+ const sync = () => {
842
+ setPrices(readPrices())
843
+ refresh()
844
+ }
527
845
  document.addEventListener('dsh-loadout:changed', sync)
528
846
  window.addEventListener('focus', sync)
529
847
  return () => {
@@ -569,6 +887,38 @@ window.__ModuleLoader__.load({
569
887
  const activePreset = base === 'kit' ? presets.find((kit) => kit.name === selection.kit) : undefined
570
888
  const applied = data?.appliedPlugins ?? []
571
889
  const available = data?.availableExtras ?? plugins.map((plugin) => plugin.id)
890
+ // Prompt size comes from the host's `contextPressure` projection; it is
891
+ // null until this conversation has sent its first request.
892
+ const contextTokens = Number(data?.context?.tokens) || 0
893
+ // One sentence for the bill, stated for every conversation size. The same
894
+ // text a tooltip used to carry per row — moved here because the rows are
895
+ // read at a glance and the reminder under them is what people act on.
896
+ const billSentence = contextTokens > 0
897
+ ? `本对话上下文(约 ${formatTokens(contextTokens)} tokens),下一条未命中价${switchCost(contextTokens, prices)},之后恢复至缓存价。`
898
+ : '这个对话还没有发出第一条消息;切换后第一条消息会把整套工具定义按未命中价算一次,之后恢复至缓存价。'
899
+
900
+ /**
901
+ * This menu belongs to one conversation: it may switch that conversation
902
+ * and it may build a new kit, but choosing what *new* conversations start
903
+ * from is a global decision and lives in 设置 → 装备栏 (`defaultKit`).
904
+ */
905
+
906
+ /**
907
+ * A row's weight in the prompt: the tool definitions it would install.
908
+ *
909
+ * The price of *switching* is one number for the whole conversation — it
910
+ * depends on how long the conversation is, not on which kit you pick — so
911
+ * printing it on every row made 全部, 仅内置 and each kit read identically.
912
+ * The rows differ in how many tokens of tools they add, and that is what
913
+ * these hints now say; the bill itself sits once, under the reminder.
914
+ */
915
+ const toolTokens = (entry) => (Number.isFinite(entry?.tokens) ? `≈${formatTokens(entry.tokens)} tokens` : '')
916
+ const withDefault = (label, isDefault) => (isDefault ? `${label}(默认)` : label)
917
+
918
+ /** `≈2.4k tokens · 只读`, and nothing at all when the row has no price. */
919
+ const rowHint = (parts) => parts
920
+ .filter((part) => part !== undefined && part !== '' && part !== false)
921
+ .join(' · ')
572
922
 
573
923
  const run = async (payload) => {
574
924
  setBusy(true)
@@ -595,7 +945,7 @@ window.__ModuleLoader__.load({
595
945
  setSaveAs(false)
596
946
  }
597
947
 
598
- const saveBuilder = async () => {
948
+ const saveBuilder = async (mode) => {
599
949
  if (builder === null) return
600
950
  const kitName = builder.name.trim()
601
951
  if (kitName === '') {
@@ -617,8 +967,14 @@ window.__ModuleLoader__.load({
617
967
  plugins: builder.plugins,
618
968
  })
619
969
  setData(created)
620
- const appliedKit = await run({ action: 'applyBase', base: 'kit', kit: kitName })
621
970
  setBuilder(null)
971
+ if (mode === 'keep') {
972
+ // Saving is not switching: the kit is now on the list, and this
973
+ // conversation keeps the tools it already paid for.
974
+ setMessage(`已保存「${kitName}」。要在这个对话用它,点标题栏的「装备栏」。`)
975
+ return
976
+ }
977
+ const appliedKit = await run({ action: 'applyBase', base: 'kit', kit: kitName })
622
978
  if (appliedKit !== null) setMessage(`已保存并启用「${kitName}」`)
623
979
  } catch (error) {
624
980
  setMessage(String(error.message ?? error))
@@ -690,7 +1046,10 @@ window.__ModuleLoader__.load({
690
1046
 
691
1047
  const ledger = data?.ledger
692
1048
  const saved = ledger?.savedTokens ?? 0
693
- const summary = `当前:${label === '' ? '全部' : label}${extras.length === 0 ? '' : ` · 加挂 ${extras.length}`}${saved > 0 ? ` · 省 ~${formatTokens(saved)} tokens` : ''}`
1049
+ // The context size is not a warning, it is the denominator of every price
1050
+ // below: "why is this 0.23" needs "because this conversation is 200k".
1051
+ const contextNote = contextTokens >= PRICE_MIN_TOKENS ? ` · 上下文 ~${formatTokens(contextTokens)}` : ''
1052
+ const summary = `当前:${label === '' ? '全部' : label}${extras.length === 0 ? '' : ` · 加挂 ${extras.length}`}${saved > 0 ? ` · 省 ~${formatTokens(saved)} tokens` : ''}${contextNote}`
694
1053
 
695
1054
 
696
1055
  const rootBody = h('div', null,
@@ -705,16 +1064,19 @@ window.__ModuleLoader__.load({
705
1064
  onClick: () => { run({ action: 'refresh' }) },
706
1065
  }, '⟳')),
707
1066
  h(MenuRow, {
708
- label: '全部',
1067
+ label: withDefault('全部', (data?.defaultKit ?? '') === ''),
709
1068
  tip: '不裁剪:Harness 自带工具和所有已启用插件的工具都能用。',
710
- hint: '不限制',
1069
+ // Both base rows pay for their own tool table: 全部 installs every
1070
+ // tool there is, 仅内置 only the built-in ones. That difference is the
1071
+ // reason to pick one over the other, so both rows state it.
1072
+ hint: rowHint([toolTokens({ tokens: data?.baseTokens?.all })]),
711
1073
  active: isAll,
712
1074
  onClick: () => run({ action: 'applyBase', base: 'all' }),
713
1075
  }),
714
1076
  h(MenuRow, {
715
- label: '仅内置',
1077
+ label: withDefault('仅内置', (data?.defaultKit ?? '') === '@dsh'),
716
1078
  tip: '只保留 Harness 自带的工具,下载插件的工具全部隐藏。',
717
- hint: '隐藏插件',
1079
+ hint: rowHint([toolTokens({ tokens: data?.baseTokens?.dsh })]),
718
1080
  active: isDsh,
719
1081
  onClick: () => run({ action: 'applyBase', base: 'dsh' }),
720
1082
  }),
@@ -738,8 +1100,8 @@ window.__ModuleLoader__.load({
738
1100
  // creates a set — so "new kit" never needs a click on the head first.
739
1101
  activeUserKit === undefined ? null : h(MenuRow, {
740
1102
  key: 'active-kit',
741
- label: kitLabel(activeUserKit),
742
- hint: activeUserKit.isDefault ? '默认' : '',
1103
+ label: withDefault(kitLabel(activeUserKit), activeUserKit.isDefault),
1104
+ hint: rowHint([toolTokens(activeUserKit)]),
743
1105
  active: true,
744
1106
  indent: true,
745
1107
  onClick: () => run({ action: 'applyBase', base: 'kit', kit: activeUserKit.name }),
@@ -749,8 +1111,8 @@ window.__ModuleLoader__.load({
749
1111
  .filter((kit) => activeUserKit === undefined || kit.name !== activeUserKit.name)
750
1112
  .map((kit) => h(MenuRow, {
751
1113
  key: kit.name,
752
- label: kitLabel(kit),
753
- hint: kit.isDefault ? '默认' : '',
1114
+ label: withDefault(kitLabel(kit), kit.isDefault),
1115
+ hint: rowHint([toolTokens(kit)]),
754
1116
  indent: true,
755
1117
  onClick: () => {
756
1118
  run({ action: 'applyBase', base: 'kit', kit: kit.name })
@@ -783,8 +1145,8 @@ window.__ModuleLoader__.load({
783
1145
  SHOW_PRESETS === true && groups.presets !== true && activePreset !== undefined
784
1146
  ? h(MenuRow, {
785
1147
  key: 'active-preset',
786
- label: activePreset.title,
787
- hint: '只读',
1148
+ label: withDefault(activePreset.title, data?.defaultKit === activePreset.name),
1149
+ hint: rowHint([toolTokens(activePreset), '只读']),
788
1150
  active: true,
789
1151
  indent: true,
790
1152
  onClick: () => run({ action: 'applyBase', base: 'kit', kit: activePreset.name }),
@@ -795,8 +1157,8 @@ window.__ModuleLoader__.load({
795
1157
  ? h('div', { key: 'none', style: { ...styles.meta, padding: '2px 6px' } }, '没有预置套装')
796
1158
  : presets.map((preset) => h(MenuRow, {
797
1159
  key: preset.name,
798
- label: preset.title,
799
- hint: '只读',
1160
+ label: withDefault(preset.title, data?.defaultKit === preset.name),
1161
+ hint: rowHint([toolTokens(preset), '只读']),
800
1162
  active: activePreset !== undefined && activePreset.name === preset.name,
801
1163
  indent: true,
802
1164
  onClick: () => {
@@ -808,9 +1170,13 @@ window.__ModuleLoader__.load({
808
1170
  appliedBlock,
809
1171
  h(GroupHead, {
810
1172
  title: '加挂',
811
- count: extras.length,
1173
+ // "already in this conversation / plugins there are at all". For the
1174
+ // left number that means the plugins the base brings plus the ones
1175
+ // attached on top; under 全部 it is all of them, so it reads 6/6 and
1176
+ // there is nothing left to add.
1177
+ count: `${plugins.filter((plugin) => applied.includes(plugin.id) || extras.includes(plugin.id)).length}/${plugins.length}`,
812
1178
  open: groups.extras,
813
- tip: '在当前选择之上临时加开插件,不新建套装。已经包含的插件不能再加。',
1179
+ tip: '在当前选择上临时加开插件,不新建套装。已经包含的插件不能再加。',
814
1180
  onToggle: () => setGroups({ ...groups, extras: !groups.extras }),
815
1181
  action: extras.length === 0
816
1182
  ? null
@@ -880,7 +1246,14 @@ window.__ModuleLoader__.load({
880
1246
  : null)
881
1247
  : null)
882
1248
  : null,
883
- h('div', { style: styles.footer }, FOOTER),
1249
+ h('div', { style: styles.footer },
1250
+ h('div', null, FOOTER),
1251
+ h('div', { style: styles.warnLine },
1252
+ h('strong', { style: { fontWeight: 600 } }, '重要提醒:'),
1253
+ FOOTER_WARN),
1254
+ // The bill, in one place instead of under every row: the reminder
1255
+ // above is the thing people read, and a tooltip has to be found.
1256
+ h('div', { style: styles.billLine }, billSentence)),
884
1257
  message === '' ? null : h('div', { style: styles.error }, message))
885
1258
 
886
1259
  const body = builder === null
@@ -891,8 +1264,10 @@ window.__ModuleLoader__.load({
891
1264
  onChange: (next) => { setBuilder(next); setMessage('') },
892
1265
  busy,
893
1266
  onCancel: () => setBuilder(null),
894
- onSave: saveBuilder,
1267
+ onSave: () => saveBuilder('apply'),
1268
+ onSaveKeep: () => saveBuilder('keep'),
895
1269
  saveLabel: '保存并启用',
1270
+ saveTip: '工具清单会变,这个对话下一条消息要按未命中价重新计算一次。',
896
1271
  clash: nameClash(builder.name, presets, kits, null),
897
1272
  })
898
1273
 
@@ -931,10 +1306,15 @@ window.__ModuleLoader__.load({
931
1306
  // settings list they sit next to kits, and two actions make sense for them:
932
1307
  // copy the command that switches a conversation, or make them the default.
933
1308
  function DefaultCard(props) {
934
- const { title, description, isDefault, onUse, onDefault } = props
1309
+ const { title, description, isDefault, tokens, onUse, onDefault } = props
935
1310
  return h('div', { style: styles.card },
936
1311
  h('div', { style: { display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' } },
937
1312
  h('strong', null, title),
1313
+ // Same unit as the kit cards: what this choice costs in tools. Without
1314
+ // it 全部 and 仅内置 were the only two cards in the page with no number.
1315
+ Number.isFinite(tokens)
1316
+ ? h('span', { style: styles.meta, title: '按工具定义估算(约 3.5 字符 / token)' }, `≈${formatTokens(tokens)} tokens`)
1317
+ : null,
938
1318
  isDefault ? h('span', { style: { ...styles.meta, color: '#2563eb' } }, '默认') : null,
939
1319
  h('span', { style: { flex: 1 } }),
940
1320
  h('button', { type: 'button', style: styles.ghost, onClick: onUse }, '复制切换命令'),
@@ -1164,6 +1544,11 @@ window.__ModuleLoader__.load({
1164
1544
  }
1165
1545
 
1166
1546
  return h('div', { style: { padding: '4px 2px', fontSize: 12 } },
1547
+ // This page edits defaults, not a conversation: saying so up front is
1548
+ // cheaper than explaining why the numbers below never change while the
1549
+ // user is only renaming a kit.
1550
+ h('div', { style: { ...styles.meta, marginBottom: 8, lineHeight: 1.6 } },
1551
+ '设置页不绑定具体会话,上面的数字来自最近活跃的那个对话。'),
1167
1552
  h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
1168
1553
  h('button', { type: 'button', style: styles.ghost, disabled: busy, onClick: refresh }, '刷新'),
1169
1554
  h('button', {
@@ -1203,13 +1588,15 @@ window.__ModuleLoader__.load({
1203
1588
  '(估算:按工具定义长度 ÷ 3.5 字符/token)',
1204
1589
  ].join(' · ')),
1205
1590
  // This panel has no Session of its own: the host answers a bare GET from
1206
- // the most recently seen agent. Saying so is cheaper than explaining why
1207
- // the numbers "don't change" when the user is only editing a default.
1208
- h('div', { style: { ...styles.meta, marginTop: 4 } },
1209
- '设置页不绑定具体会话,上面的数字来自最近活跃的那个对话;当前对话请在标题栏「装备栏」里切换。'),
1210
- h('div', { style: styles.sectionTitle, marginTop: 14 }, '新对话默认套装'),
1591
+ // the most recently seen agent, so the ledger above is that conversation's.
1592
+ // One string, one style. As two spans the caveat sat behind a flex gap
1593
+ // and carried its own 0.6 opacity inside a heading that is already 0.6 —
1594
+ // nested opacity multiplies, so it rendered dimmer than the title next to
1595
+ // it and read as a different font rather than the rest of the sentence.
1596
+ h('div', { style: { ...styles.sectionTitle, marginTop: 14 } }, '新对话默认套装只影响新建对话'),
1211
1597
  h('div', { style: { ...styles.meta, marginBottom: 6 } },
1212
- '只影响之后新建的对话;已经开着的对话各自保留自己的选择,这里改不到它们。'),
1598
+ // The rest of the sentence stays abbreviated for the 370px panel.
1599
+ '当前对话在「装备栏」切换;新建时也可选套装。'),
1213
1600
  // 全部 and 仅内置 are choices, not kits, but they read like the kit cards
1214
1601
  // below (copy the switch command / make it the default) so the page has
1215
1602
  // one idiom instead of two.
@@ -1217,6 +1604,7 @@ window.__ModuleLoader__.load({
1217
1604
  h(DefaultCard, {
1218
1605
  title: '全部',
1219
1606
  description: '内置工具和已启用插件的工具都能用。',
1607
+ tokens: data.baseTokens?.all,
1220
1608
  isDefault: defaultIsAll,
1221
1609
  onUse: () => copyCommand('全部'),
1222
1610
  onDefault: () => act({ action: 'default', name: '' }),
@@ -1224,6 +1612,7 @@ window.__ModuleLoader__.load({
1224
1612
  h(DefaultCard, {
1225
1613
  title: '仅内置',
1226
1614
  description: '只带 Harness 内置的工具,下载插件的工具默认隐藏。',
1615
+ tokens: data.baseTokens?.dsh,
1227
1616
  isDefault: defaultIsDsh,
1228
1617
  onUse: () => copyCommand('仅内置'),
1229
1618
  onDefault: () => act({ action: 'default', name: '@dsh' }),
@@ -1400,6 +1789,19 @@ window.__ModuleLoader__.load({
1400
1789
  order: -100,
1401
1790
  extra: { inject: (sessionId) => ({ sessionId }) },
1402
1791
  }, HeaderMenu)
1792
+ // The new-conversation picker: the one moment where switching is free,
1793
+ // because the agent — and its first request — does not exist yet. It sits
1794
+ // at the front of the composer's right-hand tool group, i.e. immediately
1795
+ // to the right of the 标准模式 selector, and that row is mounted in both
1796
+ // phases — so the component gates itself on `blank`.
1797
+ registerSurface(ctx, {
1798
+ name: 'conversation.input.right',
1799
+ order: -10,
1800
+ // Session-scoped slots get their Session id through `inject`; without
1801
+ // it the picker could neither read this conversation's binding nor
1802
+ // write one.
1803
+ extra: { inject: (sessionId) => ({ sessionId }) },
1804
+ }, HeroKit)
1403
1805
  // The settings page: the full editor and the place kits are stored from.
1404
1806
  registerSurface(ctx, {
1405
1807
  name: 'settings.section',