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/README.md +7 -4
- package/lib/client.js +417 -486
- package/lib/index.js +209 -6
- package/lib/types/client/UsageCards.d.ts +28 -0
- package/lib/types/client/context-window-core.d.ts +9 -0
- package/lib/types/client/session-usage-locales.d.ts +20 -0
- package/lib/types/context-window.d.ts +5 -0
- package/lib/types/usage-buckets.d.ts +14 -0
- package/lib/types/usage-composition.d.ts +12 -0
- package/lib/types/wire.d.ts +30 -0
- package/package.json +1 -1
- package/src/client/ContextWindowPanel.module.css +23 -3
- package/src/client/ContextWindowPanel.tsx +42 -4
- package/src/client/SessionUsageView.tsx +6 -6
- package/src/client/StatsLineEnhanced.tsx +30 -22
- package/src/client/UsageCards.module.css +74 -0
- package/src/client/UsageCards.tsx +244 -0
- package/src/client/context-window-core.ts +39 -0
- package/src/client/session-usage-locales.ts +33 -3
- package/src/client/stats-line-core.ts +14 -5
- package/src/context-window.ts +15 -5
- package/src/session-detail.ts +3 -2
- package/src/usage-buckets.ts +27 -0
- package/src/usage-composition.ts +203 -0
- package/src/wire.ts +37 -0
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
|
|
1778
|
-
const name = String(
|
|
1779
|
-
if (name === "") continue;
|
|
1780
|
-
add("tools", name,
|
|
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;
|
package/lib/types/wire.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
-
.
|
|
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
|
-
<
|
|
165
|
-
<
|
|
166
|
-
|
|
167
|
-
|
|
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 {
|
|
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
|
|
210
|
-
request records:
|
|
211
|
-
|
|
212
|
-
per-turn detail. */}
|
|
213
|
-
|
|
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
|
}
|