@linxin666/dsh-usage 0.4.7 → 0.4.9

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.
@@ -27,8 +27,12 @@ import { useEffect, useState, useSyncExternalStore, type ReactNode } from 'react
27
27
  import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
28
28
  import { t } from './locales.ts'
29
29
  import styles from './usage.module.css'
30
+ import { formatClock, PeakBadge } from './PeakBadge.tsx'
30
31
  import { formatTokens, type UsageSettings } from './UsageSectionCard.tsx'
32
+ import { deepseekPeriodAt } from '../core/pricing.ts'
33
+ import { isDeepSeekProviderRoute } from '../core/adapters.ts'
31
34
  import { totalTokens } from '../core/ledger.ts'
35
+ import { formatSpend } from '../core/spend.ts'
32
36
  import type { BalanceView, ProviderSnapshotView, UsageOverviewView } from '../core/types.ts'
33
37
  import type { UsageStoreInstance } from './usage-store.ts'
34
38
 
@@ -114,27 +118,23 @@ function formatBalance(balance: BalanceView): string {
114
118
  return currency + ' ' + balance.totalBalance
115
119
  }
116
120
 
117
- function formatClock(ms: number): string {
118
- try {
119
- return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
120
- } catch {
121
- return ''
122
- }
123
- }
124
-
125
121
  /**
126
- * Display name of the provider owning today's priced spend: the day's highest
127
- * cost row, else the current session route, resolved through the provider
128
- * snapshot (an older host without the day rows falls back to the current
129
- * route). Undefined when there is no route to name.
122
+ * Route key owning today's priced spend: the day's highest cost row, else the
123
+ * current session route (an older host without the day rows falls back to the
124
+ * current route). Undefined when neither names one.
130
125
  */
131
- function spendProvider(snapshot: UsageOverviewView): string | undefined {
126
+ function spendRoute(snapshot: UsageOverviewView): string | undefined {
132
127
  let top: UsageOverviewView['usage']['today']['providers'][number] | undefined
133
128
  for (const row of snapshot.usage.today.providers) {
134
129
  if (row.totals.cost <= 0) continue
135
130
  if (top === undefined || row.totals.cost > top.totals.cost) top = row
136
131
  }
137
- const route = top?.provider ?? snapshot.current.provider
132
+ return top?.provider ?? snapshot.current.provider
133
+ }
134
+
135
+ /** Display name of the route spendRoute() names, resolved through the snapshot. */
136
+ function spendProvider(snapshot: UsageOverviewView): string | undefined {
137
+ const route = spendRoute(snapshot)
138
138
  if (route === undefined) return undefined
139
139
  return snapshot.providers.find((entry) => entry.provider === route)?.displayName
140
140
  ?? (route === snapshot.current.provider ? snapshot.current.displayName : undefined)
@@ -149,9 +149,9 @@ function spendProvider(snapshot: UsageOverviewView): string | undefined {
149
149
  */
150
150
  function headline(snapshot: UsageOverviewView): { label: string; value: string; tokenFallback: boolean; provider?: string } {
151
151
  const totals = snapshot.usage.today.totals
152
- if (totals.cost > 0) return { label: t('usage.foot.cost'), value: '¥' + totals.cost.toFixed(2), tokenFallback: false, provider: spendProvider(snapshot) }
152
+ if (totals.cost > 0) return { label: t('usage.foot.cost'), value: formatSpend(totals.cost, snapshot.display), tokenFallback: false, provider: spendProvider(snapshot) }
153
153
  if (totals.calls > 0) return { label: t('usage.today'), value: formatTokens(totalTokens(totals)) + ' tokens', tokenFallback: true }
154
- return { label: t('usage.foot.cost'), value: '¥0.00', tokenFallback: false }
154
+ return { label: t('usage.foot.cost'), value: formatSpend(0, snapshot.display), tokenFallback: false }
155
155
  }
156
156
 
157
157
  /** The tokens/calls sub-line under the headline; a quiet no-data line on an empty day. */
@@ -266,6 +266,11 @@ export function UsageFootCard(props: UsageFootCardProps): ReactNode {
266
266
 
267
267
  const head = headline(snapshot)
268
268
  const balances = balanceLine(snapshot)
269
+ // The published peak/off-peak clock prices the official DeepSeek family only,
270
+ // so the marker follows the route that owns today's spend (else the session
271
+ // route) and stays away from every other provider's card.
272
+ const route = spendRoute(snapshot)
273
+ const period = route !== undefined && isDeepSeekProviderRoute(route) ? deepseekPeriodAt(Date.now()) : undefined
269
274
 
270
275
  if (collapsed) {
271
276
  return (
@@ -274,6 +279,7 @@ export function UsageFootCard(props: UsageFootCardProps): ReactNode {
274
279
  <span className={styles.footStrip} data-dsh-part="foot-card-strip">
275
280
  <GaugeIcon />
276
281
  {head.provider !== undefined && <span className={styles.footStripProvider}>{head.provider}</span>}
282
+ {period !== undefined && <PeakBadge peak={period.peak} boundaryMs={period.boundaryMs} variant="compact" />}
277
283
  <span className={styles.footStripLabel}>{head.label}</span>
278
284
  <span className={styles.footStripValue}>{head.value}</span>
279
285
  </span>
@@ -292,6 +298,14 @@ export function UsageFootCard(props: UsageFootCardProps): ReactNode {
292
298
  </span>
293
299
  <span className={styles.footLine} data-dsh-part="foot-card-usage">{usageLine(snapshot, head.tokenFallback)}</span>
294
300
  {balances !== undefined && <span className={styles.footLine} data-dsh-part="foot-card-balances">{balances}</span>}
301
+ {period !== undefined && (
302
+ <span className={styles.footPeak}>
303
+ <PeakBadge peak={period.peak} boundaryMs={period.boundaryMs} variant="compact" />
304
+ <span className={styles.footPeakClock}>
305
+ {t(period.peak ? 'usage.peak.ends' : 'usage.peak.starts', { time: formatClock(period.boundaryMs) })}
306
+ </span>
307
+ </span>
308
+ )}
295
309
  <span className={styles.footMeta}>{t('usage.updated', { time: formatClock(snapshot.updatedAt) })}</span>
296
310
  </button>
297
311
  {toggle}
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The usage statistics settings section: three tabs (用量: today's usage,
3
3
  * balances, trend; 个人套餐: per-provider plan quota windows; Token 银行:
4
- * the whale-yuan voucher minted from the DeepSeek official family's usage)
4
+ * the whale-yuan voucher minted from every provider's ledger usage)
5
5
  * plus a compact settings row. Data comes from the host's loopback-fenced
6
6
  * /api/dsh-usage/overview document; polling runs only while the section is
7
7
  * mounted and the tab is visible.
@@ -13,15 +13,21 @@ import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-
13
13
  import type { UsageStoreInstance } from './usage-store.ts'
14
14
  import { t } from './locales.ts'
15
15
  import styles from './usage.module.css'
16
+ import { PeakBadge } from './PeakBadge.tsx'
16
17
  import { isDeepSeekProviderRoute } from '../core/adapters.ts'
17
18
  import { deepseekPeriodAt } from '../core/pricing.ts'
18
19
  import { voucherData, drawVoucher, faceValue, formatDay, formatDenomination, loadVoucherArt } from './voucher.ts'
19
20
  import type { ObservedSpendView, ProviderSnapshotView, UsageDayView, UsageOverviewView, UsageProviderSummary, UsageTokenTotals, UsageWindowSummary } from '../core/types.ts'
21
+ import { formatSpend, type SpendDisplay } from '../core/spend.ts'
20
22
 
21
23
  /** The settings fields this section edits (immediate-apply semantics). */
22
24
  export interface UsageSettings {
23
25
  enabled?: boolean
24
26
  pollIntervalSec?: number
27
+ /** Currency the spend figures are displayed in; the ledger stays CNY. */
28
+ spendCurrency?: 'CNY' | 'USD'
29
+ /** CNY per USD, consulted only while the display currency is USD. */
30
+ cnyPerUsd?: number
25
31
  }
26
32
 
27
33
  /** The registration-side face the section's slot entry injects. */
@@ -77,19 +83,6 @@ function formatTime(ms: number): string {
77
83
  }
78
84
  }
79
85
 
80
- function formatClock(ms: number): string {
81
- try {
82
- return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
83
- } catch {
84
- return ''
85
- }
86
- }
87
-
88
- /** Formatted CNY spend estimate (the only priced currency today). */
89
- function formatCost(cost: number): string {
90
- return '¥' + cost.toFixed(2)
91
- }
92
-
93
86
  function toneClass(percent: number): string {
94
87
  if (percent >= 90) return styles.barLow
95
88
  if (percent >= 70) return styles.barWarn
@@ -389,9 +382,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
389
382
  onSelect={(date) => { setUsageDay(date === todayDate ? undefined : date) }}
390
383
  />
391
384
  {deepseekVisible && (
392
- <span className={styles.muted} data-dsh-part="peak-status">
393
- {t(deepseekPeriod.peak ? 'usage.peak.on' : 'usage.peak.off', { time: formatClock(deepseekPeriod.boundaryMs) })}
394
- </span>
385
+ <PeakBadge peak={deepseekPeriod.peak} boundaryMs={deepseekPeriod.boundaryMs} variant="full" />
395
386
  )}
396
387
  {usageDayLoad?.status === 'error'
397
388
  ? <span className={styles.errorLine}>{t('usage.day.error', { error: usageDayLoad.error ?? '' })}</span>
@@ -403,7 +394,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
403
394
  {(day?.totals.cost ?? 0) > 0 && (
404
395
  <div className={styles.providerRow} data-dsh-part="today-cost">
405
396
  <span className={styles.providerName}>{isToday ? t('usage.today.cost') : t('usage.day.cost')}</span>
406
- <span className={styles.providerTokens}>{formatCost(day?.totals.cost ?? 0)}</span>
397
+ <span className={styles.providerTokens}>{formatSpend(day?.totals.cost ?? 0, snapshot.display)}</span>
407
398
  </div>
408
399
  )}
409
400
  {(day?.providers.length ?? 0) > 0 && (
@@ -414,7 +405,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
414
405
  {snapshot.providers.find((provider) => provider.provider === row.provider)?.displayName ?? row.provider}
415
406
  {current.provider === row.provider && <span className={styles.currentBadge}>{t('usage.current')}</span>}
416
407
  </span>
417
- <span className={styles.providerTokens}>{formatTokens(row.totals.inputTokens + row.totals.cacheReadTokens + row.totals.cacheWriteTokens + row.totals.outputTokens)} · {t('usage.calls', { n: row.totals.calls })}{row.totals.cost > 0 ? ` · ${formatCost(row.totals.cost)}` : ''}</span>
408
+ <span className={styles.providerTokens}>{formatTokens(row.totals.inputTokens + row.totals.cacheReadTokens + row.totals.cacheWriteTokens + row.totals.outputTokens)} · {t('usage.calls', { n: row.totals.calls })}{row.totals.cost > 0 ? ` · ${formatSpend(row.totals.cost, snapshot.display)}` : ''}</span>
418
409
  </div>
419
410
  ))}
420
411
  </div>
@@ -472,6 +463,7 @@ export function UsageSectionCard(props: UsageSectionProps): ReactNode {
472
463
  // voucher falls back to that day's fold-time estimate.
473
464
  observedSpend={bankSelection === undefined ? snapshot.usage.observedSpend : undefined}
474
465
  loading={bankSelection !== undefined && bankDayLoad?.status === 'loading'}
466
+ display={snapshot.display}
475
467
  {...(bankDayLoad?.status === 'error' ? { error: bankDayLoad.error ?? '' } : {})}
476
468
  />
477
469
  </>
@@ -550,17 +542,17 @@ function totalOf(totals: UsageTokenTotals): number {
550
542
  }
551
543
 
552
544
  /**
553
- * The Token 银行 card: the mint family's (DeepSeek official + MiMo)
554
- * retained-ledger usage minted onto the whale-yuan note at 1,000,000 tokens
555
- * per whale yuan. The window is the whole retained ledger by default (the
556
- * host's aggregate, falling back to the 30-day trend when an older host serves
557
- * no `all`) or the single day the user picked; the spend line prefers the
558
- * official balance watch (whole window only) and falls back to the fold-time
559
- * estimate, which only the priced family feeds; the artwork draw failure
560
- * degrades to an error line and never takes the section down.
545
+ * The Token 银行 card: every provider's retained-ledger usage minted onto the
546
+ * whale-yuan note at 1,000,000 tokens per whale yuan. The window is the whole
547
+ * retained ledger by default (the host's aggregate, falling back to the 30-day
548
+ * trend when an older host serves no `all`) or the single day the user picked;
549
+ * the spend line prefers the official balance watch (whole window only) and
550
+ * falls back to the fold-time estimate, which only the priced family feeds;
551
+ * the artwork draw failure degrades to an error line and never takes the
552
+ * section down.
561
553
  */
562
- function VoucherCard(props: { window?: UsageWindowSummary; observedSpend?: ObservedSpendView; loading?: boolean; error?: string }): ReactNode {
563
- const { window: ledger, observedSpend, loading, error } = props
554
+ function VoucherCard(props: { window?: UsageWindowSummary; observedSpend?: ObservedSpendView; loading?: boolean; error?: string; display?: SpendDisplay }): ReactNode {
555
+ const { window: ledger, observedSpend, loading, error, display } = props
564
556
  const data = voucherData(ledger)
565
557
  const canvasRef = useRef<HTMLCanvasElement | null>(null)
566
558
  const [drawError, setDrawError] = useState<string | undefined>(undefined)
@@ -640,8 +632,8 @@ function VoucherCard(props: { window?: UsageWindowSummary; observedSpend?: Obser
640
632
  </div>
641
633
  <span className={styles.muted}>
642
634
  {observedSpend !== undefined
643
- ? t('usage.bank.spend.observed', { cost: observedSpend.cny.toFixed(2), since: formatDay(observedSpend.since) })
644
- : t('usage.bank.spend.estimated', { cost: data.cost.toFixed(2) })}
635
+ ? t('usage.bank.spend.observed', { cost: formatSpend(observedSpend.cny, display), since: formatDay(observedSpend.since) })
636
+ : t('usage.bank.spend.estimated', { cost: formatSpend(data.cost, display) })}
645
637
  </span>
646
638
  <span className={styles.muted}>{t('usage.bank.window', { from: data.from, to: data.to })}</span>
647
639
  <div className={styles.buttonRow}>
@@ -704,7 +696,7 @@ function SettingsRow(props: {
704
696
  const disabled = snapshot === undefined || !snapshot.writable
705
697
  const [failure, setFailure] = useState<string | undefined>(undefined)
706
698
 
707
- const write = (field: 'enabled' | 'pollIntervalSec', next: boolean | number): void => {
699
+ const write = (field: 'enabled' | 'pollIntervalSec' | 'spendCurrency' | 'cnyPerUsd', next: boolean | number | 'CNY' | 'USD'): void => {
708
700
  setFailure(undefined)
709
701
  let answer: Promise<boolean>
710
702
  try {
@@ -749,6 +741,34 @@ function SettingsRow(props: {
749
741
  }}
750
742
  />
751
743
  </label>
744
+ <label className={styles.settingItem}>
745
+ {t('usage.config.spendCurrency')}
746
+ <select
747
+ value={value.spendCurrency === 'USD' ? 'USD' : 'CNY'}
748
+ disabled={disabled}
749
+ onChange={(event) => { write('spendCurrency', event.target.value === 'USD' ? 'USD' : 'CNY') }}
750
+ >
751
+ <option value="CNY">CNY</option>
752
+ <option value="USD">USD</option>
753
+ </select>
754
+ </label>
755
+ <label className={styles.settingItem}>
756
+ {t('usage.config.cnyPerUsd')}
757
+ <input
758
+ type="number"
759
+ min={0.01}
760
+ max={1000}
761
+ step={0.01}
762
+ value={typeof value.cnyPerUsd === 'number' ? value.cnyPerUsd : 7.2}
763
+ disabled={disabled}
764
+ onChange={(event) => {
765
+ const parsed = Number(event.target.value)
766
+ // An unusable rate is simply not written: the stored value stays,
767
+ // and rendering falls back to the documented default rate.
768
+ if (Number.isFinite(parsed) && parsed >= 0.01 && parsed <= 1000) write('cnyPerUsd', Math.round(parsed * 100) / 100)
769
+ }}
770
+ />
771
+ </label>
752
772
  </div>
753
773
  {failure !== undefined && (
754
774
  <span className={styles.errorLine} role="status">
@@ -22,55 +22,179 @@ import { UsageFootCard, type UsageFootCardProps } from './UsageFootCard.tsx'
22
22
  /** Stable data attribute identifying the injected card container. */
23
23
  export const FOOT_CARD_SELECTOR = '[data-dsh-usage-foot-card]'
24
24
 
25
+ /** The shell slot whose occupant is the settings shell (and carries its store). */
26
+ const SETTINGS_SEAT_SLOT = 'sidebar.settings'
27
+
28
+ /** The shell sidebar column, which also scopes the settings seat. */
29
+ const SIDEBAR_SELECTOR = '[data-pane="sidebar"], [class*="sidebarCol"]'
30
+
31
+ /** The shell's settings row: the launcher seat plus the Settings trigger fallback. */
32
+ const SETTINGS_AREA_SELECTOR = '[class*="settingsArea"]'
33
+
34
+ /** A launcher that opens the settings dialog by itself (the shell's own gear). */
35
+ const DIALOG_TRIGGER_SELECTOR = 'button[aria-haspopup="dialog"]'
36
+
37
+ /** A launcher that opens a menu whose settings item opens the dialog (the account row). */
38
+ const MENU_ITEM_SELECTOR = '[role="menu"] button[role="menuitem"]'
39
+
40
+ /** One shell frame of the replay poll: menus and the panel mount a frame later. */
41
+ const REPLAY_STEP_MS = 50
42
+
43
+ /** Replay poll budget in steps (about one second of shell frames). */
44
+ const REPLAY_STEPS = 20
45
+
46
+ /**
47
+ * The settings shell's store handle, narrowed to the one write this module
48
+ * performs. A slot registration stores its seat type-erased (`StoreDecl`), so
49
+ * the shape the seat's occupant guarantees is restated here.
50
+ */
51
+ export interface SettingsShellStore {
52
+ create(): { actions: { openSection(id: string): void } }
53
+ }
54
+
55
+ /** The registry read the seat lookup needs (structurally `ctx.slots`). */
56
+ export interface SlotSeatReader {
57
+ entries(key: string): readonly { store?: unknown }[]
58
+ }
59
+
60
+ /** What the card knows about the settings section it opens. */
61
+ export interface UsageSettingsTarget {
62
+ /** Slot registration id of the usage section (the nav row the panel selects). */
63
+ id: string
64
+ /** The section's nav label in the active locale (the DOM replay's row pick). */
65
+ label: () => string
66
+ /** The settings shell's store handle, when the seat exposes one. */
67
+ store?: SettingsShellStore | undefined
68
+ }
69
+
70
+ /** The shell sidebar column, when the shell has mounted one. */
71
+ function sidebarColumn(): HTMLElement | undefined {
72
+ return document.querySelector<HTMLElement>(SIDEBAR_SELECTOR) ?? undefined
73
+ }
74
+
25
75
  /** The shell sidebar's foot area (footer actions + the Settings row), when mounted. */
26
76
  function footArea(): HTMLElement | undefined {
27
- const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
28
- return column?.querySelector<HTMLElement>('[class*="footArea"]') ?? undefined
77
+ return sidebarColumn()?.querySelector<HTMLElement>('[class*="footArea"]') ?? undefined
78
+ }
79
+
80
+ /**
81
+ * Resolve the settings shell's store handle from the `sidebar.settings` seat.
82
+ *
83
+ * The seat is a single-occupant slot and the shell's own registration carries
84
+ * the store that owns the panel's open state and active section, so reading it
85
+ * is the one step that opens the panel without touching the DOM. A shell that
86
+ * exposes no seat, a seat without a store, or a registry answering mid-teardown
87
+ * all yield undefined, and the caller replays the DOM path instead.
88
+ * @param slots - the renderer-owned slot registry.
89
+ * @returns the store handle, or undefined when the seat carries none.
90
+ */
91
+ export function settingsShellStore(slots: SlotSeatReader | undefined): SettingsShellStore | undefined {
92
+ let seat: unknown
93
+ try {
94
+ seat = slots?.entries(SETTINGS_SEAT_SLOT)[0]?.store
95
+ } catch {
96
+ return undefined
97
+ }
98
+ if (seat === undefined || seat === null) return undefined
99
+ return typeof (seat as { create?: unknown }).create === 'function'
100
+ ? seat as SettingsShellStore
101
+ : undefined
29
102
  }
30
103
 
31
104
  /**
32
- * Open the settings panel on the usage section. The shell owns the panel's
33
- * open state and exposes no service for it, so the card replays the user's
34
- * own path: activate the sidebar Settings trigger, then pick the nav row
35
- * carrying the section's (localized) label. Every step degrades silently —
36
- * with the panel already open only the nav pick runs, and a missing row
37
- * leaves the panel on its default section.
38
- * @param label - the usage section's nav label in the active locale.
105
+ * Drive the shell's settings store onto the usage section.
106
+ * @param store - the seat's store handle.
107
+ * @param id - the section's slot registration id.
108
+ * @returns true when the store accepted the call, false when it threw.
39
109
  */
40
- export function openUsageSettings(label: () => string): void {
41
- /** Click the nav row carrying the usage label inside the open panel. */
42
- const pick = (panel: Element): void => {
43
- const wanted = label()
44
- for (const row of panel.querySelectorAll<HTMLButtonElement>('nav button')) {
45
- if (row.textContent !== null && row.textContent.includes(wanted)) {
46
- row.click()
47
- return
48
- }
110
+ function openThroughStore(store: SettingsShellStore, id: string): boolean {
111
+ try {
112
+ store.create().actions.openSection(id)
113
+ return true
114
+ } catch {
115
+ // A foreign shell exposing an unrelated seat: the DOM replay still runs.
116
+ return false
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Click the nav row carrying the section's label inside the open panel.
122
+ * @param panel - the open settings dialog.
123
+ * @param label - the section's nav label in the active locale.
124
+ */
125
+ function pickSectionRow(panel: Element, label: () => string): void {
126
+ const wanted = label()
127
+ for (const row of panel.querySelectorAll<HTMLButtonElement>('nav button')) {
128
+ if (row.textContent !== null && row.textContent.includes(wanted)) {
129
+ row.click()
130
+ return
49
131
  }
50
- // Panel open but the usage row is gone (plugin toggled off): stay put.
51
132
  }
52
- // A panel that is already open must never be toggled shut by the trigger;
133
+ // Panel open but the usage row is gone (plugin toggled off): stay put.
134
+ }
135
+
136
+ /**
137
+ * Poll the page over the shell's next frames until a lookup yields a value.
138
+ * @param find - the lookup, run once per step.
139
+ * @param steps - remaining frame budget.
140
+ * @returns the first value found, or undefined once the budget is spent.
141
+ */
142
+ function until<T>(find: () => T | null | undefined, steps = REPLAY_STEPS): Promise<T | undefined> {
143
+ const found = find()
144
+ if (found !== undefined && found !== null) return Promise.resolve(found)
145
+ if (steps <= 0) return Promise.resolve(undefined)
146
+ return new Promise<void>((resolve) => { window.setTimeout(resolve, REPLAY_STEP_MS) })
147
+ .then(() => until(find, steps - 1))
148
+ }
149
+
150
+ /**
151
+ * Replay the user's own settings path, one shell frame at a time.
152
+ * @param launcher - the settings seat's trigger.
153
+ * @param label - the section's nav label in the active locale.
154
+ */
155
+ async function replay(launcher: HTMLButtonElement, label: () => string): Promise<void> {
156
+ launcher.click()
157
+ // A launcher that opens a menu (an account row occupying settings.launcher)
158
+ // reaches Settings through its first item; the shell's own trigger opens the
159
+ // panel directly.
160
+ if (launcher.getAttribute('aria-haspopup') === 'menu') {
161
+ const item = await until(() => document.querySelector<HTMLButtonElement>(MENU_ITEM_SELECTOR))
162
+ if (item === undefined) return
163
+ item.click()
164
+ }
165
+ const panel = await until(() => document.querySelector('[role="dialog"]'))
166
+ if (panel !== undefined) pickSectionRow(panel, label)
167
+ }
168
+
169
+ /**
170
+ * Open the settings panel on the usage section.
171
+ *
172
+ * The seat's store is the primary path: `openSection` opens the dialog on the
173
+ * usage row in one call, with no dependence on which plugin occupies the
174
+ * `settings.launcher` seat — an account row there turns a trigger click into a
175
+ * menu, which is how this click used to land on the account menu instead of the
176
+ * panel. Without such a store the card replays the user's own path: activate
177
+ * the launcher (preferring the dialog trigger over a menu trigger), follow a
178
+ * menu launcher's first settings item, then pick the nav row carrying the
179
+ * section's label. Every step degrades silently — a missing surface leaves the
180
+ * page untouched, and with the panel already open only the nav pick runs.
181
+ * @param target - the section id, its localized label, and the seat's store.
182
+ */
183
+ export function openUsageSettings(target: UsageSettingsTarget): void {
184
+ const { id, label, store } = target
185
+ if (store !== undefined && openThroughStore(store, id)) return
186
+ // A panel that is already open must never be toggled shut by the launcher;
53
187
  // only the nav pick runs.
54
188
  const open = document.querySelector('[role="dialog"]')
55
189
  if (open !== null) {
56
- pick(open)
190
+ pickSectionRow(open, label)
57
191
  return
58
192
  }
59
- const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
60
- const trigger = column?.querySelector<HTMLButtonElement>('[class*="settingsArea"] button')
61
- trigger?.click()
62
- let tries = 0
63
- const attempt = (): void => {
64
- const panel = document.querySelector('[role="dialog"]')
65
- if (panel !== null) {
66
- pick(panel)
67
- return
68
- }
69
- tries += 1
70
- // The panel mounts on the shell's next frames; wait briefly, then give up.
71
- if (tries <= 20) window.setTimeout(attempt, 50)
72
- }
73
- window.setTimeout(attempt, 0)
193
+ const area = sidebarColumn()?.querySelector<HTMLElement>(SETTINGS_AREA_SELECTOR)
194
+ const launcher = area?.querySelector<HTMLButtonElement>(DIALOG_TRIGGER_SELECTOR)
195
+ ?? area?.querySelector<HTMLButtonElement>('button')
196
+ if (launcher === undefined || launcher === null) return
197
+ void replay(launcher, label)
74
198
  }
75
199
 
76
200
  /**
@@ -93,7 +217,7 @@ export function mountUsageFootCard(props: UsageFootCardProps): () => void {
93
217
  const place = (): void => {
94
218
  const foot = footArea()
95
219
  if (foot === undefined) return
96
- const settings = foot.querySelector<HTMLElement>('[class*="settingsArea"]')
220
+ const settings = foot.querySelector<HTMLElement>(SETTINGS_AREA_SELECTOR)
97
221
  if (settings !== null) {
98
222
  if (container.nextElementSibling !== settings) foot.insertBefore(container, settings)
99
223
  return
@@ -19,7 +19,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-slots'
19
19
  import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
20
20
  import { createUsageStore, type UsageStoreInstance } from './usage-store.ts'
21
21
  import { UsageSectionCard, type UsageSectionFace, type UsageSettings } from './UsageSectionCard.tsx'
22
- import { mountUsageFootCard, openUsageSettings } from './foot-card-mount.tsx'
22
+ import { mountUsageFootCard, openUsageSettings, settingsShellStore } from './foot-card-mount.tsx'
23
23
  import { NS, en, zh, t } from './locales.ts'
24
24
  import { createServedEntryForm } from './settings-entry-form.ts'
25
25
  import type { UsageDayResponse, UsageDayView, UsageOverviewView } from '../core/types.ts'
@@ -68,6 +68,9 @@ const USAGE_ENTRY_IDS: readonly string[] = [AGGREGATE_ENTRY_ID, 'usage', USAGE_S
68
68
  /** First-level nav position: directly below the Workshop section (order 150). */
69
69
  const SECTION_ORDER = 151
70
70
 
71
+ /** The section's nav row id — what the shell's store selects when the card opens. */
72
+ const USAGE_SECTION_ID = 'dsh-usage'
73
+
71
74
  /** Required services. */
72
75
  export const inject = ['slots', 'locale', 'connection', 'configForms', 'remote']
73
76
 
@@ -174,7 +177,13 @@ export function apply(ctx: ClientContext): void {
174
177
  store,
175
178
  poll,
176
179
  settings: settingsForm,
177
- onOpen: () => { openUsageSettings(() => t('usage.title')) },
180
+ onOpen: () => {
181
+ openUsageSettings({
182
+ id: USAGE_SECTION_ID,
183
+ label: () => t('usage.title'),
184
+ store: settingsShellStore(ctx.slots),
185
+ })
186
+ },
178
187
  locale: ctx.locale,
179
188
  })
180
189
  ctx.effect(() => () => {
@@ -189,7 +198,7 @@ export function apply(ctx: ClientContext): void {
189
198
  try {
190
199
  const unregister = ctx.slots.register({
191
200
  name: 'settings.section',
192
- id: 'dsh-usage',
201
+ id: USAGE_SECTION_ID,
193
202
  order: SECTION_ORDER,
194
203
  label: () => ctx.locale.bind(NS)('usage.title'),
195
204
  locale: NS,
@@ -31,6 +31,10 @@ export const zh = {
31
31
  'usage.day.error': '该日用量加载失败:{error}',
32
32
  'usage.peak.on': 'DeepSeek 高峰时段:计价 ×2,{time} 结束',
33
33
  'usage.peak.off': 'DeepSeek 空闲时段:计价为高峰一半,{time} 进入高峰',
34
+ 'usage.peak.tag.on': '高峰',
35
+ 'usage.peak.tag.off': '空闲',
36
+ 'usage.peak.ends': '{time} 结束',
37
+ 'usage.peak.starts': '{time} 进入高峰',
34
38
  'usage.calls': '{n} 次调用',
35
39
  'usage.tokens.total': '总 tokens',
36
40
  'usage.tokens.input': '输入',
@@ -55,13 +59,15 @@ export const zh = {
55
59
  'usage.config.title': '设置',
56
60
  'usage.config.enabled': '启用插件',
57
61
  'usage.config.pollIntervalSec': '轮询间隔(秒)',
62
+ 'usage.config.spendCurrency': '消费显示货币',
63
+ 'usage.config.cnyPerUsd': '汇率(1 美元兑人民币)',
58
64
  'usage.config.saveFailed': '保存失败:设置未被接受。',
59
65
  'usage.bank.title': '鲸元券',
60
- 'usage.bank.hint': 'DeepSeek 官方 API 与 MiMo 每消耗 100 万 tokens 铸造 1 鲸元;保存或分享这张票券。',
61
- 'usage.bank.noUsage': '暂无 DeepSeek 官方 / MiMo 用量数据(统计自插件启用起)',
66
+ 'usage.bank.hint': '所有模型每消耗 100 万 tokens 铸造 1 鲸元;保存或分享这张票券。',
67
+ 'usage.bank.noUsage': '暂无用量数据',
62
68
  'usage.bank.minted': '累计铸造 {minted} 鲸元({tokens} tokens)',
63
- 'usage.bank.spend.observed': '官方余额实测花费 ¥{cost}(自 {since} 起)',
64
- 'usage.bank.spend.estimated': '消费估算:约 ¥{cost}',
69
+ 'usage.bank.spend.observed': '官方余额实测花费 {cost}(自 {since} 起)',
70
+ 'usage.bank.spend.estimated': '消费估算:约 {cost}',
65
71
  'usage.bank.window': '统计窗口 {from} ~ {to}',
66
72
  'usage.bank.windowAll': '全部保留期',
67
73
  'usage.bank.save': '保存图片',
@@ -98,6 +104,10 @@ export const en: Record<UsageKey, string> = {
98
104
  'usage.day.error': 'Failed to load that day: {error}',
99
105
  'usage.peak.on': 'DeepSeek peak hours: 2x pricing, ends {time}',
100
106
  'usage.peak.off': 'DeepSeek off-peak: half of peak pricing, peak returns {time}',
107
+ 'usage.peak.tag.on': 'Peak',
108
+ 'usage.peak.tag.off': 'Off-peak',
109
+ 'usage.peak.ends': 'ends {time}',
110
+ 'usage.peak.starts': 'peak at {time}',
101
111
  'usage.calls': '{n} calls',
102
112
  'usage.tokens.total': 'Total tokens',
103
113
  'usage.tokens.input': 'Input',
@@ -122,13 +132,15 @@ export const en: Record<UsageKey, string> = {
122
132
  'usage.config.title': 'Settings',
123
133
  'usage.config.enabled': 'Enable plugin',
124
134
  'usage.config.pollIntervalSec': 'Poll interval (seconds)',
135
+ 'usage.config.spendCurrency': 'Spend display currency',
136
+ 'usage.config.cnyPerUsd': 'Exchange rate (CNY per USD)',
125
137
  'usage.config.saveFailed': 'Save failed: the deployment did not accept the setting.',
126
138
  'usage.bank.title': 'Whale-yuan voucher',
127
- 'usage.bank.hint': 'Every 1,000,000 tokens spent on the DeepSeek official API or on MiMo mint one whale yuan; save or share the note.',
128
- 'usage.bank.noUsage': 'No DeepSeek official or MiMo usage yet (counting starts when the plugin is enabled)',
139
+ 'usage.bank.hint': 'Every 1,000,000 tokens spent across all models mint one whale yuan; save or share the note.',
140
+ 'usage.bank.noUsage': 'No usage data yet',
129
141
  'usage.bank.minted': 'Minted {minted} whale yuan ({tokens} tokens)',
130
- 'usage.bank.spend.observed': 'Spent ¥{cost} observed on the official balance (watching since {since})',
131
- 'usage.bank.spend.estimated': 'Estimated spend: about ¥{cost}',
142
+ 'usage.bank.spend.observed': 'Spent {cost} observed on the official balance (watching since {since})',
143
+ 'usage.bank.spend.estimated': 'Estimated spend: about {cost}',
132
144
  'usage.bank.window': 'Window {from} - {to}',
133
145
  'usage.bank.windowAll': 'All retained days',
134
146
  'usage.bank.save': 'Save image',