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.
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,10 +60,14 @@ 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;
66
67
  contextWindowUnknown: string;
68
+ contextDigest: string;
69
+ contextDigestUnknown: string;
70
+ contextFree: string;
67
71
  contextCache: string;
68
72
  contextCacheHit: string;
69
73
  contextCacheMiss: string;
@@ -87,6 +91,22 @@ export declare const usageZh: {
87
91
  contextTipShare: string;
88
92
  contextTipWindow: string;
89
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;
90
110
  };
91
111
  export type SessionUsageKey = keyof typeof usageZh;
92
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.0.9",
3
+ "version": "1.1.2",
4
4
  "description": "DSH web plugin: token 用量统计看板 + 单会话(主智能体 + 子智能体)用量统计。提供今日/近3天/近7天/近1月/近3月/自定义筛选、六个汇总卡片、活跃热力图、缓存命中率折线与分模型柱状图、按模型/按供应商 Top5 列表。",
5
5
  "icon": "./icon.svg",
6
6
  "type": "module",
@@ -3,14 +3,26 @@
3
3
  * prompt-cache split, a treemap of what occupies the window, and one legend row
4
4
  * naming the six buckets. Category colors live as custom properties on the tone
5
5
  * classes so the tile and the legend dot share one palette. */
6
- .panel { background:#fff; border:1px solid #e4e7ee; border-radius:12px; margin:0 0 16px; overflow:hidden; }
6
+ .panel { background:#fff; border:1px solid #e4e7ee; border-radius:12px; margin:0 0 16px; overflow:hidden; container-type:inline-size; }
7
7
  .head { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:12px 16px 0; }
8
8
  .headCollapsed { padding-bottom:12px; }
9
- .toggle { display:flex; align-items:center; gap:6px; min-width:0; background:none; border:0; padding:0; font:inherit; color:#394354; cursor:pointer; }
9
+ .toggle { display:flex; align-items:center; gap:6px; min-width:0; flex:none; background:none; border:0; padding:0; font:inherit; color:#394354; cursor:pointer; }
10
10
  .toggle h2 { font-size:13px; font-weight:600; margin:0; white-space:nowrap; }
11
11
  .chevron { flex:none; color:#99a1af; transition:transform .2s; }
12
12
  .chevronOpen { transform:rotate(90deg); }
13
- .headRight { display:flex; align-items:center; gap:8px; min-width:0; }
13
+ .headLead { display:flex; align-items:center; gap:8px; min-width:0; flex:1 1 auto; }
14
+ /* Folded digest: the used/window figures plus a strip whose whole length is the
15
+ * model's window — the tinted head is what the six buckets occupy, and the grey
16
+ * track behind it is the room still free. A collapsed panel answers "how full,
17
+ * with what, and how much is left" while it refreshes. */
18
+ .digest { display:flex; align-items:center; gap:8px; min-width:0; flex:1 1 auto; }
19
+ /* The figures never shrink — a truncated count is worse than no strip — so the
20
+ * strip absorbs the shortfall and steps aside once it can no longer be read. */
21
+ .digestTotal { flex:none; white-space:nowrap; font-size:11px; color:#6f7889; font-variant-numeric:tabular-nums; }
22
+ /* The track is the free room: `#eef0f4` shows through after the used share. */
23
+ .digestBar { display:flex; flex:1 1 auto; min-width:0; max-width:320px; height:8px; border-radius:999px; overflow:hidden; background:#e8ebf1; }
24
+ .digestSeg { height:100%; flex:none; background:var(--ctx); transition:width .35s; }
25
+ .headRight { display:flex; align-items:center; gap:8px; min-width:0; flex:none; }
14
26
  .model { min-width:0; max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; color:#8b93a4; }
15
27
  .livePill { display:inline-flex; align-items:center; gap:4px; flex:none; padding:1px 8px 1px 6px; border-radius:999px; background:#e9f8f1; color:#128a63; font-size:10px; font-weight:500; }
16
28
  .liveDot { width:5px; height:5px; border-radius:50%; background:#18a97b; box-shadow:0 0 0 2px #d6f2e5; }
@@ -71,7 +83,15 @@
71
83
  .map { height:220px; }
72
84
  .model { display:none; }
73
85
  }
86
+ /* Container queries measure this card, not the page — the panel sits in a
87
+ * column, so below ~620px the strip is no longer readable and steps aside; the
88
+ * figures keep every number, and the tooltip still holds the split. */
89
+ @container (max-width:620px) {
90
+ .digestBar { display:none; }
91
+ .model { display:none; }
92
+ }
74
93
  @media (prefers-reduced-motion:reduce) {
75
94
  .chevron { transition:none; }
76
95
  .slot { transition:none; }
96
+ .digestSeg { transition:none; }
77
97
  }
@@ -157,14 +157,52 @@ export function ContextWindowPanel({ context, t, updatedAt }: {
157
157
  value: formatCompact(context.contextWindow),
158
158
  percent: String(percent ?? 0),
159
159
  })
160
+ // Folded, the body's figures are out of sight, so the header carries the
161
+ // digest itself: used / window · share, then a strip whose whole length is
162
+ // the model's window — the tinted part is what is in use, cut into the six
163
+ // buckets in legend order, and the grey tail is the room still free. So the
164
+ // bar reads as occupancy, not as a share of itself. Both read the same
165
+ // snapshot the body does, so every poll redraws them in place — the figures
166
+ // and the proportions stay live without the panel ever being opened.
167
+ const categoryTotal = totals.reduce((sum, row) => sum + row.tokens, 0)
168
+ // Without a reported window there is no "free room" to draw: the strip spans
169
+ // what is in use instead of inventing a denominator.
170
+ const windowKnown = context.contextWindow !== null && context.contextWindow > 0
171
+ const usedShare = windowKnown
172
+ ? Math.min(100, Math.max(0, reading.usedTokens / context.contextWindow! * 100))
173
+ : 100
174
+ const freeTokens = windowKnown ? Math.max(0, context.contextWindow! - reading.usedTokens) : 0
175
+ const digestLabel = [
176
+ `${t('contextUsed')} ${formatTokens(reading.usedTokens)} token`,
177
+ ...totals.map(row => `${t(CATEGORY_LABEL[row.key])} ${formatCompact(row.tokens)}`),
178
+ windowKnown ? `${t('contextFree')} ${formatTokens(freeTokens)} token` : windowText,
179
+ ].join(' · ')
180
+ const digestText = context.contextWindow === null
181
+ ? t('contextDigestUnknown', { used: formatCompact(reading.usedTokens) })
182
+ : t('contextDigest', {
183
+ used: formatCompact(reading.usedTokens),
184
+ window: formatCompact(context.contextWindow),
185
+ percent: String(percent ?? 0),
186
+ })
160
187
  return <section className={css.panel} data-dsh-context-window>
161
188
  {/* Collapsed, the body's spacing is gone, so the header pads its own
162
189
  bottom — otherwise the folded row reads heavy on top. */}
163
190
  <header className={open ? css.head : `${css.head} ${css.headCollapsed}`}>
164
- <button type="button" className={css.toggle} onClick={() => setOpen(!open)} aria-expanded={open} aria-controls="dsh-context-window-body">
165
- <svg className={`${css.chevron} ${open ? css.chevronOpen : ''}`} width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="m4 2 4 4-4 4" stroke="currentColor" strokeWidth="1.5" fill="none" /></svg>
166
- <h2>{t('contextTitle')}</h2>
167
- </button>
191
+ <div className={css.headLead}>
192
+ <button type="button" className={css.toggle} onClick={() => setOpen(!open)} aria-expanded={open} aria-controls="dsh-context-window-body">
193
+ <svg className={`${css.chevron} ${open ? css.chevronOpen : ''}`} width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="m4 2 4 4-4 4" stroke="currentColor" strokeWidth="1.5" fill="none" /></svg>
194
+ <h2>{t('contextTitle')}</h2>
195
+ </button>
196
+ {!open && <div className={css.digest}>
197
+ <span className={css.digestTotal} title={`${t('contextUsed')} ${formatTokens(reading.usedTokens)} token · ${windowText}`}>
198
+ {digestText}
199
+ </span>
200
+ {categoryTotal > 0 && <span className={css.digestBar} role="img" aria-label={digestLabel} title={digestLabel}>
201
+ {totals.map(row => <span key={row.key} className={`${css.digestSeg} ${CATEGORY_TONE[row.key]}`}
202
+ style={{ width: `${row.tokens / categoryTotal * usedShare}%` }} />)}
203
+ </span>}
204
+ </div>}
205
+ </div>
168
206
  <div className={css.headRight}>
169
207
  {model !== '' && <span className={css.model} title={`${context.provider} / ${context.model}`}>{model}</span>}
170
208
  <span className={css.livePill}>
@@ -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
  }