dsh-token-total 1.1.0 → 1.1.3

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
@@ -1620,6 +1620,16 @@ function promptTokensOf(usage) {
1620
1620
  const cached = Number(record?.cacheReadTokens) || 0;
1621
1621
  return input + cached > 0 ? input + cached : null;
1622
1622
  }
1623
+ /** Prompt tokens one tool definition costs: its name, description and JSON
1624
+ * schema, plus the framing every schema node pays. 0 for a nameless entry.
1625
+ * Shared with the spend composition, which replays the same catalogue on
1626
+ * every request. */
1627
+ function toolTokens(tool) {
1628
+ const record = tool;
1629
+ const name = String(record?.name ?? "");
1630
+ if (name === "") return 0;
1631
+ return estimateTokens(name) + valueTokens(record?.description) + estimateTokens(JSON.stringify(record?.parameters ?? {})) + TOOL_SCHEMA_OVERHEAD_TOKENS;
1632
+ }
1623
1633
  /** Fold one category's rows: keep the biggest until KEEP_SHARE is covered (and
1624
1634
  * never more than MAX_ROWS_PER_CATEGORY), then merge the tail into one row so
1625
1635
  * a session with 200 tool calls still yields a readable treemap. */
@@ -1774,10 +1784,10 @@ function buildSessionContextWindow(events) {
1774
1784
  }
1775
1785
  for (const summary of summaries) if (summary.tokens > 0) add("memory", "", summary.tokens, "compaction-summary");
1776
1786
  for (const tool of tools) {
1777
- const record = tool;
1778
- const name = String(record?.name ?? "");
1779
- if (name === "") continue;
1780
- add("tools", name, estimateTokens(name) + valueTokens(record?.description) + estimateTokens(JSON.stringify(record?.parameters ?? {})) + TOOL_SCHEMA_OVERHEAD_TOKENS);
1787
+ const tokens = toolTokens(tool);
1788
+ const name = String(tool?.name ?? "");
1789
+ if (name === "" || tokens === 0) continue;
1790
+ add("tools", name, tokens);
1781
1791
  }
1782
1792
  const items = foldDrafts([...drafts.values()]);
1783
1793
  return {
@@ -1793,6 +1803,197 @@ function buildSessionContextWindow(events) {
1793
1803
  };
1794
1804
  }
1795
1805
  //#endregion
1806
+ //#region src/usage-buckets.ts
1807
+ const USAGE_BUCKET_ORDER = [
1808
+ "systemPrompt",
1809
+ "user",
1810
+ "skill",
1811
+ "toolResult",
1812
+ "toolDef",
1813
+ "injected",
1814
+ "assistant",
1815
+ "output"
1816
+ ];
1817
+ USAGE_BUCKET_ORDER.filter((key) => key !== "output");
1818
+ //#endregion
1819
+ //#region src/usage-composition.ts
1820
+ /**
1821
+ * Spend composition: what the session's requests actually paid for, split by
1822
+ * what filled each prompt.
1823
+ *
1824
+ * The context-window snapshot answers "what is in the window NOW". This module
1825
+ * answers the other half of the picture: every provider request replays the
1826
+ * whole message list, so a session that made 300 calls paid for the system
1827
+ * prompt 300 times. The number the Token stats card shows per bucket is
1828
+ * therefore the sum of that bucket's size AT EVERY REQUEST — not its current
1829
+ * size, which would always favour whatever arrived last.
1830
+ *
1831
+ * The naive way to get that is to rebuild the replayed list per request, which
1832
+ * is O(requests × events) and far too slow for a view that polls every 1.5 s
1833
+ * over a long session. Instead this walks the log ONCE and keeps a live block
1834
+ * map with per-bucket subtotals: a request snapshots those subtotals (8 adds)
1835
+ * plus the tool catalogue total, and shadowed sequences subtract their block
1836
+ * the moment compaction prunes them. That is O(events + requests × 8).
1837
+ *
1838
+ * Token sizes are ESTIMATES from the same character-count estimator as
1839
+ * `context-window.ts` (the provider never reports a per-block number), so the
1840
+ * card labels them approximate. The billed total, cache split and request
1841
+ * count come straight from `usage` records and are exact.
1842
+ */
1843
+ /** Shadowed sequences a compaction event removed from the replay list. */
1844
+ function shadowedSeqs(data) {
1845
+ const seqs = [];
1846
+ for (const seq of Array.isArray(data?.shadowedSeqs) ? data.shadowedSeqs : []) seqs.push(Number(seq));
1847
+ const range = data?.shadowedRange;
1848
+ if (typeof range?.start === "number" && typeof range?.end === "number") for (let seq = range.start; seq <= range.end; seq++) seqs.push(seq);
1849
+ return seqs;
1850
+ }
1851
+ /**
1852
+ * Accumulate one session's own events into the eight legend buckets.
1853
+ *
1854
+ * Returns null for an empty log or a log whose requests never reported usage
1855
+ * (an older host writing no usage records) so the card can hide itself rather
1856
+ * than draw an empty ring.
1857
+ */
1858
+ function buildSessionUsageComposition(events) {
1859
+ if (events.length === 0) return null;
1860
+ const ordered = [...events].sort((a, b) => a.seq - b.seq);
1861
+ /** Blocks still replayed by the next request, keyed by their event sequence. */
1862
+ const live = /* @__PURE__ */ new Map();
1863
+ /** Current size of the replay list per bucket — what one request would send. */
1864
+ const liveByBucket = /* @__PURE__ */ new Map();
1865
+ /** What every request so far sent, summed per bucket. */
1866
+ const acc = /* @__PURE__ */ new Map();
1867
+ const totals = {
1868
+ uncachedInputTokens: 0,
1869
+ outputTokens: 0,
1870
+ cacheReadTokens: 0,
1871
+ cacheWriteTokens: 0
1872
+ };
1873
+ let billed = 0;
1874
+ let requests = 0;
1875
+ /** Prompt tokens the newest request/header's tool catalogue costs. */
1876
+ let catalogue = 0;
1877
+ const addBlock = (seq, bucket, tokens) => {
1878
+ if (tokens <= 0) return;
1879
+ live.set(seq, {
1880
+ bucket,
1881
+ tokens
1882
+ });
1883
+ liveByBucket.set(bucket, (liveByBucket.get(bucket) ?? 0) + tokens);
1884
+ };
1885
+ const dropBlock = (seq) => {
1886
+ const block = live.get(seq);
1887
+ if (block === void 0) return;
1888
+ live.delete(seq);
1889
+ liveByBucket.set(block.bucket, (liveByBucket.get(block.bucket) ?? 0) - block.tokens);
1890
+ };
1891
+ /** Charge one request for everything it replayed. */
1892
+ const snapshot = () => {
1893
+ for (const [bucket, tokens] of liveByBucket) if (tokens > 0) acc.set(bucket, (acc.get(bucket) ?? 0) + tokens);
1894
+ if (catalogue > 0) acc.set("toolDef", (acc.get("toolDef") ?? 0) + catalogue);
1895
+ };
1896
+ /** Fold the provider's exact report into the billed split. */
1897
+ const account = (usage) => {
1898
+ const input = Number(usage?.inputTokens) || 0;
1899
+ const cacheRead = Number(usage?.cacheReadTokens) || 0;
1900
+ const cacheWrite = Number(usage?.cacheWriteTokens) || 0;
1901
+ const output = Number(usage?.outputTokens) || 0;
1902
+ totals.uncachedInputTokens += input;
1903
+ totals.cacheReadTokens += cacheRead;
1904
+ totals.cacheWriteTokens += cacheWrite;
1905
+ totals.outputTokens += output;
1906
+ billed += input + cacheRead + cacheWrite + output;
1907
+ requests++;
1908
+ if (output > 0) acc.set("output", (acc.get("output") ?? 0) + output);
1909
+ };
1910
+ for (const event of ordered) {
1911
+ const data = event.data ?? {};
1912
+ if (event.type === "compaction/prune" || event.type === "compaction/summary") {
1913
+ if (event.type === "compaction/summary" && data.usage !== void 0 && data.usage !== null) {
1914
+ snapshot();
1915
+ account(data.usage);
1916
+ }
1917
+ for (const seq of shadowedSeqs(data)) dropBlock(seq);
1918
+ if (event.type === "compaction/summary") addBlock(event.seq, "injected", messageTokens({ content: data.summary }));
1919
+ continue;
1920
+ }
1921
+ if (event.type === "request/header") {
1922
+ const header = data.header ?? {};
1923
+ if (Array.isArray(header.tools)) {
1924
+ catalogue = 0;
1925
+ for (const tool of header.tools) catalogue += toolTokens(tool);
1926
+ }
1927
+ continue;
1928
+ }
1929
+ if (event.type === "assistant/message") {
1930
+ if (data.usage !== void 0 && data.usage !== null) {
1931
+ snapshot();
1932
+ account(data.usage);
1933
+ }
1934
+ addBlock(event.seq, "assistant", messageTokens(data.message ?? { content: data.content }));
1935
+ continue;
1936
+ }
1937
+ if (event.type === "system/message") {
1938
+ const bucket = String(data.message?.source?.kind ?? data.source?.kind ?? "system-prompt") === "system-prompt" ? "systemPrompt" : "injected";
1939
+ addBlock(event.seq, bucket, messageTokens(data.message ?? { content: data.content }));
1940
+ continue;
1941
+ }
1942
+ if (event.type === "user/message") {
1943
+ const kind = String(data.source?.kind ?? data.message?.source?.kind ?? "user");
1944
+ const bucket = kind === "user" ? "user" : kind === "skill-catalog" ? "skill" : "injected";
1945
+ addBlock(event.seq, bucket, messageTokens(data.message ?? { content: data.content }));
1946
+ continue;
1947
+ }
1948
+ if (event.type === "tool/result") {
1949
+ addBlock(event.seq, "toolResult", messageTokens(data.message ?? {}));
1950
+ continue;
1951
+ }
1952
+ }
1953
+ if (requests === 0) return null;
1954
+ return {
1955
+ buckets: USAGE_BUCKET_ORDER.map((key) => ({
1956
+ key,
1957
+ tokens: Math.max(0, Math.round(acc.get(key) ?? 0))
1958
+ })),
1959
+ billedTokens: billed,
1960
+ totals,
1961
+ requests
1962
+ };
1963
+ }
1964
+ /** Add one tree's compositions into the root's, matching its other aggregates. */
1965
+ function mergeUsageCompositions(parts) {
1966
+ const present = parts.filter((part) => part !== null && part !== void 0);
1967
+ if (present.length === 0) return null;
1968
+ const acc = /* @__PURE__ */ new Map();
1969
+ const totals = {
1970
+ uncachedInputTokens: 0,
1971
+ outputTokens: 0,
1972
+ cacheReadTokens: 0,
1973
+ cacheWriteTokens: 0
1974
+ };
1975
+ let billed = 0;
1976
+ let requests = 0;
1977
+ for (const part of present) {
1978
+ for (const bucket of part.buckets) acc.set(bucket.key, (acc.get(bucket.key) ?? 0) + bucket.tokens);
1979
+ totals.uncachedInputTokens += part.totals.uncachedInputTokens;
1980
+ totals.cacheReadTokens += part.totals.cacheReadTokens;
1981
+ totals.cacheWriteTokens += part.totals.cacheWriteTokens;
1982
+ totals.outputTokens += part.totals.outputTokens;
1983
+ billed += part.billedTokens;
1984
+ requests += part.requests;
1985
+ }
1986
+ return {
1987
+ buckets: USAGE_BUCKET_ORDER.map((key) => ({
1988
+ key,
1989
+ tokens: acc.get(key) ?? 0
1990
+ })),
1991
+ billedTokens: billed,
1992
+ totals,
1993
+ requests
1994
+ };
1995
+ }
1996
+ //#endregion
1796
1997
  //#region src/session-detail.ts
1797
1998
  const zero = () => ({
1798
1999
  uncachedInputTokens: 0,
@@ -2120,7 +2321,8 @@ async function one(p, id, header, live) {
2120
2321
  requests,
2121
2322
  averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null,
2122
2323
  tokensPerSecond: decodeRateOf(records),
2123
- context: buildSessionContextWindow(own)
2324
+ context: buildSessionContextWindow(own),
2325
+ composition: buildSessionUsageComposition(own)
2124
2326
  };
2125
2327
  }
2126
2328
  async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
@@ -2220,7 +2422,8 @@ async function buildSessionUsageDetail(persistence, sessions, rootId, ctx) {
2220
2422
  }),
2221
2423
  records,
2222
2424
  routes: [...routeMap.values()],
2223
- context: parts[0]?.context ?? null
2425
+ context: parts[0]?.context ?? null,
2426
+ composition: mergeUsageCompositions(parts.map((p) => p.composition))
2224
2427
  };
2225
2428
  }
2226
2429
  //#endregion
@@ -0,0 +1,28 @@
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 { SessionUsageDetail } from '../wire.ts';
21
+ import type { SessionUsageKey } from './session-usage-locales.ts';
22
+ type T = (key: SessionUsageKey, params?: Record<string, string>) => string;
23
+ /** The pair, rendered where the context-window panel used to sit. */
24
+ export declare function SessionUsageCards({ detail, t }: {
25
+ detail: SessionUsageDetail;
26
+ t: T;
27
+ }): JSX.Element | null;
28
+ export {};
@@ -4,6 +4,7 @@
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';
7
8
  /** Legend order, also the color assignment order (see the CSS module). */
8
9
  export declare const CONTEXT_CATEGORY_ORDER: readonly SessionContextCategory[];
9
10
  /** One positioned treemap block in container pixels. */
@@ -19,6 +20,14 @@ export declare function contextCategoryTotals(items: readonly SessionContextItem
19
20
  key: SessionContextCategory;
20
21
  tokens: number;
21
22
  }>;
23
+ /** The live window folded into the same legend the spend card prints, in
24
+ * legend order and keeping zero buckets so both cards stay aligned. */
25
+ export declare function contextBucketTotals(items: readonly SessionContextItem[]): Array<{
26
+ key: SessionUsageBucket;
27
+ tokens: number;
28
+ }>;
29
+ /** What a share of the whole is, for the legend column. */
30
+ export declare function bucketPercent(tokens: number, total: number): number;
22
31
  /** Window occupancy in whole percent, or null when the host never reported the
23
32
  * model's window (the panel then says "unknown" instead of guessing one). */
24
33
  export declare function contextWindowPercent(usedTokens: number, contextWindow: number | null): number | null;
@@ -60,6 +60,7 @@ export declare const usageZh: {
60
60
  asOf: string;
61
61
  refresh: string;
62
62
  contextTitle: string;
63
+ currentContextTitle: string;
63
64
  contextUsed: string;
64
65
  contextLive: string;
65
66
  contextWindowKnown: string;
@@ -90,6 +91,22 @@ export declare const usageZh: {
90
91
  contextTipShare: string;
91
92
  contextTipWindow: string;
92
93
  contextEstimateNote: string;
94
+ statsTitle: string;
95
+ statsTotal: string;
96
+ statsCacheHit: string;
97
+ statsIncludesThinking: string;
98
+ statsEmpty: string;
99
+ contextWindowTotal: string;
100
+ contextWindowTotalUnknown: string;
101
+ contextUsedShort: string;
102
+ bucketSystemPrompt: string;
103
+ bucketUser: string;
104
+ bucketSkill: string;
105
+ bucketToolResult: string;
106
+ bucketToolDef: string;
107
+ bucketInjected: string;
108
+ bucketAssistant: string;
109
+ bucketOutput: string;
93
110
  };
94
111
  export type SessionUsageKey = keyof typeof usageZh;
95
112
  export declare const usageEn: Record<SessionUsageKey, string>;
@@ -31,6 +31,11 @@ import type { SessionContextWindow } from './wire.ts';
31
31
  export declare function estimateTokens(text: string): number;
32
32
  /** Approximate tokens of one replayed message (content + framing). */
33
33
  export declare function messageTokens(message: unknown): number;
34
+ /** Prompt tokens one tool definition costs: its name, description and JSON
35
+ * schema, plus the framing every schema node pays. 0 for a nameless entry.
36
+ * Shared with the spend composition, which replays the same catalogue on
37
+ * every request. */
38
+ export declare function toolTokens(tool: unknown): number;
34
39
  /** Build the context-window snapshot from one session's own events. Returns
35
40
  * null for an empty log so the panel can hide itself. */
36
41
  export declare function buildSessionContextWindow(events: readonly UsageSessionEvent[]): SessionContextWindow | null;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The legend order of the Token stats card, shared by the host accumulator and
3
+ * the client card so the two halves can never disagree about which bucket sits
4
+ * where. Left column first (what filled the prompt), right column second: the
5
+ * layout mirrors the reference design — system/user/skill/results on the left,
6
+ * tool definitions/injected/assistant/output on the right.
7
+ *
8
+ * Kept in its own dependency-free module: the host builds the numbers and the
9
+ * client paints them, and neither should pull the other's graph in for this.
10
+ */
11
+ import type { SessionUsageBucket } from './wire.ts';
12
+ export declare const USAGE_BUCKET_ORDER: readonly SessionUsageBucket[];
13
+ /** The buckets that describe the live window (output never occupies it). */
14
+ export declare const CONTEXT_BUCKET_ORDER: readonly SessionUsageBucket[];
@@ -0,0 +1,12 @@
1
+ import type { UsageSessionEvent } from './context-types.ts';
2
+ import type { SessionUsageComposition } from './wire.ts';
3
+ /**
4
+ * Accumulate one session's own events into the eight legend buckets.
5
+ *
6
+ * Returns null for an empty log or a log whose requests never reported usage
7
+ * (an older host writing no usage records) so the card can hide itself rather
8
+ * than draw an empty ring.
9
+ */
10
+ export declare function buildSessionUsageComposition(events: readonly UsageSessionEvent[]): SessionUsageComposition | null;
11
+ /** Add one tree's compositions into the root's, matching its other aggregates. */
12
+ export declare function mergeUsageCompositions(parts: ReadonlyArray<SessionUsageComposition | null | undefined>): SessionUsageComposition | null;
@@ -191,6 +191,36 @@ export interface SessionUsageDetail {
191
191
  /** The root session's live context composition, rendered above the cards.
192
192
  * Null when the log has no readable events (or on an older host). */
193
193
  context?: SessionContextWindow | null;
194
+ /** What the session's requests spent, split by what filled the prompt.
195
+ * Null when the log has no readable usage (or on an older host). */
196
+ composition?: SessionUsageComposition | null;
197
+ }
198
+ /** The eight buckets the Token stats card names, in legend order: the left
199
+ * column first (prompt composition), then the right column. */
200
+ export type SessionUsageBucket = 'systemPrompt' | 'user' | 'skill' | 'toolResult' | 'toolDef' | 'injected' | 'assistant' | 'output';
201
+ /** One session tree's spend split by prompt content.
202
+ *
203
+ * Every provider request replays the whole message list, so a bucket's number
204
+ * is what that bucket contributed ACROSS ALL REQUESTS, not its size in the
205
+ * window today. The replay figures are ESTIMATES (character counts, the same
206
+ * estimator the context-window snapshot uses) because the provider only bills
207
+ * a whole prompt; the billed total and its cache split are exact and drive
208
+ * the headline and the cache-hit badge. */
209
+ export interface SessionUsageComposition {
210
+ /** Estimated replay tokens per bucket, in legend order (kept at 0 when a
211
+ * bucket never appeared so the card can keep a stable order). */
212
+ buckets: Array<{
213
+ key: SessionUsageBucket;
214
+ tokens: number;
215
+ }>;
216
+ /** Exact provider-billed total across the same requests (prompt including
217
+ * cached tokens, plus output). */
218
+ billedTokens: number;
219
+ /** The same spend split into the four billing buckets, for the cache badge
220
+ * and any future detail row. */
221
+ totals: SessionUsageTotals;
222
+ /** How many provider answers the accumulation covers (compaction included). */
223
+ requests: number;
194
224
  }
195
225
  /** The six buckets the context-window panel paints, in legend order. They are
196
226
  * the buckets a reader thinks in: the harness prompt, the tool catalogue, the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-total",
3
- "version": "1.1.0",
3
+ "version": "1.1.3",
4
4
  "description": "DSH web plugin: token 用量统计看板 + 单会话(主智能体 + 子智能体)用量统计。提供今日/近3天/近7天/近1月/近3月/自定义筛选、六个汇总卡片、活跃热力图、缓存命中率折线与分模型柱状图、按模型/按供应商 Top5 列表。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
@@ -5,7 +5,7 @@ import { billedTotal, cacheHitPercentOf } from './session-usage-core.ts'
5
5
  import { formatCompact, formatTokens, normalizeModelName, decodeRateOf } from './format.ts'
6
6
  import { SESSION_USAGE_NS, type SessionUsageKey } from './session-usage-locales.ts'
7
7
  import { useSessionDetail } from './use-session-detail.ts'
8
- import { ContextWindowPanel } from './ContextWindowPanel.tsx'
8
+ import { SessionUsageCards } from './UsageCards.tsx'
9
9
  import { ModelLogo } from './ModelLogo.tsx'
10
10
  import css from './SessionUsageView.module.css'
11
11
 
@@ -206,11 +206,11 @@ function Records({ detail, t }: { detail: SessionUsageDetail; t: T }): JSX.Eleme
206
206
  export function SessionUsageContent({ detail, t, updatedAt }: { detail: SessionUsageDetail; t: T; updatedAt?: number | null }): JSX.Element {
207
207
  return <>
208
208
  <p className={css.scope}>{t('scope')}</p><Metrics detail={detail} t={t} /><Tables detail={detail} t={t} />
209
- {/* The context window sits between the per-subagent/model tables and the
210
- request records: it describes what the next request carries right now,
211
- while everything above is spent-token history and everything below is
212
- per-turn detail. */}
213
- {detail.context ? <ContextWindowPanel context={detail.context} t={t} updatedAt={updatedAt} /> : null}
209
+ {/* The two live cards sit between the per-subagent/model tables and the
210
+ request records: the left one describes what the next request carries
211
+ right now, the right one what the whole session spent, while everything
212
+ above is spent-token history and everything below is per-turn detail. */}
213
+ <SessionUsageCards detail={detail} t={t} />
214
214
  <Records key={detail.sessionId} detail={detail} t={t} /><p className={css.note}>{t('note')}</p>
215
215
  </>
216
216
  }
@@ -31,9 +31,14 @@
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
36
- * its own child component so stream deltas never re-render the pills. The live
34
+ * figure with a live estimate of what is happening now, and hands the slot back
35
+ * to the session's decode average when nothing is streaming. While a step
36
+ * streams the live rate is the only reading that can match the pace of the
37
+ * answer on screen; once the step settles it becomes a figure from a window
38
+ * nobody is watching any more, so the pill returns to the same quotient the
39
+ * usage view quotes (ΣoutputTokens / ΣdecodeMs) instead of freezing on the last
40
+ * measurement. That subscription lives in its own child component, so stream
41
+ * 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
39
44
  * quotient charged a whole step's text to a window that had just opened, so a
@@ -176,29 +181,43 @@ 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. Two figures share the slot, chosen by
185
+ * whether a step is streaming right now:
186
+ *
187
+ * - while one is (`partial != null`), the measured rate of THAT stream — a
188
+ * bounded sliding window over observed token growth (stats-line-core's
189
+ * StreamingRateSampler), the only reading that can track the pace of the
190
+ * answer currently on screen;
191
+ * - otherwise the session's own decode average, ΣoutputTokens / ΣdecodeMs —
192
+ * the same quotient `decodeRateOf` quotes in the usage view. An idle composer
193
+ * must not freeze on whatever the last window happened to measure: a settled
194
+ * thirty-step turn kept showing its final second-long reading (57 tok/s)
195
+ * beside the usage view's 156 tok/s for the same work, and the two read as a
196
+ * contradiction rather than as two scopes of one number.
197
+ *
198
+ * The measured reading carries the step it was taken in, so a new step cannot
199
+ * inherit the previous one's figure before its own window publishes; nothing is
200
+ * held across the gap — the gap is exactly when the average applies.
183
201
  *
184
202
  * One window covers a whole step, across every output kind the accumulator
185
203
  * carries — reasoning, answer text, and Tool-call arguments — because the
186
204
  * 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.
205
+ * sequence.
190
206
  *
191
207
  * Sampling happens in a layout effect and the figure it yields is published as
192
208
  * state, so no render reads a clock or mutates the window: a render React starts
193
209
  * and then discards must not decide what the next committed one displays. The
194
210
  * rate itself comes from stats-line-core's StreamingRateSampler — a bounded
195
- * window over observed token growth, smoothed, and held while nothing arrives.
211
+ * window over observed token growth, smoothed, and held while nothing arrives
212
+ * inside the step it belongs to.
196
213
  */
197
214
  function LiveSpeed({ useChat, scale, fallback, t }: {
198
215
  useChat: ChatSnapshotSelectorHook
199
216
  /** Correction measured from this session's settled steps (1 = raw prior). */
200
217
  scale: number
201
- /** The official session figure, shown until the estimate means something. */
218
+ /** The session's own decode average (ΣoutputTokens / ΣdecodeMs): what the
219
+ * slot shows whenever no step is streaming, and before the streaming step's
220
+ * window has published its first reading. */
202
221
  fallback: string | null
203
222
  t: T
204
223
  }) {
@@ -216,19 +235,22 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
216
235
  sampler.current = { key, rate: new StreamingRateSampler() }
217
236
  }
218
237
  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
- })
238
+ // The figure last measured, tagged with the step it came from. The tag is
239
+ // what retires it: a reading taken in 3:4 never shows during 3:5, and with
240
+ // no step streaming (`key === null`) no tag can match at all, so the slot
241
+ // falls through to the session average on its own — no timer, no clearing
242
+ // effect, and no stale figure left standing after a turn settles.
243
+ const [reading, setReading] = useState<{ key: string; value: number } | null>(null)
227
244
  const scaleRef = useRef(scale)
228
245
  // Every delta re-runs this (its dependencies move), and the beat re-runs it once
229
246
  // a second so a step that has gone quiet still advances its window.
230
247
  const [beat, setBeat] = useState(0)
231
248
  useLayoutEffect(() => {
249
+ // Nothing is streaming: the slot shows the session average, and there is no
250
+ // window to advance. Skipping the observation also keeps a settled step's
251
+ // sampler from being fed a zero estimate, which would re-anchor it against
252
+ // text that is already counted.
253
+ if (key === null) return
232
254
  // A correction that moved re-prices the whole estimate, so growth already
233
255
  // counted under the old one must not be measured against the new one: that
234
256
  // would read as a burst the stream never produced.
@@ -237,10 +259,15 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
237
259
  rate.reset()
238
260
  }
239
261
  rate.observe(tokens, Date.now())
262
+ // A null here means the fresh window has not measured anything yet (a step
263
+ // that just opened, or one that re-anchored); the average stays up until
264
+ // this step's own window publishes something.
240
265
  const next = rate.ratePerSecond()
241
- setPublished((previous) => (
242
- previous.key === key && previous.rate === next ? previous : { key, rate: next }
243
- ))
266
+ if (next !== null) {
267
+ setReading(previous => (previous !== null && previous.key === key && previous.value === next
268
+ ? previous
269
+ : { key, value: next }))
270
+ }
244
271
  }, [tokens, key, beat, rate, scale])
245
272
  // Keyed on "a step is streaming", not on the step identity: switching steps
246
273
  // must not tear the beat down and defer the next one by a full period.
@@ -250,10 +277,12 @@ function LiveSpeed({ useChat, scale, fallback, t }: {
250
277
  const handle = setInterval(() => { setBeat(Date.now()) }, LIVE_SPEED_REFRESH_MS)
251
278
  return () => { clearInterval(handle) }
252
279
  }, [streaming])
253
- const reading = published.key === key ? published.rate : null
254
- return <SpeedFigure text={reading === null
280
+ // The tag test is the whole selection rule: a reading only shows while the
281
+ // step that produced it is the step streaming now.
282
+ const measured = reading !== null && reading.key === key ? reading.value : null
283
+ return <SpeedFigure text={measured === null
255
284
  ? fallback
256
- : t('stats.tokensPerSecond', { throughput: formatTokensPerSecond(reading) })} />
285
+ : t('stats.tokensPerSecond', { throughput: formatTokensPerSecond(measured) })} />
257
286
  }
258
287
 
259
288
  function TimePill({ stats, t, dialog, useChat, scale, dock }: {
@@ -270,9 +299,10 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
270
299
  }) {
271
300
  const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
272
301
  const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps })
273
- // The pill's accessible name stays on the official figure: a screen reader
274
- // gets the durable session reading rather than a value that changes every
275
- // second while a step streams.
302
+ // The session figure now serves both jobs: the pill's accessible name (a
303
+ // screen reader gets the durable session reading rather than a value that
304
+ // changes every second while a step streams) and the visible figure whenever
305
+ // no step is streaming.
276
306
  const officialTps = stats.decodeMs > 0
277
307
  ? t('stats.tokensPerSecond', {
278
308
  throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),