dsh-token-total 1.0.9 → 1.1.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.
@@ -31,8 +31,13 @@
31
31
  * cannot see.
32
32
  *
33
33
  * Streaming throughput replaces the gauge pill's speed
34
- * figure with a live estimate while a step is still streaming and drops back to
35
- * the official session figure the moment it settles. That subscription lives in
34
+ * figure with a live estimate of what is happening now. The figure is the
35
+ * streaming step's measured rate while one streams and the LAST measured rate
36
+ * once it settles — a session average describes the whole session's past, and
37
+ * it cannot match the pace of the answer on screen, which is what users read
38
+ * as the pill disagreeing with the stream in front of them. The official
39
+ * session figure only shows on a session that has never streamed. That
40
+ * subscription lives in
36
41
  * its own child component so stream deltas never re-render the pills. The live
37
42
  * figure itself is measured by a bounded sliding window over observed token
38
43
  * growth (stats-line-core's StreamingRateSampler): the earlier cumulative
@@ -176,17 +181,19 @@ const LIVE_SPEED_REFRESH_MS = 1_000
176
181
  * The gauge pill's always-on streaming speed slot.
177
182
  *
178
183
  * It subscribes to `legacy.partial` itself so per-frame deltas re-render only
179
- * this child, not the surrounding pills. Until the streaming step has a
180
- * measurable window the official session
181
- * figure stays on screen, so the slot is never empty and the handover at
182
- * settle is a plain value change.
184
+ * this child, not the surrounding pills. The figure it carries is the CURRENT
185
+ * stream's measured rate, and it stays that rate once the step settles:
186
+ * tool execution and the gap between steps happen inside the same answer, and
187
+ * the session average this slot used to fall back to there is a figure from
188
+ * the whole session's past — a number that cannot match the pace of the answer
189
+ * on screen, which is what reads as "not the real-time speed". The official
190
+ * session figure is only a session's opening state, shown until the first
191
+ * window measures anything.
183
192
  *
184
193
  * One window covers a whole step, across every output kind the accumulator
185
194
  * carries — reasoning, answer text, and Tool-call arguments — because the
186
195
  * provider bills all three as completion tokens and a step emits them in
187
- * sequence. Between steps the partial is gone (the step's assistant/message
188
- * settles it and the next step has not begun), so Tool execution shows the
189
- * official figure until the next step opens its own window.
196
+ * sequence.
190
197
  *
191
198
  * Sampling happens in a layout effect and the figure it yields is published as
192
199
  * state, so no render reads a clock or mutates the window: a render React starts
@@ -198,7 +205,7 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
198
205
  useChat: ChatSnapshotSelectorHook
199
206
  /** Correction measured from this session's settled steps (1 = raw prior). */
200
207
  scale: number
201
- /** The official session figure, shown until the estimate means something. */
208
+ /** The official session figure, shown until the slot has measured its own. */
202
209
  fallback: string | null
203
210
  t: T
204
211
  }) {
@@ -216,14 +223,13 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
216
223
  sampler.current = { key, rate: new StreamingRateSampler() }
217
224
  }
218
225
  const rate = sampler.current.rate
219
- // The reading the effect below produced, tagged with the step it describes. The
220
- // tag is what keeps a switch honest: the frame after a switch renders before the
221
- // new step's first observation lands, and without it that frame would show the
222
- // previous step's rate.
223
- const [published, setPublished] = useState<{ key: string | null; rate: number | null }>({
224
- key: null,
225
- rate: null,
226
- })
226
+ // The latest figure this slot has measured, held across step boundaries. A
227
+ // settled step's rate is still the conversation's current speed until the
228
+ // next step measures its own, so the display keeps it instead of switching to
229
+ // a session average the moment the stream goes quiet — holding it is the
230
+ // point, not a stale value to guard against, which is why no step tag rides
231
+ // along any more.
232
+ const [reading, setReading] = useState<number | null>(null)
227
233
  const scaleRef = useRef(scale)
228
234
  // Every delta re-runs this (its dependencies move), and the beat re-runs it once
229
235
  // a second so a step that has gone quiet still advances its window.
@@ -237,10 +243,13 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
237
243
  rate.reset()
238
244
  }
239
245
  rate.observe(tokens, Date.now())
246
+ // A null here means the fresh window has not measured anything yet (a step
247
+ // that just opened, or one that re-anchored); the figure already on screen
248
+ // is still the best current reading, so it is left alone.
240
249
  const next = rate.ratePerSecond()
241
- setPublished((previous) => (
242
- previous.key === key && previous.rate === next ? previous : { key, rate: next }
243
- ))
250
+ if (next !== null) {
251
+ setReading(previous => (previous === next ? previous : next))
252
+ }
244
253
  }, [tokens, key, beat, rate, scale])
245
254
  // Keyed on "a step is streaming", not on the step identity: switching steps
246
255
  // must not tear the beat down and defer the next one by a full period.
@@ -250,7 +259,6 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
250
259
  const handle = setInterval(() => { setBeat(Date.now()) }, LIVE_SPEED_REFRESH_MS)
251
260
  return () => { clearInterval(handle) }
252
261
  }, [streaming])
253
- const reading = published.key === key ? published.rate : null
254
262
  return <SpeedFigure text={reading === null
255
263
  ? fallback
256
264
  : t('stats.tokensPerSecond', { throughput: formatTokensPerSecond(reading) })} />
@@ -0,0 +1,74 @@
1
+ /* The two live cards that replaced the context-window panel: the window's
2
+ * current composition on the left, the session's spend composition on the
3
+ * right. Surfaces ride the host semantic tokens (`--dsw-alias-*`) like every
4
+ * other card on this page, so light/dark flip for free; only the eight bucket
5
+ * hues are this plugin's own (they match the reference design and the
6
+ * dashboard's chart accents). Each bucket sets `--bucket`, and the swatch, ring
7
+ * segment, bar segment and value text all read that one variable. */
8
+ .grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:18px; margin:0 0 18px; align-items:stretch; }
9
+ .card { min-width:0; display:flex; flex-direction:column; background:var(--dsw-alias-bg-layer-1); border:1px solid var(--dsw-alias-border-l2); border-radius:12px; overflow:hidden; }
10
+ .head { display:flex; align-items:center; justify-content:space-between; gap:10px; min-height:42px; padding:12px 16px 0; }
11
+ .title { margin:0; font-size:13px; font-weight:600; color:var(--dsw-alias-label-primary); white-space:nowrap; }
12
+ .model { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; color:var(--dsw-alias-label-tertiary); }
13
+ .cacheBadge { flex:none; font-size:11px; color:var(--dsw-alias-label-tertiary); white-space:nowrap; }
14
+ .cacheBadge b { margin-left:2px; font-weight:600; color:var(--dsw-alias-state-success-primary); font-variant-numeric:tabular-nums; }
15
+ .body { flex:1 1 auto; padding:12px 16px 16px; }
16
+ /* ── Left card: the live window ─────────────────────────────────────────── */
17
+ .totalRow { display:flex; align-items:baseline; justify-content:space-between; gap:12px; }
18
+ .total { font-size:20px; font-weight:650; letter-spacing:-.3px; color:var(--dsw-alias-label-primary); font-variant-numeric:tabular-nums; white-space:nowrap; }
19
+ .totalShare { flex:none; font-size:11px; color:var(--dsw-alias-label-tertiary); white-space:nowrap; }
20
+ .totalShare b { color:var(--dsw-alias-label-primary-dimmed); font-weight:600; font-variant-numeric:tabular-nums; }
21
+ /* A stacked share bar over the drawn content; the empty state is the same
22
+ * diagonal hatch the old panel used for "no data yet". */
23
+ .bar { display:flex; width:100%; height:10px; margin:12px 0 14px; border-radius:999px; overflow:hidden; background:var(--dsw-alias-border-l1); }
24
+ .barSeg { height:100%; flex:none; background:var(--bucket); transition:width .35s ease; }
25
+ .barEmpty { display:block; width:100%; height:100%; background:repeating-linear-gradient(90deg,var(--dsw-alias-border-l2) 0 4px,transparent 4px 8px); }
26
+ /* ── Right card: the spend composition ──────────────────────────────────── */
27
+ .statsMain { display:flex; align-items:center; gap:16px; }
28
+ .ringWrap { position:relative; flex:none; width:116px; height:116px; }
29
+ .ring { display:block; }
30
+ .ringTrack { stroke:var(--dsw-alias-border-l1); }
31
+ .ringCenter { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1px; text-align:center; pointer-events:none; }
32
+ .ringTotal { font-size:17px; font-weight:650; letter-spacing:-.3px; color:var(--dsw-alias-label-primary); font-variant-numeric:tabular-nums; }
33
+ .ringCaption { font-size:10px; color:var(--dsw-alias-label-tertiary); }
34
+ /* Legend: two columns, column-major (left column is the first four buckets,
35
+ * the right column the rest), so the reading order matches the ring. */
36
+ .legend { flex:1 1 auto; min-width:0; display:grid; grid-template-rows:repeat(4,auto); grid-auto-flow:column; gap:7px 16px; margin:0; padding:0; list-style:none; }
37
+ .legendRow { display:grid; grid-template-columns:7px minmax(0,1fr) auto auto; align-items:center; gap:6px; min-width:0; font-size:11px; }
38
+ .swatch { width:7px; height:7px; border-radius:50%; background:var(--bucket); }
39
+ .legendName { min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:var(--dsw-alias-label-secondary); }
40
+ .legendPercent { color:var(--dsw-alias-label-tertiary); font-variant-numeric:tabular-nums; text-align:right; white-space:nowrap; }
41
+ .legendValue { color:var(--bucket); font-variant-numeric:tabular-nums; font-weight:500; text-align:right; white-space:nowrap; }
42
+ .legendSuffix { color:var(--dsw-alias-label-tertiary); font-weight:400; }
43
+ .empty { margin:16px 0 18px; text-align:center; font-size:12px; color:var(--dsw-alias-label-tertiary); }
44
+ /* ── Bucket palette ─────────────────────────────────────────────────────── */
45
+ .toneSystemPrompt { --bucket:#6f6bf5; }
46
+ .toneUser { --bucket:#2bb673; }
47
+ .toneSkill { --bucket:#16b8a6; }
48
+ .toneToolResult { --bucket:#4d6bfe; }
49
+ .toneToolDef { --bucket:#f0a53a; }
50
+ .toneInjected { --bucket:#a75ff0; }
51
+ .toneAssistant { --bucket:#7c8cff; }
52
+ .toneOutput { --bucket:#ef5da8; }
53
+ /* Narrow surfaces: one card per row, and the legends go back to wrapping rows
54
+ * (a two-column legend cannot keep 11 px labels readable under ~340 px). */
55
+ @container (max-width:880px) {
56
+ .grid { grid-template-columns:minmax(0,1fr); }
57
+ }
58
+ @container (max-width:520px) {
59
+ .statsMain { flex-wrap:wrap; justify-content:center; }
60
+ .legend { grid-template-rows:none; grid-auto-flow:row; grid-template-columns:minmax(0,1fr); width:100%; }
61
+ }
62
+ @media (prefers-reduced-motion:reduce) {
63
+ .barSeg { transition:none; }
64
+ }
65
+ /* Dark theme: the host re-resolves its aliases; only the bucket hues need a
66
+ * brighter variant so chips and ring segments hold their weight. */
67
+ body[data-ds-dark-theme] .toneSystemPrompt { --bucket:#9b96ff; }
68
+ body[data-ds-dark-theme] .toneUser { --bucket:#3ed08a; }
69
+ body[data-ds-dark-theme] .toneSkill { --bucket:#2fd6c2; }
70
+ body[data-ds-dark-theme] .toneToolResult { --bucket:#7d95ff; }
71
+ body[data-ds-dark-theme] .toneToolDef { --bucket:#f5b861; }
72
+ body[data-ds-dark-theme] .toneInjected { --bucket:#c184ff; }
73
+ body[data-ds-dark-theme] .toneAssistant { --bucket:#9aabff; }
74
+ body[data-ds-dark-theme] .toneOutput { --bucket:#ff85c0; }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The two cards that replaced the context-window panel.
3
+ *
4
+ * Left: what the window holds RIGHT NOW (the context snapshot). Right: what the
5
+ * session's requests actually spent, split by content (the spend composition).
6
+ * They answer two different questions — "what is in the prompt" and "what did
7
+ * the whole session pay for" — so they sit side by side at equal weight rather
8
+ * than one above the other.
9
+ *
10
+ * Everything is drawn from the detail payload the view already polls every
11
+ * 1.5 s (`useSessionDetail`), so both cards refresh with no extra timer. The
12
+ * charts are hand-rolled SVG/CSS: the plugin ships no chart dependency, and a
13
+ * ring plus a stacked bar is little enough markup to keep it that way.
14
+ *
15
+ * The palette is the plugin's tone scale, matching the stat cards above and the
16
+ * dashboard charts; dark values flip under `body[data-ds-dark-theme]` (the
17
+ * host's own theme switch), and every surface uses the host's semantic tokens
18
+ * so the cards sit on the same canvas as the tables.
19
+ */
20
+ import type {
21
+ SessionContextWindow,
22
+ SessionUsageBucket,
23
+ SessionUsageComposition,
24
+ SessionUsageDetail,
25
+ } from '../wire.ts'
26
+ import { bucketPercent, contextBucketTotals, contextReading } from './context-window-core.ts'
27
+ import { cacheRate, formatCompact, formatPercent, formatTokens } from './format.ts'
28
+ import { cacheHitPercentOf } from './session-usage-core.ts'
29
+ import type { SessionUsageKey } from './session-usage-locales.ts'
30
+ import { CONTEXT_BUCKET_ORDER, USAGE_BUCKET_ORDER } from '../usage-buckets.ts'
31
+ import css from './UsageCards.module.css'
32
+
33
+ type T = (key: SessionUsageKey, params?: Record<string, string>) => string
34
+
35
+ /** Legend names, one per bucket (the context-window panel's category names are
36
+ * a different split — files live inside tool results here). */
37
+ const BUCKET_LABEL: Record<SessionUsageBucket, SessionUsageKey> = {
38
+ systemPrompt: 'bucketSystemPrompt',
39
+ user: 'bucketUser',
40
+ skill: 'bucketSkill',
41
+ toolResult: 'bucketToolResult',
42
+ toolDef: 'bucketToolDef',
43
+ injected: 'bucketInjected',
44
+ assistant: 'bucketAssistant',
45
+ output: 'bucketOutput',
46
+ }
47
+
48
+ /** Each bucket brings its own accent as a CSS custom property, so the swatch,
49
+ * the ring segment, the bar segment and the value text all read one variable. */
50
+ const BUCKET_TONE: Record<SessionUsageBucket, string> = {
51
+ systemPrompt: css.toneSystemPrompt,
52
+ user: css.toneUser,
53
+ skill: css.toneSkill,
54
+ toolResult: css.toneToolResult,
55
+ toolDef: css.toneToolDef,
56
+ injected: css.toneInjected,
57
+ assistant: css.toneAssistant,
58
+ output: css.toneOutput,
59
+ }
60
+
61
+ interface Row {
62
+ key: SessionUsageBucket
63
+ tokens: number
64
+ }
65
+
66
+ /** Approximate value as the card prints it: "≈299.1k". */
67
+ const approx = (n: number): string => `≈${formatCompact(n)}`
68
+
69
+ /** Ring geometry: a 116 px donut with a 15 px stroke. */
70
+ const RING_SIZE = 116
71
+ const RING_STROKE = 15
72
+ const RING_RADIUS = (RING_SIZE - RING_STROKE) / 2 - 2
73
+ const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
74
+
75
+ /** Donut of bucket shares with the grand total in the middle. Segments are
76
+ * stroked along one circle and offset by the running length, so the ring is
77
+ * DPI-independent and needs no chart library. `total` sizes the segments (the
78
+ * replay estimate); `center` is the exact billed figure the card headlines. */
79
+ function Ring({ rows, total, center, centerTitle, caption }: {
80
+ rows: readonly Row[]
81
+ total: number
82
+ center: string
83
+ centerTitle: string
84
+ caption: string
85
+ }): JSX.Element {
86
+ const drawn = rows.filter(row => row.tokens > 0)
87
+ let offset = 0
88
+ return <div className={css.ringWrap}>
89
+ <svg className={css.ring} width={RING_SIZE} height={RING_SIZE} viewBox={`0 0 ${RING_SIZE} ${RING_SIZE}`} role="img" aria-label={caption}>
90
+ <title>{caption}</title>
91
+ <circle className={css.ringTrack} cx={RING_SIZE / 2} cy={RING_SIZE / 2} r={RING_RADIUS} fill="none" strokeWidth={RING_STROKE} />
92
+ <g transform={`rotate(-90 ${RING_SIZE / 2} ${RING_SIZE / 2})`}>
93
+ {drawn.map(row => {
94
+ const length = total > 0 ? (row.tokens / total) * RING_CIRCUMFERENCE : 0
95
+ // A hairline gap keeps adjacent hues from merging into one band; the
96
+ // segment never shrinks below 0.6 px even when it is a rounding crumb.
97
+ const visible = Math.max(length - 1.2, 0.6)
98
+ const dash = `${visible} ${RING_CIRCUMFERENCE - visible}`
99
+ const circle = <circle
100
+ key={row.key}
101
+ className={BUCKET_TONE[row.key]}
102
+ cx={RING_SIZE / 2}
103
+ cy={RING_SIZE / 2}
104
+ r={RING_RADIUS}
105
+ fill="none"
106
+ stroke="var(--bucket)"
107
+ strokeWidth={RING_STROKE}
108
+ strokeDasharray={dash}
109
+ strokeDashoffset={-offset}
110
+ />
111
+ offset += length
112
+ return circle
113
+ })}
114
+ </g>
115
+ </svg>
116
+ <div className={css.ringCenter}>
117
+ <strong className={css.ringTotal} title={centerTitle}>{center}</strong>
118
+ <span className={css.ringCaption}>{caption}</span>
119
+ </div>
120
+ </div>
121
+ }
122
+
123
+ /** One legend entry: swatch, name, share, approximate value. */
124
+ function LegendRow({ row, percent, suffix, t }: {
125
+ row: Row
126
+ percent: number
127
+ suffix?: string
128
+ t: T
129
+ }): JSX.Element {
130
+ return <li className={`${css.legendRow} ${BUCKET_TONE[row.key]}`}>
131
+ <i className={css.swatch} aria-hidden="true" />
132
+ <span className={css.legendName}>{t(BUCKET_LABEL[row.key])}</span>
133
+ <span className={css.legendPercent}>{percent.toFixed(1)}%</span>
134
+ <span className={css.legendValue} title={formatTokens(row.tokens)}>
135
+ {approx(row.tokens)}{suffix === undefined ? '' : <span className={css.legendSuffix}> · {suffix}</span>}
136
+ </span>
137
+ </li>
138
+ }
139
+
140
+ /** Left card: the live window's composition, its occupancy and the hard cap. */
141
+ function ContextCard({ context, t }: { context: SessionContextWindow; t: T }): JSX.Element {
142
+ const reading = contextReading(context)
143
+ const rows = contextBucketTotals(context.items)
144
+ // Percentages answer "what share of the window's content", so they divide by
145
+ // the sum of the drawn buckets — not by the billed prompt, which can differ
146
+ // when upstream content rewriting happens. Occupancy keeps its own reading.
147
+ const total = rows.reduce((sum, row) => sum + row.tokens, 0)
148
+ // The strip spans the model's window, never just the used part: each segment
149
+ // takes its share of the drawn content times the share of the window in use,
150
+ // so the tinted run stops exactly where "used" stops and the track behind it
151
+ // is the room still free. Without a reported window there is no scale to draw
152
+ // against, so the strip falls back to the drawn content itself.
153
+ const capacity = context.contextWindow !== null && context.contextWindow > 0 ? context.contextWindow : total
154
+ const occupied = Math.min(100, bucketPercent(reading.usedTokens, capacity))
155
+ const windowText = context.contextWindow !== null && context.contextWindow > 0
156
+ ? t('contextWindowTotal', { used: formatCompact(reading.usedTokens), window: formatCompact(context.contextWindow) })
157
+ : t('contextWindowTotalUnknown', { used: formatCompact(reading.usedTokens) })
158
+ return <section className={`${css.card} ${css.contextCard}`}>
159
+ <header className={css.head}>
160
+ <h2 className={css.title}>{t('currentContextTitle')}</h2>
161
+ <span className={css.model} title={`${context.provider} / ${context.model}`}>
162
+ {[context.provider, context.model].filter(Boolean).join(' · ') || '—'}
163
+ </span>
164
+ </header>
165
+ <div className={css.body}>
166
+ <div className={css.totalRow}>
167
+ <strong className={css.total} title={formatTokens(reading.usedTokens)}>{windowText}</strong>
168
+ {reading.percent !== null && <span className={css.totalShare}>
169
+ <b>{formatPercent(reading.percent)}</b> {t('contextUsedShort')}
170
+ </span>}
171
+ </div>
172
+ {/* One stacked strip across the whole window: a bucket's width is its
173
+ share of what the next request carries, scaled by how full the window
174
+ is, so the remainder reads as the room still free. */}
175
+ <div className={css.bar} role="img" aria-label={t('currentContextTitle')}>
176
+ {rows.map(row => row.tokens > 0 && total > 0
177
+ ? <span key={row.key} className={`${css.barSeg} ${BUCKET_TONE[row.key]}`} style={{ width: `${bucketPercent(row.tokens, total) * occupied / 100}%` }} />
178
+ : null)}
179
+ {total <= 0 && <span className={css.barEmpty} />}
180
+ </div>
181
+ <ul className={css.legend}>
182
+ {rows.map(row => <LegendRow key={row.key} row={row} percent={bucketPercent(row.tokens, total)} t={t} />)}
183
+ </ul>
184
+ </div>
185
+ </section>
186
+ }
187
+
188
+ /** Right card: every request's replayed content plus the billed output. */
189
+ function StatsCard({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element {
190
+ const composition = detail.composition ?? null
191
+ const rows = composition?.buckets ?? []
192
+ const total = rows.reduce((sum, row) => sum + row.tokens, 0)
193
+ const billed = composition?.billedTokens ?? 0
194
+ // Prefer the session's exact cache split; the live window's snapshot is the
195
+ // fallback for an older host payload that predates the composition.
196
+ const hit = composition !== null
197
+ ? cacheHitPercentOf(composition.totals)
198
+ : detail.context?.cacheKnown
199
+ ? cacheRate(detail.context.cacheHitTokens, detail.context.cacheMissTokens)
200
+ : null
201
+ return <section className={`${css.card} ${css.statsCard}`}>
202
+ <header className={css.head}>
203
+ <h2 className={css.title}>{t('statsTitle')}</h2>
204
+ {hit !== null && <span className={css.cacheBadge}>
205
+ {t('statsCacheHit')} <b>{hit.toFixed(2)}%</b>
206
+ </span>}
207
+ </header>
208
+ <div className={css.body}>
209
+ {composition === null
210
+ ? <p className={css.empty}>{t('statsEmpty')}</p>
211
+ : <>
212
+ <div className={css.statsMain}>
213
+ <Ring rows={rows} total={total} center={formatCompact(billed)} centerTitle={formatTokens(billed)} caption={t('statsTotal')} />
214
+ <ul className={css.legend}>
215
+ {USAGE_BUCKET_ORDER.map(key => {
216
+ const row = rows.find(item => item.key === key) ?? { key, tokens: 0 }
217
+ return <LegendRow
218
+ key={key}
219
+ row={row}
220
+ percent={bucketPercent(row.tokens, total)}
221
+ suffix={key === 'output' ? t('statsIncludesThinking') : undefined}
222
+ t={t}
223
+ />
224
+ })}
225
+ </ul>
226
+ </div>
227
+ </>}
228
+ </div>
229
+ </section>
230
+ }
231
+
232
+ /** The pair, rendered where the context-window panel used to sit. */
233
+ export function SessionUsageCards({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Element | null {
234
+ if (detail.context === null || detail.context === undefined) {
235
+ if (detail.composition === null || detail.composition === undefined) return null
236
+ return <div className={css.grid}>
237
+ <StatsCard detail={detail} t={t} />
238
+ </div>
239
+ }
240
+ return <div className={css.grid}>
241
+ <ContextCard context={detail.context} t={t} />
242
+ <StatsCard detail={detail} t={t} />
243
+ </div>
244
+ }
@@ -4,6 +4,8 @@
4
4
  * separate from the component so both are testable without a DOM.
5
5
  */
6
6
  import type { SessionContextCategory, SessionContextItem, SessionContextWindow } from '../wire.ts'
7
+ import type { SessionUsageBucket } from '../wire.ts'
8
+ import { CONTEXT_BUCKET_ORDER } from '../usage-buckets.ts'
7
9
 
8
10
  /** Legend order, also the color assignment order (see the CSS module). */
9
11
  export const CONTEXT_CATEGORY_ORDER: readonly SessionContextCategory[] = [
@@ -30,6 +32,43 @@ export function contextCategoryTotals(
30
32
  .filter(row => row.tokens > 0)
31
33
  }
32
34
 
35
+ /** Which legend bucket one window row belongs to.
36
+ *
37
+ * The row categories are the treemap's (they group files separately); the
38
+ * cards speak the eight-bucket legend instead. A folded `rest` row lost its
39
+ * kind, so a conversation tail lands with the assistant — the larger half of
40
+ * what a turn replays.
41
+ */
42
+ function contextBucketOf(item: SessionContextItem): SessionUsageBucket {
43
+ switch (item.category) {
44
+ case 'system': return 'systemPrompt'
45
+ case 'tools': return 'toolDef'
46
+ case 'memory': return item.kind === 'skill-catalog' ? 'skill' : 'injected'
47
+ case 'files':
48
+ case 'results': return 'toolResult'
49
+ case 'conversation': return item.kind === 'user' ? 'user' : 'assistant'
50
+ default: return 'injected'
51
+ }
52
+ }
53
+
54
+ /** The live window folded into the same legend the spend card prints, in
55
+ * legend order and keeping zero buckets so both cards stay aligned. */
56
+ export function contextBucketTotals(
57
+ items: readonly SessionContextItem[],
58
+ ): Array<{ key: SessionUsageBucket; tokens: number }> {
59
+ const totals = new Map<SessionUsageBucket, number>()
60
+ for (const item of items) {
61
+ const key = contextBucketOf(item)
62
+ totals.set(key, (totals.get(key) ?? 0) + item.tokens)
63
+ }
64
+ return CONTEXT_BUCKET_ORDER.map(key => ({ key, tokens: totals.get(key) ?? 0 }))
65
+ }
66
+
67
+ /** What a share of the whole is, for the legend column. */
68
+ export function bucketPercent(tokens: number, total: number): number {
69
+ return total > 0 ? (tokens / total) * 100 : 0
70
+ }
71
+
33
72
  /** Window occupancy in whole percent, or null when the host never reported the
34
73
  * model's window (the panel then says "unknown" instead of guessing one). */
35
74
  export function contextWindowPercent(usedTokens: number, contextWindow: number | null): number | null {
@@ -16,8 +16,10 @@ export const usageZh = {
16
16
  answering: '正在回答...', firstWait: '首字耗时', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
17
17
  cacheShort: '缓存', avgCache: '平均缓存', avgRate: '平均速率', inProgress: '个进行中',
18
18
  live: '实时更新', asOf: '统计截至', refresh: '刷新',
19
- contextTitle: '上下文窗口', contextUsed: '已用上下文', contextLive: '实时',
19
+ contextTitle: '上下文窗口', currentContextTitle: '当前上下文', contextUsed: '已用上下文', contextLive: '实时',
20
20
  contextWindowKnown: '模型窗口 {value} · 已用 {percent}%', contextWindowUnknown: '模型的窗口大小未知',
21
+ contextDigest: '{used} / {window} · {percent}%', contextDigestUnknown: '{used} / 窗口未知',
22
+ contextFree: '空闲',
21
23
  contextCache: '提示词缓存', contextCacheHit: '命中', contextCacheMiss: '未命中',
22
24
  contextCachePending: '供应商回答可见',
23
25
  contextSystem: '系统提示词', contextTools: '工具', contextMemory: '记忆',
@@ -28,6 +30,14 @@ export const usageZh = {
28
30
  contextKindRest: '另外 {count} 项', contextTimes: '×{count}',
29
31
  contextEmpty: '本会话还没有可统计的上下文', contextTipShare: '占上下文 {percent}', contextTipWindow: '占窗口 {percent}',
30
32
  contextEstimateNote: '色块按历史重放估算 {estimate};供应商实测提示词 {billed}(上游已压缩或裁剪)',
33
+ // ── The two cards that replaced the context-window panel ──────────────────
34
+ statsTitle: 'Token 统计', statsTotal: '总用量', statsCacheHit: '缓存命中',
35
+ statsIncludesThinking: '含思考', statsEmpty: '本会话还没有可统计的 token',
36
+ contextWindowTotal: '{used} / {window} tokens', contextWindowTotalUnknown: '{used} tokens',
37
+ contextUsedShort: '上下文已用',
38
+ bucketSystemPrompt: '系统提示词', bucketUser: '用户消息', bucketSkill: '技能注入',
39
+ bucketToolResult: '工具结果', bucketToolDef: '工具定义', bucketInjected: '注入内容',
40
+ bucketAssistant: '助手消息', bucketOutput: '输出',
31
41
  }
32
42
  export type SessionUsageKey = keyof typeof usageZh
33
43
  export const usageEn: Record<SessionUsageKey, string> = {
@@ -46,8 +56,10 @@ export const usageEn: Record<SessionUsageKey, string> = {
46
56
  answering: 'answering...', firstWait: 'first token', sec: 's', tokenWord: 'token', perSec: 'token/s',
47
57
  cacheShort: 'cache', avgCache: 'avg cache', avgRate: 'avg rate', inProgress: 'in progress',
48
58
  live: 'Live', asOf: 'as of', refresh: 'Refresh',
49
- contextTitle: 'Context window', contextUsed: 'Context used', contextLive: 'Live',
59
+ contextTitle: 'Context window', currentContextTitle: 'Current context', contextUsed: 'Context used', contextLive: 'Live',
50
60
  contextWindowKnown: 'Window {value} · {percent}% used', contextWindowUnknown: 'Model window size unknown',
61
+ contextDigest: '{used} / {window} · {percent}%', contextDigestUnknown: '{used} / window unknown',
62
+ contextFree: 'free',
51
63
  contextCache: 'Prompt cache', contextCacheHit: 'hit', contextCacheMiss: 'miss',
52
64
  contextCachePending: 'Visible once the provider answers',
53
65
  contextSystem: 'System prompt', contextTools: 'Tools', contextMemory: 'Memory',
@@ -58,6 +70,14 @@ export const usageEn: Record<SessionUsageKey, string> = {
58
70
  contextKindRest: '{count} more', contextTimes: '×{count}',
59
71
  contextEmpty: 'Nothing in this session’s context yet', contextTipShare: '{percent} of context', contextTipWindow: '{percent} of window',
60
72
  contextEstimateNote: 'Blocks estimate {estimate} by replay; the provider billed {billed} after upstream compression',
73
+ // ── The two cards that replaced the context-window panel ──────────────────
74
+ statsTitle: 'Token stats', statsTotal: 'Total usage', statsCacheHit: 'Cache hit',
75
+ statsIncludesThinking: 'incl. thinking', statsEmpty: 'No token spend recorded for this session yet',
76
+ contextWindowTotal: '{used} / {window} tokens', contextWindowTotalUnknown: '{used} tokens',
77
+ contextUsedShort: 'context used',
78
+ bucketSystemPrompt: 'System prompt', bucketUser: 'User messages', bucketSkill: 'Skill injection',
79
+ bucketToolResult: 'Tool results', bucketToolDef: 'Tool definitions', bucketInjected: 'Injected content',
80
+ bucketAssistant: 'Assistant messages', bucketOutput: 'Output',
61
81
  }
62
82
  export const usageZhTW: Record<SessionUsageKey, string> = {
63
83
  ...usageZh,
@@ -74,8 +94,10 @@ export const usageZhTW: Record<SessionUsageKey, string> = {
74
94
  answering: '正在回答...', firstWait: '首字耗時', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
75
95
  cacheShort: '快取', avgCache: '平均快取', avgRate: '平均速率', inProgress: '個進行中',
76
96
  live: '即時更新', asOf: '統計截至', refresh: '重新整理',
77
- contextTitle: '上下文視窗', contextUsed: '已用上下文', contextLive: '即時',
97
+ contextTitle: '上下文視窗', currentContextTitle: '當前上下文', contextUsed: '已用上下文', contextLive: '即時',
78
98
  contextWindowKnown: '模型視窗 {value} · 已用 {percent}%', contextWindowUnknown: '模型的視窗大小未知',
99
+ contextDigest: '{used} / {window} · {percent}%', contextDigestUnknown: '{used} / 視窗未知',
100
+ contextFree: '空閒',
79
101
  contextCache: '提示詞快取', contextCacheHit: '命中', contextCacheMiss: '未命中',
80
102
  contextCachePending: '供應商回答可見',
81
103
  contextSystem: '系統提示詞', contextTools: '工具', contextMemory: '記憶',
@@ -86,4 +108,12 @@ export const usageZhTW: Record<SessionUsageKey, string> = {
86
108
  contextKindRest: '另外 {count} 項', contextTimes: '×{count}',
87
109
  contextEmpty: '本會話還沒有可統計的上下文', contextTipShare: '佔上下文 {percent}', contextTipWindow: '佔視窗 {percent}',
88
110
  contextEstimateNote: '色塊按歷史重放估算 {estimate};供應商實測提示詞 {billed}(上游已壓縮或裁剪)',
111
+ // ── The two cards that replaced the context-window panel ──────────────────
112
+ statsTitle: 'Token 統計', statsTotal: '總用量', statsCacheHit: '快取命中',
113
+ statsIncludesThinking: '含思考', statsEmpty: '本會話還沒有可統計的 token',
114
+ contextWindowTotal: '{used} / {window} tokens', contextWindowTotalUnknown: '{used} tokens',
115
+ contextUsedShort: '上下文已用',
116
+ bucketSystemPrompt: '系統提示詞', bucketUser: '使用者訊息', bucketSkill: '技能注入',
117
+ bucketToolResult: '工具結果', bucketToolDef: '工具定義', bucketInjected: '注入內容',
118
+ bucketAssistant: '助手訊息', bucketOutput: '輸出',
89
119
  }
@@ -314,16 +314,25 @@ const CJK_TOKENS_PER_CHAR = 0.6
314
314
  const ASCII_TOKENS_PER_CHAR = 0.3
315
315
 
316
316
  /**
317
- * Shortest observable decode window before a live rate is published, in ms
318
- * (the same floor MiMo-Code's sidebar tps uses). Below it the first chunk's
319
- * burst would read as hundreds of tok/s; the sampler also publishes its FIRST
320
- * reading at exactly this mark rather than at the next steady cadence.
317
+ * Shortest observable decode window before a live rate is published, in ms.
318
+ * Below it the first chunk's burst would read as hundreds of tok/s; the
319
+ * sampler also publishes its FIRST reading at exactly this mark rather than at
320
+ * the next steady cadence.
321
+ *
322
+ * The floor is deliberately short — 200 ms, down from the 500 ms MiMo-Code's
323
+ * sidebar tps holds — because every step opens a window of its own, and at
324
+ * 500 ms a step that finishes inside the floor never published at all: a run
325
+ * of short steps (one burst of tool-call arguments, then settle) kept showing
326
+ * a figure measured somewhere else while the screen was full of live output.
327
+ * At this length the opening burst is judged by the plausibility check below
328
+ * rather than by waiting it out, and the smoothing constant still carries the
329
+ * first reading only part of the way into an established figure.
321
330
  *
322
331
  * The floor is a publishing gate, NOT a rejection gate: it is applied after the
323
332
  * plausibility check below, so a backlog frame can never dodge that check by
324
333
  * arriving early (see {@link StreamingRateSampler.observe}).
325
334
  */
326
- const MIN_STREAM_WINDOW_MS = 500
335
+ const MIN_STREAM_WINDOW_MS = 200
327
336
 
328
337
  /**
329
338
  * Longest window a live rate may span, in ms. This is the bound the previous
@@ -132,6 +132,18 @@ function promptTokensOf(usage: unknown): number | null {
132
132
  return input + cached > 0 ? input + cached : null
133
133
  }
134
134
 
135
+ /** Prompt tokens one tool definition costs: its name, description and JSON
136
+ * schema, plus the framing every schema node pays. 0 for a nameless entry.
137
+ * Shared with the spend composition, which replays the same catalogue on
138
+ * every request. */
139
+ export function toolTokens(tool: unknown): number {
140
+ const record = tool as { name?: unknown; description?: unknown; parameters?: unknown }
141
+ const name = String(record?.name ?? '')
142
+ if (name === '') return 0
143
+ return estimateTokens(name) + valueTokens(record?.description)
144
+ + estimateTokens(JSON.stringify(record?.parameters ?? {})) + TOOL_SCHEMA_OVERHEAD_TOKENS
145
+ }
146
+
135
147
  /** Fold one category's rows: keep the biggest until KEEP_SHARE is covered (and
136
148
  * never more than MAX_ROWS_PER_CATEGORY), then merge the tail into one row so
137
149
  * a session with 200 tool calls still yields a readable treemap. */
@@ -279,11 +291,9 @@ export function buildSessionContextWindow(events: readonly UsageSessionEvent[]):
279
291
  }
280
292
  // The tool catalogue the next call sends: name + description + JSON schema.
281
293
  for (const tool of tools) {
282
- const record = tool as { name?: unknown; description?: unknown; parameters?: unknown }
283
- const name = String(record?.name ?? '')
284
- if (name === '') continue
285
- const tokens = estimateTokens(name) + valueTokens(record?.description)
286
- + estimateTokens(JSON.stringify(record?.parameters ?? {})) + TOOL_SCHEMA_OVERHEAD_TOKENS
294
+ const tokens = toolTokens(tool)
295
+ const name = String((tool as { name?: unknown })?.name ?? '')
296
+ if (name === '' || tokens === 0) continue
287
297
  add('tools', name, tokens)
288
298
  }
289
299
  const items = foldDrafts([...drafts.values()])
@@ -9,6 +9,7 @@ import type {
9
9
  } from './context-types.ts'
10
10
  import { UsageFold } from './collector.ts'
11
11
  import { buildSessionContextWindow } from './context-window.ts'
12
+ import { buildSessionUsageComposition, mergeUsageCompositions } from './usage-composition.ts'
12
13
  import { resolveModelNames, resolveProviderNames } from './model-names.ts'
13
14
  import { decodeRateOf } from './shared.ts'
14
15
  import type { SessionUsageDetail, SessionUsageTotals } from './wire.ts'
@@ -209,7 +210,7 @@ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionH
209
210
  // a cancelled step is not a wait the reader experienced as an answer.
210
211
  const firsts = records.filter(x => x.status === 'completed').map(x => x.firstTokenMs).filter((x): x is number => x !== null)
211
212
  for (const x of records) { x.turnName = turnTopics.get(x.turn) ?? ''; delete (x as any)._attempt; delete (x as any)._lastUsage }
212
- return { id, name, header, totals, records: records as SessionUsageDetail['records'], requests, averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), context: buildSessionContextWindow(own) }
213
+ return { id, name, header, totals, records: records as SessionUsageDetail['records'], requests, averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), context: buildSessionContextWindow(own), composition: buildSessionUsageComposition(own) }
213
214
  }
214
215
 
215
216
  export async function buildSessionUsageDetail(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, ctx?: Context): Promise<SessionUsageDetail> {
@@ -247,5 +248,5 @@ export async function buildSessionUsageDetail(persistence: UsageSessionPersisten
247
248
  // The card is scoped to this session and every subagent, so its speed and
248
249
  // first-token average fold the same records its token totals do.
249
250
  const firsts = records.filter(x => x.status === 'completed').map(x => x.firstTokenMs).filter((x): x is number => x !== null)
250
- return { sessionId: rootId, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()], context: parts[0]?.context ?? null }
251
+ return { sessionId: rootId, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()], context: parts[0]?.context ?? null, composition: mergeUsageCompositions(parts.map(p => p.composition)) }
251
252
  }