dsh-token-total 1.0.0 → 1.0.1

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.
@@ -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
 
@@ -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) })
@@ -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}
@@ -683,104 +690,243 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
683
690
  )
684
691
  }
685
692
 
686
- // ── Section 6: bottom breakdown — top 5 by model / by provider ────────
693
+ // ── Section 6: bottom ranking — top 8 by model / by provider ──────────────
687
694
  //
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.
695
+ // Two side-by-side cards, always on screen (no tabs): the left card ranks
696
+ // MODELS, the right ranks PROVIDERS, each listing its top 8 rows.
690
697
  //
691
- // The by-model tab aggregates by MODEL NAME, not by the provider/model ref:
698
+ // The by-model card aggregates by MODEL NAME, not by the provider/model ref:
692
699
  // 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.
700
+ // (同名模型不同供应商需要聚合). The by-provider card is already aggregated by
701
+ // provider upstream, so it ranks the provider rows the host returned, labelled
702
+ // with the selector's display name (`providerNames`).
703
+ //
704
+ // Both cards keep the same row anatomy — rank, swatch, name, tokens, share,
705
+ // cache hit rate — and move the cross-dimension detail into the hover tip: a
706
+ // model row explains which providers served it, a provider row explains which
707
+ // models it served. Hovering therefore never displaces the share column.
708
+
709
+ /** One child line of a row's hover detail (a provider under a model, or a
710
+ * model under a provider). `tokens` is null when the host payload predates
711
+ * the cross-dimension split and only the source NAME is known. */
712
+ interface RankChild {
713
+ key: string
714
+ name: string
715
+ tokens: number | null
716
+ color: string
717
+ }
718
+
719
+ /** The hover detail of one ranked row. The tip itself renders once, in
720
+ * UsageBreakdown, so only one row can be explained at a time. */
721
+ interface RankTip {
722
+ anchor: Element
723
+ title: string
724
+ /** The raw route id, set only when it differs from the display label. */
725
+ subtitle?: string
726
+ tokens: number
727
+ cacheHit: number
728
+ cacheMiss: number
729
+ breakdownLabel: string
730
+ breakdown: RankChild[]
731
+ }
695
732
 
696
733
  interface BreakdownRow {
697
734
  key: string
698
735
  name: string
699
- detail?: string
736
+ subtitle?: string
700
737
  tokens: number
701
738
  cacheHit: number
702
739
  cacheMiss: number
703
740
  color: string
741
+ breakdownLabel: string
742
+ breakdown: RankChild[]
704
743
  }
705
744
 
706
- function UsageBreakdown({ models, providers, total, t }: {
745
+ /** Provider hues run 1..5 then the gray tail (a provider beyond the palette is
746
+ * rare: the card lists 8 rows, the palette names 5). */
747
+ function providerColor(index: number): string {
748
+ return index < 5 ? `var(--dsw-provider-${index + 1})` : 'var(--dsw-provider-other)'
749
+ }
750
+
751
+ /** Model hues run 1..10 then the gray Other step (mirrors colorForModel). */
752
+ function modelColor(index: number): string {
753
+ return index < TOP_MODELS ? `var(--dsw-chart-${index + 1})` : OTHER_COLOR
754
+ }
755
+
756
+ function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }: {
707
757
  models: ModelTokenUsage[]
708
758
  providers: ProviderTokenUsage[]
709
759
  total: number
760
+ providerNames?: Record<string, string>
710
761
  t: Translator
762
+ panelRef: RefObject<HTMLDivElement | null>
711
763
  }) {
712
- const [tab, setTab] = useState<'model' | 'provider'>('model')
764
+ const [tip, setTip] = useState<RankTip | null>(null)
765
+
766
+ // The display label for a provider route: the host-resolved selector name
767
+ // when there is one, else the raw historical id (a deleted/renamed route).
768
+ // The typeof guard keeps a prototype-shaped key ("constructor") from being
769
+ // read as a label.
770
+ const providerLabel = useCallback((provider: string): string => {
771
+ const named = providerNames?.[provider]
772
+ return typeof named === 'string' && named.trim() !== '' ? named : provider
773
+ }, [providerNames])
774
+
775
+ const shareText = (n: number): string => (total > 0 ? formatPercent((n / total) * 100) : '—')
713
776
 
714
777
  const modelRows = useMemo<BreakdownRow[]>(() => {
715
- const byName = new Map<string, { tokens: number; cacheHit: number; cacheMiss: number; providers: Set<string> }>()
778
+ const byName = new Map<string, {
779
+ tokens: number
780
+ cacheHit: number
781
+ cacheMiss: number
782
+ /** Provider -> tokens, from the host's cross-dimension split. */
783
+ split: Map<string, number>
784
+ /** Every provider the host named, split or not. */
785
+ sources: Set<string>
786
+ }>()
716
787
  for (const m of models) {
717
788
  const name = m.model
718
- const acc = byName.get(name) ?? { tokens: 0, cacheHit: 0, cacheMiss: 0, providers: new Set<string>() }
789
+ const acc = byName.get(name) ?? { tokens: 0, cacheHit: 0, cacheMiss: 0, split: new Map<string, number>(), sources: new Set<string>() }
719
790
  acc.tokens += m.tokens
720
791
  acc.cacheHit += m.cacheHit
721
792
  acc.cacheMiss += m.cacheMiss
722
- for (const provider of m.providers ?? [m.provider]) acc.providers.add(provider)
793
+ for (const item of m.providerTokens ?? []) {
794
+ acc.split.set(item.provider, (acc.split.get(item.provider) ?? 0) + item.tokens)
795
+ }
796
+ for (const provider of m.providers ?? (m.provider === '' ? [] : [m.provider])) acc.sources.add(provider)
723
797
  byName.set(name, acc)
724
798
  }
725
799
  return [...byName.entries()]
726
800
  .sort((a, b) => b[1].tokens - a[1].tokens)
727
801
  .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])
802
+ .map(([name, acc], i) => {
803
+ const split: RankChild[] = [...acc.split.entries()]
804
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
805
+ .map(([provider, tokens], j) => ({ key: provider, name: providerLabel(provider), tokens, color: providerColor(j) }))
806
+ // A payload from before the split still names its sources; list them
807
+ // without a volume rather than inventing a number.
808
+ const known = new Set(split.map((child) => child.key))
809
+ const named: RankChild[] = [...acc.sources]
810
+ .filter((provider) => !known.has(provider))
811
+ .sort()
812
+ .map((provider, j) => ({ key: provider, name: providerLabel(provider), tokens: null, color: providerColor(split.length + j) }))
813
+ return {
814
+ key: name,
815
+ name,
816
+ tokens: acc.tokens,
817
+ cacheHit: acc.cacheHit,
818
+ cacheMiss: acc.cacheMiss,
819
+ color: modelColor(i),
820
+ breakdownLabel: t('byProvider'),
821
+ breakdown: [...split, ...named],
822
+ }
823
+ })
824
+ }, [models, providerLabel, t])
738
825
 
739
826
  const providerRows = useMemo<BreakdownRow[]>(() => {
740
827
  return [...providers]
741
828
  .filter((p) => p.tokens > 0)
742
829
  .sort((a, b) => b.tokens - a.tokens)
743
830
  .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) : '—')
831
+ .map((p, i) => {
832
+ const label = providerLabel(p.provider)
833
+ return {
834
+ key: p.provider,
835
+ name: label,
836
+ // Keep the route id reachable: the row shows the display name only.
837
+ subtitle: label === p.provider ? undefined : p.provider,
838
+ tokens: p.tokens,
839
+ cacheHit: p.cacheHit,
840
+ cacheMiss: p.cacheMiss,
841
+ color: providerColor(i),
842
+ breakdownLabel: t('byModel'),
843
+ breakdown: [...(p.modelTokens ?? [])]
844
+ .sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
845
+ .map((m, j) => ({ key: m.model, name: m.model, tokens: m.tokens, color: modelColor(j) })),
846
+ }
847
+ })
848
+ }, [providers, providerLabel, t])
756
849
 
757
850
  return (
758
851
  <section className={css.section}>
759
852
  <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. */}
853
+ {/* One heading for the whole section: both rankings are always on
854
+ screen, side by side, so there is no lens to switch. */}
763
855
  <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
856
  </div>
857
+ <div className={css.rankCards}>
858
+ <RankCard title={t('byModel')} rows={modelRows} total={total} t={t} onTip={setTip} />
859
+ <RankCard title={t('byProvider')} rows={providerRows} total={total} t={t} onTip={setTip} />
860
+ </div>
861
+ <ChartTip anchor={tip?.anchor ?? null} panelRef={panelRef}>
862
+ {tip && (
863
+ <>
864
+ <div className={css.tipTitle}>{tip.title}</div>
865
+ {tip.subtitle !== undefined && <div className={css.tipSubtitle}>{tip.subtitle}</div>}
866
+ <div className={css.tipRow}>{t('total')} {formatTokens(tip.tokens)} · {shareText(tip.tokens)}</div>
867
+ <div className={css.tipRow}>{t('cacheRateColumn')} {cacheRateText(tip.cacheHit, tip.cacheMiss)}</div>
868
+ {tip.breakdown.length > 0 && (
869
+ <div className={css.tipBreakdown}>
870
+ <div className={css.tipLabel}>{tip.breakdownLabel}</div>
871
+ {tip.breakdown.map((child) => (
872
+ <div key={child.key} className={css.tipRow}>
873
+ <i className={css.legendSwatch} style={{ background: child.color }} />
874
+ <span className={css.tipName}>{child.name}</span>
875
+ <span className={css.tipValue}>
876
+ {child.tokens === null ? '' : `${formatCompact(child.tokens)} · ${shareText(child.tokens)}`}
877
+ </span>
878
+ </div>
879
+ ))}
880
+ </div>
881
+ )}
882
+ </>
883
+ )}
884
+ </ChartTip>
885
+ </section>
886
+ )
887
+ }
888
+
889
+ /** One ranking card: a titled list whose rows explain themselves on hover.
890
+ * The tip is owned by UsageBreakdown (one open tip at a time), so a row only
891
+ * reports which row it is, never where to draw. */
892
+ function RankCard({ title, rows, total, t, onTip }: {
893
+ title: string
894
+ rows: BreakdownRow[]
895
+ total: number
896
+ t: Translator
897
+ onTip: (tip: RankTip | null) => void
898
+ }) {
899
+ const share = (n: number): string => (total > 0 ? formatPercent((n / total) * 100) : '—')
900
+ return (
901
+ <div className={css.rankCard}>
902
+ <div className={css.rankCardTitle}>{title}</div>
773
903
  {rows.length === 0 ? (
774
904
  <div className={css.empty}>{t('empty')}</div>
775
905
  ) : (
776
- <ul className={css.modelList} data-dsh-usage-glass="list">
906
+ <ul className={css.rankList} data-dsh-usage-glass="list">
777
907
  {rows.map((r, rank) => (
778
- <li key={r.key} className={css.modelRow}>
908
+ <li
909
+ key={r.key}
910
+ className={css.modelRow}
911
+ onMouseEnter={(e) => onTip({
912
+ anchor: e.currentTarget,
913
+ title: r.name,
914
+ subtitle: r.subtitle,
915
+ tokens: r.tokens,
916
+ cacheHit: r.cacheHit,
917
+ cacheMiss: r.cacheMiss,
918
+ breakdownLabel: r.breakdownLabel,
919
+ breakdown: r.breakdown,
920
+ })}
921
+ onMouseLeave={() => onTip(null)}
922
+ >
779
923
  <span className={css.modelRank} aria-hidden="true">{rank + 1}</span>
780
924
  <i className={css.legendSwatch} style={{ background: r.color }} />
781
925
  <div className={css.modelId}>
782
926
  <span className={css.modelName}>{r.name}</span>
783
- <span className={css.modelProvider}>{r.detail ?? share(r.tokens)}</span>
927
+ {/* The share keeps its own line: the hover detail never
928
+ displaces it (悬浮详情不占用百分比的位置). */}
929
+ <span className={css.modelProvider}>{share(r.tokens)}</span>
784
930
  </div>
785
931
  <div className={css.modelValues}>
786
932
  <span className={css.modelTokens}>{formatCompact(r.tokens)}</span>
@@ -790,7 +936,7 @@ function UsageBreakdown({ models, providers, total, t }: {
790
936
  ))}
791
937
  </ul>
792
938
  )}
793
- </section>
939
+ </div>
794
940
  )
795
941
  }
796
942
 
@@ -0,0 +1,58 @@
1
+ /** Pure billing helpers shared by the session-tree hook and composer pills. */
2
+ import type { SessionUsageTotals, SessionUsageTree } from '../wire.ts'
3
+
4
+ const ZERO: SessionUsageTotals = {
5
+ uncachedInputTokens: 0,
6
+ outputTokens: 0,
7
+ cacheReadTokens: 0,
8
+ cacheWriteTokens: 0,
9
+ }
10
+
11
+ /** Use the live main-agent projection when available. Never add tree.total to
12
+ * it: that would count the main agent twice. The log is only the fallback. */
13
+ export function combinedTotals(tree: SessionUsageTree, self: SessionUsageTotals = tree.self): SessionUsageTotals {
14
+ return {
15
+ uncachedInputTokens: self.uncachedInputTokens + tree.subagents.uncachedInputTokens,
16
+ outputTokens: self.outputTokens + tree.subagents.outputTokens,
17
+ cacheReadTokens: self.cacheReadTokens + tree.subagents.cacheReadTokens,
18
+ cacheWriteTokens: self.cacheWriteTokens + tree.subagents.cacheWriteTokens,
19
+ }
20
+ }
21
+
22
+ /** One consistent split for the pill, cache hit rate and every dialog row. */
23
+ export function sessionUsageForDisplay(
24
+ native: SessionUsageTotals | undefined,
25
+ tree: SessionUsageTree | null,
26
+ ): SessionUsageTree {
27
+ const extra = tree?.selfCompaction ?? ZERO
28
+ // Native tokenUsage counts message/attempt calls, but not compaction calls.
29
+ // tree.self already includes compaction, so add the supplement ONLY to native.
30
+ const self = native === undefined ? tree?.self ?? { ...ZERO } : {
31
+ uncachedInputTokens: native.uncachedInputTokens + extra.uncachedInputTokens,
32
+ outputTokens: native.outputTokens + extra.outputTokens,
33
+ cacheReadTokens: native.cacheReadTokens + extra.cacheReadTokens,
34
+ cacheWriteTokens: native.cacheWriteTokens + extra.cacheWriteTokens,
35
+ }
36
+ const subagents = tree?.subagents ?? { ...ZERO }
37
+ return {
38
+ self,
39
+ subagents,
40
+ total: tree === null ? self : combinedTotals(tree, self),
41
+ subagentSessions: tree?.subagentSessions ?? 0,
42
+ }
43
+ }
44
+
45
+ export function hasAnyTokens(totals: SessionUsageTotals | undefined): boolean {
46
+ return totals !== undefined && billedTotal(totals) > 0
47
+ }
48
+
49
+ export function billedTotal(totals: SessionUsageTotals): number {
50
+ return totals.uncachedInputTokens + totals.cacheReadTokens
51
+ + totals.cacheWriteTokens + totals.outputTokens
52
+ }
53
+
54
+ export function cacheHitPercentOf(totals: SessionUsageTotals): number | null {
55
+ const hit = totals.cacheReadTokens
56
+ const miss = totals.uncachedInputTokens + totals.cacheWriteTokens
57
+ return hit + miss === 0 ? null : (hit / (hit + miss)) * 100
58
+ }