dsh-token-total 1.0.0 → 1.0.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 (37) hide show
  1. package/README.md +5 -4
  2. package/lib/client.js +566 -159
  3. package/lib/index.js +355 -54
  4. package/lib/types/client/ContextUsageEntry.d.ts +22 -0
  5. package/lib/types/client/context-meter-core.d.ts +26 -0
  6. package/lib/types/client/context-takeover.d.ts +4 -0
  7. package/lib/types/client/locales.d.ts +1 -1
  8. package/lib/types/client/session-usage-core.d.ts +9 -0
  9. package/lib/types/client/use-session-usage.d.ts +2 -18
  10. package/lib/types/collector.d.ts +27 -12
  11. package/lib/types/context-types.d.ts +23 -2
  12. package/lib/types/model-names.d.ts +6 -0
  13. package/lib/types/query.d.ts +8 -0
  14. package/lib/types/session-usage.d.ts +8 -9
  15. package/lib/types/store.d.ts +32 -0
  16. package/lib/types/wire.d.ts +30 -1
  17. package/package.json +1 -1
  18. package/src/client/ContextUsageEntry.module.css +20 -0
  19. package/src/client/ContextUsageEntry.tsx +89 -0
  20. package/src/client/StatsLineEnhanced.tsx +17 -35
  21. package/src/client/TokenTotalPanel.module.css +72 -5
  22. package/src/client/TokenTotalPanel.tsx +204 -56
  23. package/src/client/context-meter-core.ts +54 -0
  24. package/src/client/context-takeover.ts +35 -0
  25. package/src/client/index.tsx +21 -3
  26. package/src/client/locales.ts +4 -0
  27. package/src/client/session-usage-core.ts +54 -0
  28. package/src/client/use-session-usage.ts +45 -98
  29. package/src/collector.ts +138 -67
  30. package/src/context-types.ts +17 -2
  31. package/src/index.ts +29 -0
  32. package/src/model-names.ts +34 -0
  33. package/src/query.ts +84 -13
  34. package/src/routes.ts +20 -2
  35. package/src/session-usage.ts +50 -38
  36. package/src/store.ts +136 -0
  37. package/src/wire.ts +24 -1
@@ -55,10 +55,8 @@ import {
55
55
  type ConversationNodeLike, type PartialAssistantLike, type UseProjection, type WindowStats,
56
56
  } from './stats-line-core.ts'
57
57
  import { formatTokens } from './format.ts'
58
- import {
59
- billedTotal, hasAnyTokens, useSessionUsage,
60
- type SessionTreeReading,
61
- } from './use-session-usage.ts'
58
+ import { useSessionUsage, type SessionTreeReading } from './use-session-usage.ts'
59
+ import { billedTotal, hasAnyTokens, sessionUsageForDisplay } from './session-usage-core.ts'
62
60
  import type { SessionUsageTotals } from '../wire.ts'
63
61
  import css from './StatsLineEnhanced.module.css'
64
62
 
@@ -299,7 +297,7 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
299
297
  )
300
298
  }
301
299
  return (
302
- <span ref={rootRef} className={anchorClass(dock)}>
300
+ <span ref={rootRef} className={anchorClass(dock)} data-dsh-token-total-stat>
303
301
  <button
304
302
  type="button"
305
303
  className={css.pill}
@@ -363,10 +361,8 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
363
361
  }
364
362
 
365
363
  function UsagePill({ usage, tree, t, dialog, dock }: {
366
- usage: Parameters<typeof billedInputTokens>[0]
367
- /** The whole session tree (main + subagents) from the host, when it has
368
- * answered. Subagent spend is invisible to the native projection, so the
369
- * pill's total and its dialog both switch to this once it carries any. */
364
+ usage: Parameters<typeof billedInputTokens>[0] | undefined
365
+ /** Descendant spend from the host; the main-agent figure stays live. */
370
366
  tree?: SessionTreeReading
371
367
  t: T
372
368
  /** The row's shared exclusive slot; omitted by a split-mode entry. */
@@ -375,20 +371,10 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
375
371
  dock: boolean
376
372
  }) {
377
373
  const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
378
- // The native projection is the main agent of THIS session; the tree adds
379
- // every subagent session delegated from it. Prefer the tree as soon as it
380
- // reports any subagent spend — otherwise the two are identical and the
381
- // native figure (which is live to the frame) stays authoritative.
382
- const nativeTotals: SessionUsageTotals = {
383
- uncachedInputTokens: usage.uncachedInputTokens,
384
- outputTokens: usage.outputTokens,
385
- cacheReadTokens: usage.cacheReadTokens,
386
- cacheWriteTokens: usage.cacheWriteTokens,
387
- }
388
- const treeTotals = tree?.totals ?? null
389
- const subagentTotals = tree?.tree?.subagents
390
- const showSubagents = subagentTotals !== undefined && hasAnyTokens(subagentTotals)
391
- const billed = showSubagents && treeTotals !== null ? treeTotals : nativeTotals
374
+ const display = sessionUsageForDisplay(usage, tree?.tree ?? null)
375
+ const subagentTotals = display.subagents
376
+ const showSubagents = display.subagentSessions > 0
377
+ const billed = display.total
392
378
  // Same aggregate as the official pill total: every prompt-side billing bucket plus output.
393
379
  const total = billedTotal(billed)
394
380
  const totalText = t('message.turnUsage.count', { count: formatTokensCompact(total) })
@@ -397,7 +383,7 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
397
383
  const exactCount = (value: number): string =>
398
384
  t('message.turnUsage.count', { count: formatTokens(value) })
399
385
  return (
400
- <span ref={rootRef} className={anchorClass(dock)}>
386
+ <span ref={rootRef} className={anchorClass(dock)} data-dsh-token-total-stat>
401
387
  <button
402
388
  type="button"
403
389
  className={css.pill}
@@ -461,10 +447,10 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
461
447
  <dd>{exactCount(billed.outputTokens)}</dd>
462
448
  {/* The split that the native figure cannot show: this session's own
463
449
  main agent against every subagent it delegated to. */}
464
- {showSubagents && treeTotals !== null && (
450
+ {showSubagents && (
465
451
  <>
466
452
  <dt>{t('stats.dialog.main')}</dt>
467
- <dd>{exactCount(billedTotal(nativeTotals))}</dd>
453
+ <dd>{exactCount(billedTotal(display.self))}</dd>
468
454
  <dt>{t('stats.dialog.subagents')}</dt>
469
455
  <dd>
470
456
  {exactCount(billedTotal(subagentTotals))}
@@ -472,7 +458,7 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
472
458
  {t('stats.dialog.subagentCount', { count: tree?.tree?.subagentSessions ?? 0 })}
473
459
  </dd>
474
460
  <dt>{t('stats.dialog.treeTotal')}</dt>
475
- <dd>{exactCount(billedTotal(treeTotals))}</dd>
461
+ <dd>{exactCount(billedTotal(billed))}</dd>
476
462
  </>
477
463
  )}
478
464
  </dl>
@@ -506,13 +492,10 @@ export const StatsLineEnhanced = memo(function StatsLineEnhanced(
506
492
  // density predicted for the same text. 1 (the raw prior) until a settled step
507
493
  // carries both, so the first stream of a session still reads sensibly.
508
494
  const tokenScale = useMemo(() => measuredTokenScale(settledNodes ?? []) ?? 1, [settledNodes])
509
- // The whole session tree (main + subagents). Re-asked whenever the native
510
- // usage figure moves, which is exactly when a main-agent call settled.
495
+ // Refresh on main-agent settlements and poll for child-only activity.
511
496
  const tree = useSessionUsage(sessionId, usageSignature(usage, stats.steps))
512
- // Gated on actual token activity: a session whose steps all settled without
513
- // billing (e.g. every request failed) shows its counts without a usage pill.
514
- const hasTokens = usage !== undefined
515
- && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
497
+ // A delegated-only session must still show its usage pill.
498
+ const hasTokens = hasAnyTokens(sessionUsageForDisplay(usage, tree.tree).total)
516
499
  if (stats.steps === 0 && !hasTokens) return null
517
500
  // data-composer-stats: through host 0.1.6-alpha.1 InputBar tightened the
518
501
  // composer bottom clearance while this row rendered (the official pills'
@@ -585,8 +568,7 @@ export const StatsUsageEntry = memo(function StatsUsageEntry(
585
568
  ) {
586
569
  const usage = useProjection('tokenUsage')
587
570
  const tree = useSessionUsage(sessionId, usageSignature(usage, usage === undefined ? 0 : 1))
588
- const hasTokens = usage !== undefined
589
- && (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
571
+ const hasTokens = hasAnyTokens(sessionUsageForDisplay(usage, tree.tree).total)
590
572
  if (!hasTokens) return null
591
573
  return (
592
574
  <UsagePill
@@ -499,11 +499,26 @@
499
499
  font-size: 11px;
500
500
  }
501
501
 
502
- .modelList {
503
- list-style: none;
504
- margin: 0;
505
- padding: 0;
506
- flex: 1 1 260px;
502
+ /* Two ranking cards, side by side: MODELS left, PROVIDERS right. Both are
503
+ always on screen (no lens/tab switch), so they share one row grid and
504
+ stretch to a common height; a narrow pane stacks them instead. */
505
+ .rankCards {
506
+ display: grid;
507
+ grid-template-columns: repeat(2, minmax(0, 1fr));
508
+ gap: 12px;
509
+ }
510
+
511
+ @media (max-width: 720px) {
512
+ .rankCards {
513
+ grid-template-columns: minmax(0, 1fr);
514
+ }
515
+ }
516
+
517
+ /* Each card is the bordered surface its rows live in; the title is a recessed
518
+ header strip, so the two rankings read as two equal panels. */
519
+ .rankCard {
520
+ display: flex;
521
+ flex-direction: column;
507
522
  min-width: 0;
508
523
  border: 1px solid var(--dsw-alias-border-l1);
509
524
  border-radius: 10px;
@@ -511,6 +526,29 @@
511
526
  overflow: hidden;
512
527
  }
513
528
 
529
+ .rankCardTitle {
530
+ padding: 8px 12px;
531
+ border-bottom: 1px solid var(--dsw-alias-border-l1);
532
+ background: color-mix(in srgb, var(--dsw-alias-bg-layer-2) 55%, transparent);
533
+ color: var(--dsw-alias-label-secondary);
534
+ font-size: 12px;
535
+ font-weight: 600;
536
+ }
537
+
538
+ .rankList {
539
+ list-style: none;
540
+ margin: 0;
541
+ padding: 0;
542
+ flex: 1 1 auto;
543
+ min-width: 0;
544
+ }
545
+
546
+ /* Every row opens a hover tip (the cross-dimension detail), so it carries the
547
+ same hover affordance as the chart marks. */
548
+ .modelRow:hover {
549
+ background: color-mix(in srgb, var(--dsw-alias-bg-layer-2) 60%, transparent);
550
+ }
551
+
514
552
  /* Position column: the row's rank inside its section. The Other bucket holds
515
553
  no single rank, so it renders an empty (but equally wide) cell. */
516
554
  .modelRank {
@@ -667,6 +705,35 @@
667
705
  margin-bottom: 4px;
668
706
  }
669
707
 
708
+ /* A provider row's raw route id, kept out of the row label but reachable on
709
+ hover (the row shows the display name). */
710
+ .tipSubtitle {
711
+ margin: -2px 0 4px;
712
+ color: var(--dsw-alias-label-tertiary);
713
+ font-size: 12px;
714
+ }
715
+
716
+ /* Breakdown heading inside a row tip: names the cross-dimension list under it
717
+ ("By provider" under a model, "By model" under a provider). */
718
+ .tipLabel {
719
+ margin-bottom: 2px;
720
+ color: var(--dsw-alias-label-tertiary);
721
+ font-size: 12px;
722
+ }
723
+
724
+ /* The breakdown line pushes its volume to the right edge of the plate. */
725
+ .tipName {
726
+ flex: 1 1 auto;
727
+ min-width: 0;
728
+ }
729
+
730
+ .tipValue {
731
+ margin-left: auto;
732
+ padding-left: 12px;
733
+ color: var(--dsw-alias-label-secondary);
734
+ font-variant-numeric: tabular-nums;
735
+ }
736
+
670
737
  .tipRow {
671
738
  display: flex;
672
739
  align-items: center;
@@ -20,7 +20,7 @@
20
20
  */
21
21
  import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react'
22
22
  import clsx from 'clsx'
23
- import { Activity, ArrowDownToLine, ArrowUpFromLine, ChevronDown, ChevronRight, Coins, Database, MessagesSquare } from 'lucide-react'
23
+ import { Activity, ArrowDownToLine, ArrowUpFromLine, Coins, Database, MessagesSquare } from 'lucide-react'
24
24
  import { Button, Input, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
25
25
  import type { DailyTokenUsage, ModelTokenUsage, ProviderTokenUsage, TokenTotalRange, TokenTotalRequest } from '../wire.ts'
26
26
  import { fetchRange, UsageApiError } from './api.ts'
@@ -52,8 +52,8 @@ const TOP_MODELS = 10
52
52
  const OTHER_MODEL = '\u0000other' // sentinel; cannot collide with a real model ref
53
53
  const OTHER_COLOR = 'var(--dsw-chart-other)'
54
54
 
55
- /** How many rows each bottom breakdown tab lists (requirement 下部: 展示前 5). */
56
- const TOP_BREAKDOWN = 5
55
+ /** How many rows each ranking card lists (扩展到 8 个排行). */
56
+ const TOP_BREAKDOWN = 8
57
57
 
58
58
  /** Shared empty key set: one stable identity for a collapsed expand state. */
59
59
  const NO_KEYS: ReadonlySet<string> = new Set()
@@ -254,7 +254,14 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
254
254
  <StatCards stats={stats} t={t} />
255
255
  <Heatmap daily={heatDaily} from={heatWindow.from} to={heatWindow.to} t={t} panelRef={panelRef} />
256
256
  <DailyTrend models={trendModels} daily={trendDaily} granularity={stats?.granularity ?? 'day'} t={t} colorForModel={colorForModel} panelRef={panelRef} />
257
- <UsageBreakdown models={stats.models} providers={stats.providers} total={stats.tokens} t={t} />
257
+ <UsageBreakdown
258
+ models={stats.models}
259
+ providers={stats.providers}
260
+ total={stats.tokens}
261
+ providerNames={stats.providerNames}
262
+ t={t}
263
+ panelRef={panelRef}
264
+ />
258
265
  {stats.to && (
259
266
  <div className={css.foot}>
260
267
  {t('asOf')} {stats.to}
@@ -275,7 +282,7 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
275
282
  * (zero input/output but real cache hits) or a failed-call day (requests
276
283
  * but no tokens) both carry real usage and must not read as "no data". */
277
284
  function isEmptyRange(stats: TokenTotalRange): boolean {
278
- return stats.tokens === 0 && stats.cacheHit === 0 && stats.requests === 0 && stats.turns === 0
285
+ return stats.tokens === 0 && stats.cacheHit === 0 && stats.requests === 0 && stats.failures === 0 && stats.turns === 0
279
286
  }
280
287
 
281
288
  function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
@@ -292,8 +299,10 @@ function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
292
299
  { icon: Database, label: t('cacheTokens'), value: formatCompact(stats.cacheTokens), sub: `${formatTokens(stats.cacheTokens)} ${t('tokensUnit')}` },
293
300
  // 缓存命中率 —cached share of all prompt tokens in the range.
294
301
  { icon: Activity, label: t('cacheRate'), value: cacheRateText(stats.cacheHit, stats.cacheMiss), hint: t('cacheRateHint'), sub: `${formatCompact(stats.cacheHit)} ${t('cachedTokens')}` },
295
- // 请求数量 —provider calls (tokens or not).
296
- { icon: MessagesSquare, label: t('requests'), value: String(stats.requests) },
302
+ // 请求数量 —provider calls (tokens or not). The failed share sits
303
+ // underneath, and only when there were any: a healthy range stays a
304
+ // single line. Failed calls are a SUBSET of requests, never additive.
305
+ { icon: MessagesSquare, label: t('requests'), value: String(stats.requests), sub: stats.failures > 0 ? `${t('requestsFailed')} ${stats.failures} · ${formatPercent((stats.failures / Math.max(stats.requests, 1)) * 100)}` : undefined },
297
306
  ]
298
307
  return (
299
308
  <div className={css.cards}>
@@ -683,104 +692,243 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
683
692
  )
684
693
  }
685
694
 
686
- // ── Section 6: bottom breakdown — top 5 by model / by provider ────────
695
+ // ── Section 6: bottom ranking — top 8 by model / by provider ──────────────
687
696
  //
688
- // Requirement 下部: two tabs, one ranking by MODEL and one by PROVIDER, each
689
- // listing the top 5 names with total tokens and the cache hit rate.
697
+ // Two side-by-side cards, always on screen (no tabs): the left card ranks
698
+ // MODELS, the right ranks PROVIDERS, each listing its top 8 rows.
690
699
  //
691
- // The by-model tab aggregates by MODEL NAME, not by the provider/model ref:
700
+ // The by-model card aggregates by MODEL NAME, not by the provider/model ref:
692
701
  // the same model served through two providers must show as ONE row
693
- // (同名模型不同供应商需要聚合). The by-provider tab is already aggregated by
694
- // provider upstream, so it ranks the provider rows the host returned.
702
+ // (同名模型不同供应商需要聚合). The by-provider card is already aggregated by
703
+ // provider upstream, so it ranks the provider rows the host returned, labelled
704
+ // with the selector's display name (`providerNames`).
705
+ //
706
+ // Both cards keep the same row anatomy — rank, swatch, name, tokens, share,
707
+ // cache hit rate — and move the cross-dimension detail into the hover tip: a
708
+ // model row explains which providers served it, a provider row explains which
709
+ // models it served. Hovering therefore never displaces the share column.
710
+
711
+ /** One child line of a row's hover detail (a provider under a model, or a
712
+ * model under a provider). `tokens` is null when the host payload predates
713
+ * the cross-dimension split and only the source NAME is known. */
714
+ interface RankChild {
715
+ key: string
716
+ name: string
717
+ tokens: number | null
718
+ color: string
719
+ }
720
+
721
+ /** The hover detail of one ranked row. The tip itself renders once, in
722
+ * UsageBreakdown, so only one row can be explained at a time. */
723
+ interface RankTip {
724
+ anchor: Element
725
+ title: string
726
+ /** The raw route id, set only when it differs from the display label. */
727
+ subtitle?: string
728
+ tokens: number
729
+ cacheHit: number
730
+ cacheMiss: number
731
+ breakdownLabel: string
732
+ breakdown: RankChild[]
733
+ }
695
734
 
696
735
  interface BreakdownRow {
697
736
  key: string
698
737
  name: string
699
- detail?: string
738
+ subtitle?: string
700
739
  tokens: number
701
740
  cacheHit: number
702
741
  cacheMiss: number
703
742
  color: string
743
+ breakdownLabel: string
744
+ breakdown: RankChild[]
704
745
  }
705
746
 
706
- function UsageBreakdown({ models, providers, total, t }: {
747
+ /** Provider hues run 1..5 then the gray tail (a provider beyond the palette is
748
+ * rare: the card lists 8 rows, the palette names 5). */
749
+ function providerColor(index: number): string {
750
+ return index < 5 ? `var(--dsw-provider-${index + 1})` : 'var(--dsw-provider-other)'
751
+ }
752
+
753
+ /** Model hues run 1..10 then the gray Other step (mirrors colorForModel). */
754
+ function modelColor(index: number): string {
755
+ return index < TOP_MODELS ? `var(--dsw-chart-${index + 1})` : OTHER_COLOR
756
+ }
757
+
758
+ function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }: {
707
759
  models: ModelTokenUsage[]
708
760
  providers: ProviderTokenUsage[]
709
761
  total: number
762
+ providerNames?: Record<string, string>
710
763
  t: Translator
764
+ panelRef: RefObject<HTMLDivElement | null>
711
765
  }) {
712
- const [tab, setTab] = useState<'model' | 'provider'>('model')
766
+ const [tip, setTip] = useState<RankTip | null>(null)
767
+
768
+ // The display label for a provider route: the host-resolved selector name
769
+ // when there is one, else the raw historical id (a deleted/renamed route).
770
+ // The typeof guard keeps a prototype-shaped key ("constructor") from being
771
+ // read as a label.
772
+ const providerLabel = useCallback((provider: string): string => {
773
+ const named = providerNames?.[provider]
774
+ return typeof named === 'string' && named.trim() !== '' ? named : provider
775
+ }, [providerNames])
776
+
777
+ const shareText = (n: number): string => (total > 0 ? formatPercent((n / total) * 100) : '—')
713
778
 
714
779
  const modelRows = useMemo<BreakdownRow[]>(() => {
715
- const byName = new Map<string, { tokens: number; cacheHit: number; cacheMiss: number; providers: Set<string> }>()
780
+ const byName = new Map<string, {
781
+ tokens: number
782
+ cacheHit: number
783
+ cacheMiss: number
784
+ /** Provider -> tokens, from the host's cross-dimension split. */
785
+ split: Map<string, number>
786
+ /** Every provider the host named, split or not. */
787
+ sources: Set<string>
788
+ }>()
716
789
  for (const m of models) {
717
790
  const name = m.model
718
- const acc = byName.get(name) ?? { tokens: 0, cacheHit: 0, cacheMiss: 0, providers: new Set<string>() }
791
+ const acc = byName.get(name) ?? { tokens: 0, cacheHit: 0, cacheMiss: 0, split: new Map<string, number>(), sources: new Set<string>() }
719
792
  acc.tokens += m.tokens
720
793
  acc.cacheHit += m.cacheHit
721
794
  acc.cacheMiss += m.cacheMiss
722
- for (const provider of m.providers ?? [m.provider]) acc.providers.add(provider)
795
+ for (const item of m.providerTokens ?? []) {
796
+ acc.split.set(item.provider, (acc.split.get(item.provider) ?? 0) + item.tokens)
797
+ }
798
+ for (const provider of m.providers ?? (m.provider === '' ? [] : [m.provider])) acc.sources.add(provider)
723
799
  byName.set(name, acc)
724
800
  }
725
801
  return [...byName.entries()]
726
802
  .sort((a, b) => b[1].tokens - a[1].tokens)
727
803
  .slice(0, TOP_BREAKDOWN)
728
- .map(([name, acc], i) => ({
729
- key: name,
730
- name,
731
- detail: acc.providers.size > 1 ? [...acc.providers].join(', ') : undefined,
732
- tokens: acc.tokens,
733
- cacheHit: acc.cacheHit,
734
- cacheMiss: acc.cacheMiss,
735
- color: `var(--dsw-chart-${i + 1})`,
736
- }))
737
- }, [models])
804
+ .map(([name, acc], i) => {
805
+ const split: RankChild[] = [...acc.split.entries()]
806
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
807
+ .map(([provider, tokens], j) => ({ key: provider, name: providerLabel(provider), tokens, color: providerColor(j) }))
808
+ // A payload from before the split still names its sources; list them
809
+ // without a volume rather than inventing a number.
810
+ const known = new Set(split.map((child) => child.key))
811
+ const named: RankChild[] = [...acc.sources]
812
+ .filter((provider) => !known.has(provider))
813
+ .sort()
814
+ .map((provider, j) => ({ key: provider, name: providerLabel(provider), tokens: null, color: providerColor(split.length + j) }))
815
+ return {
816
+ key: name,
817
+ name,
818
+ tokens: acc.tokens,
819
+ cacheHit: acc.cacheHit,
820
+ cacheMiss: acc.cacheMiss,
821
+ color: modelColor(i),
822
+ breakdownLabel: t('byProvider'),
823
+ breakdown: [...split, ...named],
824
+ }
825
+ })
826
+ }, [models, providerLabel, t])
738
827
 
739
828
  const providerRows = useMemo<BreakdownRow[]>(() => {
740
829
  return [...providers]
741
830
  .filter((p) => p.tokens > 0)
742
831
  .sort((a, b) => b.tokens - a.tokens)
743
832
  .slice(0, TOP_BREAKDOWN)
744
- .map((p, i) => ({
745
- key: p.provider,
746
- name: p.provider,
747
- tokens: p.tokens,
748
- cacheHit: p.cacheHit,
749
- cacheMiss: p.cacheMiss,
750
- color: `var(--dsw-provider-${i + 1})`,
751
- }))
752
- }, [providers])
753
-
754
- const rows = tab === 'model' ? modelRows : providerRows
755
- const share = (n: number): string => (total > 0 ? formatPercent((n / total) * 100) : '—')
833
+ .map((p, i) => {
834
+ const label = providerLabel(p.provider)
835
+ return {
836
+ key: p.provider,
837
+ name: label,
838
+ // Keep the route id reachable: the row shows the display name only.
839
+ subtitle: label === p.provider ? undefined : p.provider,
840
+ tokens: p.tokens,
841
+ cacheHit: p.cacheHit,
842
+ cacheMiss: p.cacheMiss,
843
+ color: providerColor(i),
844
+ breakdownLabel: t('byModel'),
845
+ breakdown: [...(p.modelTokens ?? [])]
846
+ .sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
847
+ .map((m, j) => ({ key: m.model, name: m.model, tokens: m.tokens, color: modelColor(j) })),
848
+ }
849
+ })
850
+ }, [providers, providerLabel, t])
756
851
 
757
852
  return (
758
853
  <section className={css.section}>
759
854
  <div className={css.sectionHead}>
760
- {/* One title for both tabs: the section is ONE ranking, seen through
761
- two lenses (by model / by provider), so the heading names the
762
- section rather than the current lens. */}
855
+ {/* One heading for the whole section: both rankings are always on
856
+ screen, side by side, so there is no lens to switch. */}
763
857
  <h3 className={css.sectionTitle}>{t('usageRanking')}</h3>
764
- <div className={css.group} role="tablist" aria-label={t('usageRanking')}>
765
- <Button size="sm" variant="ghost" role="tab" aria-selected={tab === 'model'} className={clsx(css.segItem, tab === 'model' && css.segActive)} onClick={() => setTab('model')}>
766
- {t('byModel')}
767
- </Button>
768
- <Button size="sm" variant="ghost" role="tab" aria-selected={tab === 'provider'} className={clsx(css.segItem, tab === 'provider' && css.segActive)} onClick={() => setTab('provider')}>
769
- {t('byProvider')}
770
- </Button>
771
- </div>
772
858
  </div>
859
+ <div className={css.rankCards}>
860
+ <RankCard title={t('byModel')} rows={modelRows} total={total} t={t} onTip={setTip} />
861
+ <RankCard title={t('byProvider')} rows={providerRows} total={total} t={t} onTip={setTip} />
862
+ </div>
863
+ <ChartTip anchor={tip?.anchor ?? null} panelRef={panelRef}>
864
+ {tip && (
865
+ <>
866
+ <div className={css.tipTitle}>{tip.title}</div>
867
+ {tip.subtitle !== undefined && <div className={css.tipSubtitle}>{tip.subtitle}</div>}
868
+ <div className={css.tipRow}>{t('total')} {formatTokens(tip.tokens)} · {shareText(tip.tokens)}</div>
869
+ <div className={css.tipRow}>{t('cacheRateColumn')} {cacheRateText(tip.cacheHit, tip.cacheMiss)}</div>
870
+ {tip.breakdown.length > 0 && (
871
+ <div className={css.tipBreakdown}>
872
+ <div className={css.tipLabel}>{tip.breakdownLabel}</div>
873
+ {tip.breakdown.map((child) => (
874
+ <div key={child.key} className={css.tipRow}>
875
+ <i className={css.legendSwatch} style={{ background: child.color }} />
876
+ <span className={css.tipName}>{child.name}</span>
877
+ <span className={css.tipValue}>
878
+ {child.tokens === null ? '' : `${formatCompact(child.tokens)} · ${shareText(child.tokens)}`}
879
+ </span>
880
+ </div>
881
+ ))}
882
+ </div>
883
+ )}
884
+ </>
885
+ )}
886
+ </ChartTip>
887
+ </section>
888
+ )
889
+ }
890
+
891
+ /** One ranking card: a titled list whose rows explain themselves on hover.
892
+ * The tip is owned by UsageBreakdown (one open tip at a time), so a row only
893
+ * reports which row it is, never where to draw. */
894
+ function RankCard({ title, rows, total, t, onTip }: {
895
+ title: string
896
+ rows: BreakdownRow[]
897
+ total: number
898
+ t: Translator
899
+ onTip: (tip: RankTip | null) => void
900
+ }) {
901
+ const share = (n: number): string => (total > 0 ? formatPercent((n / total) * 100) : '—')
902
+ return (
903
+ <div className={css.rankCard}>
904
+ <div className={css.rankCardTitle}>{title}</div>
773
905
  {rows.length === 0 ? (
774
906
  <div className={css.empty}>{t('empty')}</div>
775
907
  ) : (
776
- <ul className={css.modelList} data-dsh-usage-glass="list">
908
+ <ul className={css.rankList} data-dsh-usage-glass="list">
777
909
  {rows.map((r, rank) => (
778
- <li key={r.key} className={css.modelRow}>
910
+ <li
911
+ key={r.key}
912
+ className={css.modelRow}
913
+ onMouseEnter={(e) => onTip({
914
+ anchor: e.currentTarget,
915
+ title: r.name,
916
+ subtitle: r.subtitle,
917
+ tokens: r.tokens,
918
+ cacheHit: r.cacheHit,
919
+ cacheMiss: r.cacheMiss,
920
+ breakdownLabel: r.breakdownLabel,
921
+ breakdown: r.breakdown,
922
+ })}
923
+ onMouseLeave={() => onTip(null)}
924
+ >
779
925
  <span className={css.modelRank} aria-hidden="true">{rank + 1}</span>
780
926
  <i className={css.legendSwatch} style={{ background: r.color }} />
781
927
  <div className={css.modelId}>
782
928
  <span className={css.modelName}>{r.name}</span>
783
- <span className={css.modelProvider}>{r.detail ?? share(r.tokens)}</span>
929
+ {/* The share keeps its own line: the hover detail never
930
+ displaces it (悬浮详情不占用百分比的位置). */}
931
+ <span className={css.modelProvider}>{share(r.tokens)}</span>
784
932
  </div>
785
933
  <div className={css.modelValues}>
786
934
  <span className={css.modelTokens}>{formatCompact(r.tokens)}</span>
@@ -790,7 +938,7 @@ function UsageBreakdown({ models, providers, total, t }: {
790
938
  ))}
791
939
  </ul>
792
940
  )}
793
- </section>
941
+ </div>
794
942
  )
795
943
  }
796
944
 
@@ -0,0 +1,54 @@
1
+ export interface ContextPressureLike {
2
+ projectedTokens?: number
3
+ pressureTokens?: number
4
+ contextWindow?: number
5
+ }
6
+
7
+ export interface ContextBreakdownLike {
8
+ systemTokens: number
9
+ toolsTokens: number
10
+ messageTokens: number
11
+ }
12
+
13
+ export interface ContextOccupancy {
14
+ percent: number
15
+ usedTokens: number
16
+ contextWindow: number
17
+ }
18
+
19
+ /** Mirrors the host meter's occupancy calculation exactly. */
20
+ export function contextOccupancy(pressure: ContextPressureLike | undefined): ContextOccupancy | null {
21
+ const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens
22
+ if (usedTokens === undefined || pressure?.contextWindow === undefined) return null
23
+ return {
24
+ percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
25
+ usedTokens,
26
+ contextWindow: pressure.contextWindow,
27
+ }
28
+ }
29
+
30
+ export function contextBreakdownTotal(breakdown: ContextBreakdownLike | undefined): number {
31
+ return breakdown === undefined ? 0 : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
32
+ }
33
+
34
+ export function contextBreakdownSegments(
35
+ percent: number,
36
+ breakdown: ContextBreakdownLike | undefined,
37
+ ): Array<{ key: string; width: number }> {
38
+ const total = contextBreakdownTotal(breakdown)
39
+ if (breakdown === undefined || total === 0) return [{ key: 'total', width: percent }]
40
+ return [
41
+ { key: 'systemTokens', width: percent * breakdown.systemTokens / total },
42
+ { key: 'toolsTokens', width: percent * breakdown.toolsTokens / total },
43
+ { key: 'messageTokens', width: percent * breakdown.messageTokens / total },
44
+ ].filter(segment => segment.width > 0)
45
+ }
46
+
47
+ export function formatContextTokens(value: number, units: { thousand: string; million: string }): string {
48
+ const scaled = (candidate: number): string => candidate >= 100
49
+ ? String(Math.round(candidate))
50
+ : String(Math.round(candidate * 10) / 10)
51
+ if (value < 1e3) return String(value)
52
+ if (value < 1e6) return units.thousand.replace('{value}', scaled(value / 1e3))
53
+ return units.million.replace('{value}', scaled(value / 1e6))
54
+ }
@@ -0,0 +1,35 @@
1
+ /** The host ContextMeter is hard-coded beside composer.dock, not a slot.
2
+ * Suppress only that recognizable sibling while our replacement is mounted;
3
+ * never hide a whole dock, slot wrapper, or another plugin's readout. */
4
+ export function installContextTakeover(anchor: HTMLElement): () => void {
5
+ const dock = anchor.closest<HTMLElement>('[data-composer-dock]')
6
+ const view = anchor.ownerDocument.defaultView
7
+ if (dock === null || view === null) return () => {}
8
+ const hidden = new Map<HTMLElement, { display: string; priority: string }>()
9
+ const reconcile = (): void => {
10
+ for (const sibling of Array.from(dock.children)) {
11
+ if (!(sibling instanceof view.HTMLElement) || sibling.tagName !== 'SPAN'
12
+ || sibling.contains(anchor) || sibling.hasAttribute('data-slot-entry')
13
+ || sibling.hasAttribute('data-dsh-token-total-stat')
14
+ || hidden.has(sibling)) continue
15
+ // ContextMeter's stable SVG geometry, not a private CSS-module hash or
16
+ // a translated label. Current hosts mount its span directly in the dock.
17
+ if (sibling.querySelector('button[aria-haspopup="dialog"] svg[viewBox="0 0 14 14"] circle[cx="7"][cy="7"][r="5.5"]') === null) continue
18
+ hidden.set(sibling, { display: sibling.style.getPropertyValue('display'), priority: sibling.style.getPropertyPriority('display') })
19
+ sibling.setAttribute('data-dsh-token-total-native-context', '')
20
+ sibling.style.setProperty('display', 'none', 'important')
21
+ }
22
+ }
23
+ reconcile()
24
+ const observer = new view.MutationObserver(reconcile)
25
+ observer.observe(dock, { childList: true, subtree: true })
26
+ return () => {
27
+ observer.disconnect()
28
+ for (const [element, previous] of hidden) {
29
+ element.removeAttribute('data-dsh-token-total-native-context')
30
+ if (previous.display === '') element.style.removeProperty('display')
31
+ else element.style.setProperty('display', previous.display, previous.priority)
32
+ }
33
+ hidden.clear()
34
+ }
35
+ }