dsh-token-total 1.0.2 → 1.0.4

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/lib/index.js CHANGED
@@ -80,6 +80,7 @@ function aggregateSamples(samples, filter, granularity = "day") {
80
80
  const from = filter.from;
81
81
  const to = filter.to;
82
82
  const buckets = bucketsInRange(from, to, granularity);
83
+ const all = [...samples];
83
84
  const out = {
84
85
  from,
85
86
  to,
@@ -116,7 +117,8 @@ function aggregateSamples(samples, filter, granularity = "day") {
116
117
  const bucketTurns = /* @__PURE__ */ new Map();
117
118
  const bucketCacheHit = /* @__PURE__ */ new Map();
118
119
  const bucketCacheMiss = /* @__PURE__ */ new Map();
119
- for (const sample of samples) {
120
+ const modelAlias = aliasQualifiedModels(new Set(all.map((sample) => modelKeyOf(sample))));
121
+ for (const sample of all) {
120
122
  const bucket = bucketOf(sample, from, to, granularity);
121
123
  if (bucket === null) continue;
122
124
  if (sample.turn) {
@@ -142,9 +144,9 @@ function aggregateSamples(samples, filter, granularity = "day") {
142
144
  out.cacheHit += sample.cacheReadTokens;
143
145
  const missSide = sample.inputTokens + sample.cacheWriteTokens;
144
146
  out.cacheMiss += missSide;
145
- const modelRef = sample.model && sample.model !== "" ? sample.model : "(unknown)";
146
- const provider = providerOf(modelRef);
147
- const model = normalizeModelName(sample.modelName?.trim() || modelNameOf(modelRef));
147
+ const provider = providerOf(sample.model && sample.model !== "" ? sample.model : "(unknown)");
148
+ const modelBase = modelKeyOf(sample);
149
+ const model = modelAlias.get(modelBase) ?? modelBase;
148
150
  const sources = modelProviders.get(model) ?? /* @__PURE__ */ new Set();
149
151
  sources.add(provider);
150
152
  modelProviders.set(model, sources);
@@ -219,6 +221,47 @@ function aggregateSamples(samples, filter, granularity = "day") {
219
221
  }
220
222
  return out;
221
223
  }
224
+ /** The aggregation key of one sample: the resolved display name when the
225
+ * host resolved one, else the bare model ref — normalized like every
226
+ * display name so the key is stable across routes and refreshes. */
227
+ function modelKeyOf(sample) {
228
+ const modelRef = sample.model && sample.model !== "" ? sample.model : "(unknown)";
229
+ return normalizeModelName(sample.modelName?.trim() || modelNameOf(modelRef));
230
+ }
231
+ /**
232
+ * Qualified-name aliases: "workbuddy-ai-plugin/deepseek-v4.1-flash" and
233
+ * "deepseek-v4.1-flash" are the same model reached through a routing
234
+ * namespace, so a name containing "/" merges onto the LONGEST suffix after a
235
+ * "/" that is itself a model key in this dataset (chains like a/b/c → b/c → c
236
+ * resolve transitively). The merge is dataset-relative on purpose: a slash
237
+ * that names no bare model in the data ("GPT/6.1 sol" beside "gpt-6.1-sol")
238
+ * is a genuinely different name and stays its own row — see the
239
+ * distinct-slashes case in tests/model-aggregation.test.mjs.
240
+ */
241
+ function aliasQualifiedModels(names) {
242
+ const alias = /* @__PURE__ */ new Map();
243
+ for (const name of names) {
244
+ if (!name.includes("/")) continue;
245
+ const parts = name.split("/");
246
+ for (let i = 1; i < parts.length; i++) {
247
+ const suffix = parts.slice(i).join("/");
248
+ if (names.has(suffix)) {
249
+ alias.set(name, suffix);
250
+ break;
251
+ }
252
+ }
253
+ }
254
+ for (const name of [...alias.keys()]) {
255
+ let target = alias.get(name);
256
+ const seen = /* @__PURE__ */ new Set([name]);
257
+ while (alias.has(target) && !seen.has(target)) {
258
+ seen.add(target);
259
+ target = alias.get(target);
260
+ }
261
+ alias.set(name, target);
262
+ }
263
+ return alias;
264
+ }
222
265
  function modelsSorted(totals, cache, sources, splits) {
223
266
  const out = [];
224
267
  for (const [model, tokens] of totals) {
@@ -25,10 +25,14 @@ export declare function localDay(offsetDays: number): string;
25
25
  /** 0 = Monday … 6 = Sunday (GitHub-style heatmap rows). */
26
26
  export declare function indexOfDay(day: string): number;
27
27
  export declare function shortDay(day: string): string;
28
- /** X-axis label for a trend bucket key. Day mode keeps the short date; hour
29
- * mode names the day at 00:00 (so a three-day span keeps its day boundaries
30
- * readable) and the hour everywhere else. */
31
- export declare function bucketAxisLabel(key: string, granularity: 'day' | 'hour'): string;
28
+ /** X-axis label for a trend bucket key. `mode` is what the axis READS AS,
29
+ * chosen by the caller from the window it covers:
30
+ * - 'hour' — a single-day hourly span (今日): the date is constant, so the
31
+ * axis shows the bare clock ("8:00");
32
+ * - 'day' — anything spanning several days (3天 hourly, any daily range):
33
+ * the axis shows the short date ("10/22"); on multi-day hourly spans one
34
+ * label marks each day. The tooltip still carries the full date + hour. */
35
+ export declare function bucketAxisLabel(key: string, mode: 'day' | 'hour'): string;
32
36
  /** Tooltip title for a trend bucket key: the date in day mode, date + hour
33
37
  * in hour mode. */
34
38
  export declare function bucketTitle(key: string, granularity: 'day' | 'hour'): string;
@@ -7,7 +7,7 @@
7
7
  /** The locale namespace this plugin owns. */
8
8
  export declare const LOCALE_NS = "tokenTotal";
9
9
  /** The typed translator key union for the panel's locale seat. */
10
- export type TokenTotalKey = 'nav' | 'back' | 'range' | 'rangePreset.today' | 'rangePreset.3' | 'rangePreset.7' | 'rangePreset.30' | 'rangePreset.90' | 'rangeCustom' | 'from' | 'to' | 'refresh' | 'loading' | 'tokens' | 'tokensHint' | 'tokensUnit' | 'inputTokens' | 'outputTokens' | 'cacheTokens' | 'cachedTokens' | 'sessions' | 'requests' | 'requestsFailed' | 'activeDays' | 'cacheRate' | 'cacheRateHint' | 'cacheHitRate' | 'hitRateLegend' | 'topModel' | 'topModelHint' | 'heatmap' | 'heatLess' | 'heatMore' | 'dailyTrend' | 'hourlyTrend' | 'trendLimited' | 'usageRanking' | 'byModel' | 'byProvider' | 'tokensColumn' | 'cacheRateColumn' | 'providerModels' | 'other' | 'total' | 'percent' | 'asOf' | 'empty' | 'stats.counts' | 'stats.tokensPerSecond' | 'stats.cacheHit' | 'stats.cacheMiss' | 'stats.dialog.title' | 'stats.dialog.usageTitle' | 'stats.dialog.llmTime' | 'stats.dialog.toolTime' | 'stats.dialog.ttft' | 'stats.dialog.speed' | 'stats.dialog.main' | 'stats.dialog.subagents' | 'stats.dialog.subagentCount' | 'stats.dialog.treeTotal' | 'message.turnUsage.count' | 'message.turnUsage.cacheHit' | 'message.turnUsage.input' | 'message.turnUsage.cacheRead' | 'message.turnUsage.cacheWrite' | 'message.turnUsage.output';
10
+ export type TokenTotalKey = 'nav' | 'back' | 'range' | 'rangePreset.today' | 'rangePreset.3' | 'rangePreset.7' | 'rangePreset.30' | 'rangePreset.90' | 'rangeCustom' | 'from' | 'to' | 'refresh' | 'loading' | 'tokens' | 'tokensHint' | 'tokensUnit' | 'inputTokens' | 'outputTokens' | 'cacheTokens' | 'cachedTokens' | 'sessions' | 'requests' | 'requestsFailed' | 'activeDays' | 'cacheRate' | 'cacheRateHint' | 'cacheHitRate' | 'hitRateLegend' | 'topModel' | 'topModelHint' | 'heatmap' | 'heatLess' | 'heatMore' | 'dow.mon' | 'dow.tue' | 'dow.wed' | 'dow.thu' | 'dow.fri' | 'dow.sat' | 'dow.sun' | 'month.jan' | 'month.feb' | 'month.mar' | 'month.apr' | 'month.may' | 'month.jun' | 'month.jul' | 'month.aug' | 'month.sep' | 'month.oct' | 'month.nov' | 'month.dec' | 'dailyTrend' | 'hourlyTrend' | 'trendLimited' | 'usageRanking' | 'byModel' | 'byProvider' | 'tokensColumn' | 'cacheRateColumn' | 'providerModels' | 'other' | 'total' | 'percent' | 'asOf' | 'empty' | 'stats.counts' | 'stats.tokensPerSecond' | 'stats.cacheHit' | 'stats.cacheMiss' | 'stats.dialog.title' | 'stats.dialog.usageTitle' | 'stats.dialog.llmTime' | 'stats.dialog.toolTime' | 'stats.dialog.ttft' | 'stats.dialog.speed' | 'stats.dialog.main' | 'stats.dialog.subagents' | 'stats.dialog.subagentCount' | 'stats.dialog.treeTotal' | 'message.turnUsage.count' | 'message.turnUsage.cacheHit' | 'message.turnUsage.input' | 'message.turnUsage.cacheRead' | 'message.turnUsage.cacheWrite' | 'message.turnUsage.output';
11
11
  export declare const en: Record<TokenTotalKey, string>;
12
12
  export declare const zh: Record<TokenTotalKey, string>;
13
13
  export declare const zhTW: Record<TokenTotalKey, string>;
@@ -0,0 +1,4 @@
1
+ /** Gauge glyph for the activity pill (counts + throughput). */
2
+ export declare function GaugeIcon(): JSX.Element;
3
+ /** Database glyph for the token-usage pill. */
4
+ export declare function DatabaseIcon(): JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-total",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "DSH web plugin: token 用量统计看板 + 单会话(主智能体 + 子智能体)用量统计。提供今日/近3天/近7天/近1月/近3月/自定义筛选、六个汇总卡片、活跃热力图、缓存命中率折线与分模型柱状图、按模型/按供应商 Top5 列表。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
@@ -1,20 +1,130 @@
1
- .root { display: inline-flex; align-items: center; min-width: 0; font: inherit; font-variant-numeric: tabular-nums; line-height: inherit; }
2
- .trigger { display: inline-flex; align-items: center; gap: 6px; box-sizing: border-box; max-width: 100%; padding: 1px 8px; border: 0; border-radius: 24px; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; line-height: inherit; white-space: nowrap; cursor: pointer; }
3
- .trigger svg { flex: none; }
4
- .track { fill: none; stroke: var(--dsw-alias-separator-primary); stroke-width: 1.5; }
5
- .fill { fill: none; stroke: var(--dsw-alias-label-tertiary); stroke-width: 2; stroke-linecap: round; }
6
- .trigger:hover, .trigger[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
7
- .panel { position: fixed; z-index: 1100; box-sizing: border-box; width: max-content; min-width: min(300px, calc(100vw - 24px)); max-width: min(440px, calc(100vw - 24px)); padding: 16px; border-radius: 12px; background: var(--dsw-specific-menu); backdrop-filter: var(--dsw-menu-backdrop-filter); box-shadow: var(--dsw-elevation-prominent); color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 18px; }
8
- .header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; color: var(--dsw-alias-label-primary); font-weight: 500; }
9
- .percent { font-variant-numeric: tabular-nums; }
10
- .figures { margin-left: auto; color: var(--dsw-alias-label-tertiary); font-variant-numeric: tabular-nums; white-space: nowrap; }
11
- .bar { display: flex; width: 100%; height: 8px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-separator-primary); }
12
- .segment { height: 100%; }
13
- .systemTokens { background: var(--dsw-alias-label-primary); }
14
- .toolsTokens { background: var(--dsw-alias-label-secondary); }
15
- .messageTokens { background: var(--dsw-alias-label-tertiary); }
16
- .rows { display: grid; gap: 6px; margin: 12px 0 0; color: var(--dsw-alias-label-tertiary); }
17
- .row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
18
- .row dt { display: flex; align-items: center; gap: 8px; }
19
- .row dd { margin: 0; color: var(--dsw-alias-label-secondary); font-variant-numeric: tabular-nums; }
20
- .swatch { width: 8px; height: 8px; border-radius: 2px; }
1
+ /* Mirror the host ContextMeter, including its theme and font-size tokens. */
2
+ .root {
3
+ flex: none;
4
+ display: inline-flex;
5
+ }
6
+
7
+ .trigger {
8
+ border-radius: var(--dsw-radius-sm);
9
+ color: var(--dsw-alias-label-tertiary);
10
+ font-family: inherit;
11
+ font-size: var(--dsh-content-font-size-secondary, 13px);
12
+ font-variant-numeric: tabular-nums;
13
+ line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
14
+ white-space: nowrap;
15
+ cursor: pointer;
16
+ background: transparent;
17
+ border: none;
18
+ flex: none;
19
+ align-items: center;
20
+ gap: 6px;
21
+ padding: 1px 8px;
22
+ display: inline-flex;
23
+ }
24
+
25
+ .trigger:hover,
26
+ .trigger[aria-expanded='true'] {
27
+ background: var(--dsw-alias-interactive-bg-hover);
28
+ color: var(--dsw-alias-label-secondary);
29
+ }
30
+
31
+ .track {
32
+ fill: none;
33
+ stroke: var(--dsw-alias-border-l3);
34
+ stroke-width: 2px;
35
+ }
36
+
37
+ .fill {
38
+ fill: none;
39
+ stroke: var(--dsw-alias-label-tertiary);
40
+ stroke-width: 2px;
41
+ stroke-linecap: round;
42
+ }
43
+
44
+ .panel {
45
+ z-index: 1100;
46
+ box-sizing: border-box;
47
+ border-radius: var(--dsw-radius-lg);
48
+ background: var(--dsw-specific-menu);
49
+ width: min(264px, calc(100vw - 24px));
50
+ backdrop-filter: var(--dsw-menu-backdrop-filter);
51
+ --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
52
+ box-shadow: var(--dsw-elevation-prominent);
53
+ color: var(--dsw-alias-label-secondary);
54
+ cursor: default;
55
+ border: 0;
56
+ padding: 12px;
57
+ font-size: 12px;
58
+ line-height: 20px;
59
+ position: fixed;
60
+ }
61
+
62
+ .header {
63
+ align-items: center;
64
+ gap: 6px;
65
+ display: flex;
66
+ }
67
+
68
+ .figures {
69
+ font-variant-numeric: tabular-nums;
70
+ color: var(--dsw-alias-label-primary);
71
+ margin-left: auto;
72
+ font-weight: 500;
73
+ }
74
+
75
+ .percent {
76
+ color: var(--dsw-alias-label-primary);
77
+ font-weight: 500;
78
+ }
79
+
80
+ .headline { color: var(--dsw-alias-label-tertiary); }
81
+ .headline:empty { display: none; }
82
+
83
+ .bar {
84
+ corner-shape: round;
85
+ background: var(--dsw-alias-interactive-bg-hover);
86
+ border-radius: 999px;
87
+ gap: 1px;
88
+ height: 4px;
89
+ margin: 10px 0 12px;
90
+ display: flex;
91
+ overflow: hidden;
92
+ }
93
+
94
+ .segment {
95
+ background: var(--meter-tint, var(--dsw-alias-label-tertiary));
96
+ border-radius: 1px;
97
+ flex: none;
98
+ min-width: 2px;
99
+ height: 100%;
100
+ }
101
+
102
+ .swatch {
103
+ background: var(--meter-tint);
104
+ vertical-align: baseline;
105
+ border-radius: 2px;
106
+ width: 8px;
107
+ height: 8px;
108
+ margin-right: 6px;
109
+ display: inline-block;
110
+ }
111
+
112
+ .colorSystem { --meter-tint: var(--dsw-static-neutral-bluish-400); }
113
+ .colorTools { --meter-tint: #a78bfa; }
114
+ .colorMessages { --meter-tint: var(--dsw-static-blue-450); }
115
+ .rows { margin: 6px 0 0; }
116
+
117
+ .row {
118
+ justify-content: space-between;
119
+ align-items: center;
120
+ gap: 12px;
121
+ padding: 2px 0;
122
+ display: flex;
123
+ }
124
+
125
+ .row dt { color: var(--dsw-alias-label-secondary); }
126
+ .row dd {
127
+ font-variant-numeric: tabular-nums;
128
+ color: var(--dsw-alias-label-primary);
129
+ margin: 0;
130
+ }
@@ -10,7 +10,7 @@ export const CONTEXT_LOCALE_NS = 'tokenTotalContext'
10
10
  const keys = ['label', 'used', 'system', 'tools', 'messages', 'aria', 'thousand', 'million'] as const
11
11
  export type ContextLocaleKey = typeof keys[number]
12
12
  export const CONTEXT_LOCALES: Record<string, Record<ContextLocaleKey, string>> = {
13
- zh: { label: '上下文', used: '已用上下文', system: '系统提示词', tools: '工具', messages: '对话', aria: '上下文占用 {percent}', thousand: '{value}千', million: '{value}百万' },
13
+ zh: { label: '上下文', used: '上下文已用', system: '系统提示词', tools: '工具定义', messages: '对话消息', aria: '上下文已用 {percent}', thousand: '{value}K', million: '{value}M' },
14
14
  en: { label: 'Context', used: 'Context used', system: 'System', tools: 'Tools', messages: 'Conversation', aria: 'Context usage {percent}', thousand: '{value}K', million: '{value}M' },
15
15
  'zh-TW': { label: '上下文', used: '已用上下文', system: '系統提示詞', tools: '工具', messages: '對話', aria: '上下文使用量 {percent}', thousand: '{value}千', million: '{value}百萬' },
16
16
  }
@@ -50,12 +50,14 @@ export function ContextUsageEntry({ useProjection, t, installContextTakeover }:
50
50
  if (context === null) return null
51
51
  const percent = context.percent
52
52
  const reading = `${percent}%`
53
- const total = breakdown === undefined ? 0 : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
54
53
  const segments = contextBreakdownSegments(percent, breakdown)
55
54
  const format = (value: number): string => formatContextTokens(value, {
56
55
  thousand: t('thousand'), million: t('million'),
57
56
  })
58
- const label = (part: 'system' | 'tools' | 'messages'): string => t(part)
57
+ const [headBefore = '', headAfter = ''] = t('aria', { percent: '\0' }).split('\0').map(part => part.trim())
58
+ const rowLabels = ['system', 'tools', 'messages'] as const
59
+ const rowColors = ['colorSystem', 'colorTools', 'colorMessages'] as const
60
+ const segmentColors: Record<string, string> = { systemTokens: css.colorSystem, toolsTokens: css.colorTools, messageTokens: css.colorMessages }
59
61
  return (
60
62
  <span ref={rootRef} className={css.root} data-dsh-token-total-stat="context">
61
63
  <button type="button" className={css.trigger} aria-label={t('aria', { percent: reading })} aria-haspopup="dialog" aria-expanded={open} onClick={() => setOpen(!open)}>
@@ -68,13 +70,15 @@ export function ContextUsageEntry({ useProjection, t, installContextTakeover }:
68
70
  {open && createPortal(
69
71
  <div ref={panelRef} className={css.panel} style={position ?? MEASURE_STYLE} role="dialog" aria-label={t('used')}>
70
72
  <div className={css.header}>
71
- <span>{t('used')}</span><span className={css.percent}>{reading}</span>
73
+ <span className={css.headline}>{headBefore}</span>
74
+ <span className={css.percent}>{reading}</span>
75
+ <span className={css.headline}>{headAfter}</span>
72
76
  <span className={css.figures}>{`~${format(context.usedTokens)} / ${format(context.contextWindow)}`}</span>
73
77
  </div>
74
- <div className={css.bar}>{segments.map(segment => <div key={segment.key} className={`${css.segment} ${css[segment.key] ?? ''}`} style={{ width: `${segment.width}%` }} />)}</div>
75
- {breakdown !== undefined && total > 0 && <dl className={css.rows}>
78
+ <div className={css.bar}>{segments.map(segment => <div key={segment.key} className={segmentColors[segment.key] === undefined ? css.segment : `${css.segment} ${segmentColors[segment.key]}`} style={{ width: `${segment.width}%` }} />)}</div>
79
+ {breakdown !== undefined && <dl className={css.rows}>
76
80
  {(['systemTokens', 'toolsTokens', 'messageTokens'] as const).map((key, index) => <div className={css.row} key={key}>
77
- <dt><span className={`${css.swatch} ${css[key]}`} />{label((['system', 'tools', 'messages'] as const)[index])}</dt>
81
+ <dt><span className={`${css.swatch} ${css[rowColors[index]]}`} />{t(rowLabels[index])}</dt>
78
82
  <dd>{`~${format(breakdown[key])}`}</dd>
79
83
  </div>)}
80
84
  </dl>}
@@ -53,7 +53,7 @@
53
53
  declarations from `.root` onto `.anchor`. The tier stays off the plain
54
54
  `.anchor` because the pre-split row must keep its own `.root` tier. */
55
55
  .dockAnchor {
56
- font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px);
56
+ font-size: var(--dsh-content-font-size-secondary, 13px);
57
57
  line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
58
58
  }
59
59
 
@@ -65,8 +65,8 @@
65
65
  max-width: 100%;
66
66
  padding: 1px 8px;
67
67
  border: none;
68
- border-radius: 24px;
69
- background: transparent;
68
+ border-radius: var(--dsw-radius-sm);
69
+ background: none;
70
70
  color: var(--dsw-alias-label-tertiary);
71
71
  font: inherit;
72
72
  font-variant-numeric: tabular-nums;
@@ -103,22 +103,13 @@ button.pill[aria-expanded='true'] {
103
103
  margin: 0 6px;
104
104
  }
105
105
 
106
- /* Reserved slot for the speed figure: the official pill's figure only changes
107
- when a step settles, but the live reading refreshes on every beat. A width
108
- that tracked the digits would reflow the centred row on each of them and drag
109
- the counts pill beside it along; 10ch covers "1200 tok/s", the widest shape
110
- formatTokensPerSecond can produce. */
106
+ /* The live throughput reading sits inline exactly like the official figure:
107
+ no reserved width, no box of its own. The earlier `min-width: 10ch` slot kept
108
+ the centred row from shifting while the number grew, but it also left a
109
+ permanent gap before the separator — the official pill has no such slot, and
110
+ native parity wins over that jitter fix. */
111
111
  .speed {
112
- display: inline-block;
113
- min-width: 10ch;
114
- }
115
-
116
- /* Marks a usage figure that now includes subagent spend. Deliberately quiet:
117
- it rides inside the official pill's label and must not read as a badge or
118
- compete with the token count beside it. */
119
- .subagentMark {
120
- color: var(--dsw-alias-label-tertiary);
121
- font-variant-numeric: tabular-nums;
112
+ display: inline;
122
113
  }
123
114
 
124
115
  /* Portal dialog surface: fixed in the viewport, left/top supplied inline from
@@ -135,15 +126,15 @@ button.pill[aria-expanded='true'] {
135
126
  width: max-content;
136
127
  min-width: min(300px, calc(100vw - 24px));
137
128
  max-width: min(440px, calc(100vw - 24px));
138
- padding: 16px;
129
+ padding: 12px;
139
130
  border: 0;
140
- border-radius: 12px;
131
+ border-radius: var(--dsw-radius-lg);
141
132
  background: var(--dsw-specific-menu);
142
133
  backdrop-filter: var(--dsw-menu-backdrop-filter);
143
134
  --dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
144
135
  box-shadow: var(--dsw-elevation-prominent);
145
- font-size: 12px;
146
- line-height: 18px;
136
+ font-size: var(--dsh-content-font-size-secondary, 13px);
137
+ line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
147
138
  color: var(--dsw-alias-label-secondary);
148
139
  cursor: default;
149
140
  }
@@ -160,7 +151,7 @@ button.pill[aria-expanded='true'] {
160
151
 
161
152
  /* Rule under the heading, above its rows. */
162
153
  .dialogTitleRule {
163
- margin-bottom: 10px;
154
+ margin-bottom: 8px;
164
155
  border-top: 0.5px solid var(--dsw-alias-border-l2);
165
156
  }
166
157
 
@@ -185,7 +176,7 @@ button.pill[aria-expanded='true'] {
185
176
  .dialogDetails {
186
177
  display: grid;
187
178
  grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
188
- gap: 6px 16px;
179
+ gap: 4px 12px;
189
180
  margin: 0;
190
181
  color: var(--dsw-alias-label-tertiary);
191
182
  }
@@ -23,9 +23,12 @@
23
23
  * Which pair to register is read from the dock's entry ledger in
24
24
  * src/client/index.tsx.
25
25
  *
26
- * Cache-hit precision, the full session token breakdown and streaming
27
- * throughput are always enabled; no stored preferences gate these readouts.
28
- * The token dialog includes the cache-miss row alongside every billing bucket.
26
+ * Streaming throughput is always enabled; no stored preference gates it. The
27
+ * three columns are style-for-style the official ones: same tier, radius,
28
+ * colours, same dialog rows and the same integer cache-hit figure. The one
29
+ * difference is the middle figure's SCOPE — it totals the whole session tree
30
+ * (this agent plus every subagent it spawned), which the native projection
31
+ * cannot see.
29
32
  *
30
33
  * Streaming throughput replaces the gauge pill's speed
31
34
  * figure with a live estimate while a step is still streaming and drops back to
@@ -44,14 +47,14 @@
44
47
  */
45
48
  import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
46
49
  import { createPortal } from 'react-dom'
47
- import { IconDatabaseOutline16, IconGaugeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
48
50
  import type { PropsLocale, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
51
+ import { DatabaseIcon, GaugeIcon } from './stats-icons.tsx'
49
52
  import { LOCALE_NS } from './locales.ts'
50
53
  import { MEASURE_STYLE, useStatDialog, type StatDialogSeat } from './stat-dialog.ts'
51
54
  import {
52
- billedInputTokens, cacheHitPercentPrecise, deriveStats,
55
+ billedInputTokens, cacheHitPercent, deriveStats,
53
56
  estimateOutputTokens, formatDuration, formatTokensCompact, formatTokensPerSecond,
54
- measuredTokenScale, StreamingRateSampler, tokenBreakdown,
57
+ measuredTokenScale, StreamingRateSampler,
55
58
  type ConversationNodeLike, type PartialAssistantLike, type UseProjection, type WindowStats,
56
59
  } from './stats-line-core.ts'
57
60
  import { formatTokens } from './format.ts'
@@ -112,18 +115,15 @@ function anchorClass(dock: boolean): string | undefined {
112
115
  return dock ? `${css.anchor} ${css.dockAnchor}` : css.anchor
113
116
  }
114
117
 
115
- /** The separator + figure shape both speed arms render into the gauge pill.
116
- * The figure sits in a width-reserved slot (css.speed): the live reading
117
- * changes every second, and a slot that tracked its digits would reflow the
118
- * centred row — and with it the counts pill — on every refresh. The slot is
119
- * rendered even with no figure at all (an empty session has no official figure
120
- * to fall back to until its first step settles), so the row's layout never
121
- * depends on whether a number is available. */
118
+ /** The separator + figure shape both speed arms render into the gauge pill:
119
+ * exactly the official pill's inline shape — separator and figure appear only
120
+ * when there is a figure, and the figure occupies no reserved width, so the
121
+ * row reads and reflows like the native one. */
122
122
  function SpeedFigure({ text }: { text: string | null }) {
123
123
  return (
124
124
  <>
125
125
  {text !== null && <span className={css.sep} aria-hidden>·</span>}
126
- <span className={css.speed} data-stats-speed>{text}</span>
126
+ {text !== null && <span className={css.speed} data-stats-speed>{text}</span>}
127
127
  </>
128
128
  )
129
129
  }
@@ -290,7 +290,7 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
290
290
  return (
291
291
  <span className={anchorClass(dock)}>
292
292
  <span className={css.pill}>
293
- <IconGaugeOutline16 />
293
+ <GaugeIcon />
294
294
  {label}
295
295
  </span>
296
296
  </span>
@@ -306,7 +306,7 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
306
306
  aria-label={officialTps === null ? counts : counts + ' · ' + officialTps}
307
307
  onClick={() => { setOpen(!open) }}
308
308
  >
309
- <IconGaugeOutline16 />
309
+ <GaugeIcon />
310
310
  {label}
311
311
  </button>
312
312
  {open && createPortal(
@@ -320,7 +320,7 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
320
320
  >
321
321
  <div className={css.dialogTitle}>
322
322
  <span className={css.dialogTitleLabel}>
323
- <IconGaugeOutline16 />
323
+ <GaugeIcon />
324
324
  {t('stats.dialog.title')}
325
325
  </span>
326
326
  </div>
@@ -372,13 +372,15 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
372
372
  }) {
373
373
  const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
374
374
  const display = sessionUsageForDisplay(usage, tree?.tree ?? null)
375
- const subagentTotals = display.subagents
376
- const showSubagents = display.subagentSessions > 0
377
375
  const billed = display.total
378
376
  // Same aggregate as the official pill total: every prompt-side billing bucket plus output.
379
377
  const total = billedTotal(billed)
380
378
  const totalText = t('message.turnUsage.count', { count: formatTokensCompact(total) })
381
- const cacheHit = cacheHitPercentPrecise(billed)
379
+ // Native formatting, one consistent scope: the ratio comes from the same
380
+ // aggregate the headline uses, so "33M tok · 87%" can never contradict the
381
+ // number printed beside it. The integer shape and its >99% guard are the
382
+ // official ones, untouched.
383
+ const cacheHit = cacheHitPercent(billed)
382
384
  const cacheHitText = cacheHit !== null ? t('stats.cacheHit', { percent: cacheHit }) : null
383
385
  const exactCount = (value: number): string =>
384
386
  t('message.turnUsage.count', { count: formatTokens(value) })
@@ -392,19 +394,13 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
392
394
  aria-label={cacheHitText === null ? totalText : totalText + ' · ' + cacheHitText}
393
395
  onClick={() => { setOpen(!open) }}
394
396
  >
395
- <IconDatabaseOutline16 />
397
+ <DatabaseIcon />
396
398
  <span className={css.label}>
397
399
  {totalText}
398
- {showSubagents && (
399
- <span className={css.sep} aria-hidden>·</span>
400
- )}
401
- {showSubagents && (
402
- <span className={css.subagentMark} title={t('stats.dialog.subagents')}>
403
- +{tree?.tree?.subagentSessions ?? 0}
404
- </span>
405
- )}
406
400
  {cacheHitText !== null && (
407
401
  <>
402
+ {/* Separator only when the second figure exists: an empty span
403
+ would still take the label's gap and read as a trailing dot. */}
408
404
  <span className={css.sep} aria-hidden>·</span>
409
405
  {cacheHitText}
410
406
  </>
@@ -422,12 +418,14 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
422
418
  >
423
419
  <div className={css.dialogTitle}>
424
420
  <span className={css.dialogTitleLabel}>
425
- <IconDatabaseOutline16 />
421
+ <DatabaseIcon />
426
422
  {t('stats.dialog.usageTitle')}
427
423
  </span>
428
424
  <span className={css.dialogTitleValue}>{exactCount(total)}</span>
429
425
  </div>
430
426
  <div className={css.dialogTitleRule} aria-hidden />
427
+ {/* Exactly the official dialog's rows, in the official order: the
428
+ takeover adds the tree to the TOTAL, not to the breakdown. */}
431
429
  <dl className={css.dialogDetails} data-session-stats-usage>
432
430
  {cacheHit !== null && (
433
431
  <>
@@ -439,28 +437,14 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
439
437
  <dd>{exactCount(billed.uncachedInputTokens)}</dd>
440
438
  <dt>{t('message.turnUsage.cacheRead')}</dt>
441
439
  <dd>{exactCount(billed.cacheReadTokens)}</dd>
442
- <dt>{t('message.turnUsage.cacheWrite')}</dt>
443
- <dd>{exactCount(billed.cacheWriteTokens)}</dd>
444
- <dt>{t('stats.cacheMiss')}</dt>
445
- <dd>{exactCount(tokenBreakdown(billed).cacheMiss)}</dd>
446
- <dt>{t('message.turnUsage.output')}</dt>
447
- <dd>{exactCount(billed.outputTokens)}</dd>
448
- {/* The split that the native figure cannot show: this session's own
449
- main agent against every subagent it delegated to. */}
450
- {showSubagents && (
440
+ {billed.cacheWriteTokens !== 0 && (
451
441
  <>
452
- <dt>{t('stats.dialog.main')}</dt>
453
- <dd>{exactCount(billedTotal(display.self))}</dd>
454
- <dt>{t('stats.dialog.subagents')}</dt>
455
- <dd>
456
- {exactCount(billedTotal(subagentTotals))}
457
- {' · '}
458
- {t('stats.dialog.subagentCount', { count: tree?.tree?.subagentSessions ?? 0 })}
459
- </dd>
460
- <dt>{t('stats.dialog.treeTotal')}</dt>
461
- <dd>{exactCount(billedTotal(billed))}</dd>
442
+ <dt>{t('message.turnUsage.cacheWrite')}</dt>
443
+ <dd>{exactCount(billed.cacheWriteTokens)}</dd>
462
444
  </>
463
445
  )}
446
+ <dt>{t('message.turnUsage.output')}</dt>
447
+ <dd>{exactCount(billed.outputTokens)}</dd>
464
448
  </dl>
465
449
  </div>,
466
450
  document.body,