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/client.js +535 -223
- package/lib/index.js +47 -4
- package/lib/types/client/format.d.ts +8 -4
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/stats-icons.d.ts +4 -0
- package/package.json +1 -1
- package/src/client/ContextUsageEntry.module.css +130 -20
- package/src/client/ContextUsageEntry.tsx +11 -7
- package/src/client/StatsLineEnhanced.module.css +15 -24
- package/src/client/StatsLineEnhanced.tsx +33 -49
- package/src/client/TokenTotalPanel.module.css +259 -99
- package/src/client/TokenTotalPanel.tsx +197 -62
- package/src/client/format.ts +9 -7
- package/src/client/locales.ts +76 -0
- package/src/client/stats-icons.tsx +83 -0
- package/src/query.ts +53 -2
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
|
-
|
|
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
|
|
146
|
-
const
|
|
147
|
-
const model =
|
|
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.
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
|
|
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>;
|
package/package.json
CHANGED
|
@@ -1,20 +1,130 @@
|
|
|
1
|
-
|
|
2
|
-
.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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: '
|
|
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
|
|
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
|
|
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} ${
|
|
75
|
-
{breakdown !== undefined &&
|
|
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[
|
|
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:
|
|
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:
|
|
69
|
-
background:
|
|
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
|
-
/*
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
the
|
|
110
|
-
|
|
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
|
|
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:
|
|
129
|
+
padding: 12px;
|
|
139
130
|
border: 0;
|
|
140
|
-
border-radius:
|
|
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:
|
|
146
|
-
line-height:
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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,
|
|
55
|
+
billedInputTokens, cacheHitPercent, deriveStats,
|
|
53
56
|
estimateOutputTokens, formatDuration, formatTokensCompact, formatTokensPerSecond,
|
|
54
|
-
measuredTokenScale, StreamingRateSampler,
|
|
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
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
|
|
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('
|
|
453
|
-
<dd>{exactCount(
|
|
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,
|