pi-editor-footer 0.12.2 → 0.12.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/CHANGELOG.md CHANGED
@@ -1,3 +1,25 @@
1
+ # Changelog
2
+
3
+ ## [0.12.3](https://github.com/Rianico/pi-editor-footer/compare/v0.12.2...v0.12.3) (2026-09-17)
4
+
5
+ ### Bug Fixes
6
+
7
+ * **footer:** count total input tokens in footer statistics ([a2153a1](https://github.com/Rianico/pi-editor-footer/commit/a2153a1f0f4884e3879a610744a9e664a9124053))
8
+
9
+ ### Documentation
10
+
11
+ * **agents:** mark agent-run token accounting a scope boundary ([ffbbee2](https://github.com/Rianico/pi-editor-footer/commit/ffbbee29300a40d6963e0916c7526e0a1ce49881))
12
+ * document total input token semantics for the footer ([9480bf9](https://github.com/Rianico/pi-editor-footer/commit/9480bf9f1508930d75a5e53b94d944d00d1f6a2a))
13
+
14
+ <!-- markdownlint-configure-file { "MD004": { "style": "asterisk" } } -->
15
+ <!-- Bullets stay `*`: semantic-release's preset writes `*`, and this file is excluded from the
16
+ formatters so they cannot normalize them to `-`. Keep `# Changelog` as the FIRST line:
17
+ @semantic-release/changelog rewrites the title in place only while the file starts with
18
+ the configured `changelogTitle`, and prepends release notes above it otherwise. Do not
19
+ move this title below the comments, and do not remove the title. -->
20
+
21
+ All notable changes to this project will be documented in this file.
22
+
1
23
  ## [0.12.2](https://github.com/Rianico/pi-editor-footer/compare/v0.12.1...v0.12.2) (2026-09-13)
2
24
 
3
25
  ### Bug Fixes
@@ -24,10 +46,6 @@
24
46
  * allow pnpm builds for esbuild and genai ([36e139e](https://github.com/Rianico/pi-editor-footer/commit/36e139e9443ef928a4366c3d7c8afe494b6e0850))
25
47
  * make prepare tolerant to missing husky for pi install ([23ebb62](https://github.com/Rianico/pi-editor-footer/commit/23ebb62cd8d732656fd113da27c807ffa34579fc))
26
48
 
27
- # Changelog
28
-
29
- All notable changes to this project will be documented in this file.
30
-
31
49
  ## [0.11.0] - 2026-08-29
32
50
 
33
51
  ### Changed
package/README.md CHANGED
@@ -12,8 +12,8 @@ A [pi](https://pi.dev) extension that turns the editor chrome into a project-awa
12
12
 
13
13
  **Footer** — single line below the input, responsive via `fitSegmentsByPriority` and `alignRight`:
14
14
  - Left: `cwd` (`·` `git branch` + status `[! ? + ↑↓]` + stashed/conflicted) `•` `runtime` (`node`/`python`/`rust`/`go`… + version) `•` `timer` (`working`/`done`)
15
- - Right: `tokens` (` input | output |  $cost`) immediately next to `context` (` [bar] % · tokens/contextWindow`)
16
- - Separators: `·` between `cwd` and `git`, `•` as default between other left components; `tokens` sits directly left of the context bar
15
+ - Right: `tokens` (`↑ <total input> · <output> · $<cost>`) `↑` counts every billed input token (uncached input + cache read + cache write), so it tracks the context bar's token figure instead of `usage.input` alone
16
+ - Separators: `·` between `cwd` and `git`, `•` as default between other left components; the `context` bar (`<pct> · <tokens>/<window> | c <hit%>`) sits on the top border next to the model label, not in the footer
17
17
  - `cwd` respects `workspaceDisplay` (`~/development/ai/pi-skill-desc` vs `pi-skill-desc`, switchable in settings)
18
18
  - `context` bar uses `stressColor` and `renderBar` (12 cols max) with `•`/`·` handling
19
19
  - Extension statuses line (`wrapTextWithAnsi`) when `footerSegments.extensionStatuses`
@@ -16,11 +16,11 @@ const BASIC16 = [
16
16
  [0, 255, 255],
17
17
  [255, 255, 255],
18
18
  ];
19
- /** 6x6x6 colour-cube channel values (indices 0-5). */
19
+ /** 6x6x6 color-cube channel values (indices 0-5). */
20
20
  const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
21
21
  /** Grayscale ramp values (indices 232-255: 24 grays from 8 to 238). */
22
22
  const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);
23
- /** Anything but the truecolor marker means the 256-colour ramp. */
23
+ /** Anything but the truecolor marker means the 256-color ramp. */
24
24
  export function normalizeColorMode(mode) {
25
25
  return mode === "truecolor" ? "truecolor" : "256color";
26
26
  }
@@ -74,7 +74,7 @@ function colorDistance(r1, g1, b1, r2, g2, b2) {
74
74
  // Weighted Euclidean distance (the eye is more sensitive to green).
75
75
  return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;
76
76
  }
77
- /** Quantise RGB to the closest xterm-256 index (same rule as the theme loader). */
77
+ /** Quantize RGB to the closest xterm-256 index (same rule as the theme loader). */
78
78
  export function rgbTo256(r, g, b) {
79
79
  const rIdx = findClosestCubeIndex(r);
80
80
  const gIdx = findClosestCubeIndex(g);
@@ -90,7 +90,7 @@ export function rgbTo256(r, g, b) {
90
90
  const grayIndex = 232 + grayIdx;
91
91
  const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue);
92
92
  const spread = Math.max(r, g, b) - Math.min(r, g, b);
93
- // Only consider grayscale when the colour is nearly neutral AND closer.
93
+ // Only consider grayscale when the color is nearly neutral AND closer.
94
94
  if (spread < 10 && grayDist < cubeDist)
95
95
  return grayIndex;
96
96
  return cubeIndex;
@@ -126,14 +126,14 @@ export function parseFgAnsiToRgb(theme, color) {
126
126
  return indexToRgb(Number(palette[1]));
127
127
  return null;
128
128
  }
129
- /** SGR foreground sequence for an RGB value at the terminal's colour fidelity. */
129
+ /** SGR foreground sequence for an RGB value at the terminal's color fidelity. */
130
130
  export function rgbToFgAnsi({ r, g, b }, mode) {
131
131
  return normalizeColorMode(mode) === "truecolor"
132
132
  ? `\x1b[38;2;${r};${g};${b}m`
133
133
  : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
134
134
  }
135
135
  /**
136
- * Colour `s` with an exact hex, bypassing the theme's named-token lookup.
136
+ * Color `s` with an exact hex, bypassing the theme's named-token lookup.
137
137
  * Returned painter resets only the foreground (`\x1b[39m`), matching `theme.fg`.
138
138
  */
139
139
  export function hexFg(hex, mode) {
@@ -8,7 +8,7 @@
8
8
  * thus required touching 3-4 modules with no locality.
9
9
  *
10
10
  * Depth: one small interface (glyphs + isAscii + fg/dim/glow + format* helpers) hides
11
- * icon-mode resolution, the SAFETY theme cast, colour application, and the chrome format
11
+ * icon-mode resolution, the SAFETY theme cast, color application, and the chrome format
12
12
  * entry points (context bar, telemetry, tokens, run activity, stall). Callers learn one
13
13
  * shape; LiveBorder's islands become thin lookups.
14
14
  *
@@ -26,7 +26,7 @@ import { hexFg } from "./ansi-color.js";
26
26
  * Read the live pi theme into a typed { fg, fgHex } surface. The cast is a SAFETY
27
27
  * seam — a pi theme missing `fg` degrades to identity rather than throwing, keeping
28
28
  * the chrome resilient. `fgHex` needs no theme call at all: the named-token lookup
29
- * rejects raw hex, so hex is rendered straight to SGR at the theme's colour mode.
29
+ * rejects raw hex, so hex is rendered straight to SGR at the theme's color mode.
30
30
  */
31
31
  export function adaptTheme(rawTheme) {
32
32
  const t = rawTheme;
@@ -62,7 +62,7 @@ export class ChromeComposition {
62
62
  this.theme = adaptTheme(rawTheme);
63
63
  this.glow = opts.glow ?? resolveGlow(rawTheme);
64
64
  }
65
- /** Colour a string with a theme style (derived once, cast cached). */
65
+ /** Color a string with a theme style (derived once, cast cached). */
66
66
  fg(style, s) {
67
67
  return this.theme.fg(style, s);
68
68
  }
@@ -23,7 +23,7 @@ import { fmtTokens } from "./format.js";
23
23
  // ---------------------------------------------------------------------------
24
24
  /**
25
25
  * Painter for one context tier: exact hex when the live theme can emit raw hex,
26
- * else the nearest semantic token (tests/mocks, themes without colour mode).
26
+ * else the nearest semantic token (tests/mocks, themes without color mode).
27
27
  */
28
28
  function contextPainter(theme, tier) {
29
29
  const hex = CONTEXT_TIER_HEX[tier];
package/dist/footer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
- import { getUsageTotals } from "./state.js";
2
+ import { getUsageTotals, totalInputTokens } from "./state.js";
3
3
  import { runtimeSymbol } from "./icons.js";
4
4
  import { ChromeComposition } from "./chrome-composition.js";
5
5
  import { alignRight, fitSegmentsByPriority, } from "./layout.js";
@@ -141,7 +141,9 @@ export function renderFooter(width, state, config, theme, ctx) {
141
141
  }
142
142
  const stats = [];
143
143
  if (segments.tokens) {
144
- stats.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(totals.input)}`));
144
+ // Total input billed for the model — includes the cached prompt prefix, not just
145
+ // `usage.input` (which excludes cacheRead/cacheWrite). See totalInputTokens.
146
+ stats.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(totalInputTokens(totals))}`));
145
147
  stats.push(theme.fg("success", `${glyphs.output} ${fmtTokens(totals.output)}`));
146
148
  }
147
149
  if (segments.cost) {
@@ -6,7 +6,7 @@
6
6
  * TrackingEditor — which owns the editor slot for this extension — can keep
7
7
  * rendering the model label and thinking-level border glow that the original
8
8
  * widget provided. Self-contained apart from pi-tui width utils, the shared
9
- * ANSI quantiser (ansi-color.ts) and stripAnsi (format.ts).
9
+ * ANSI quantizer (ansi-color.ts) and stripAnsi (format.ts).
10
10
  *
11
11
  * The port is intentional: pi allows exactly ONE custom editor (last
12
12
  * `setEditorComponent` writer wins). pi-skill-desc must own the slot to track
@@ -41,7 +41,7 @@ const LABEL_PAD = 1;
41
41
  // ---------------------------------------------------------------------------
42
42
  // ---------------------------------------------------------------------------
43
43
  // Color helpers: theme token ANSI → RGB → boosted glow ANSI
44
- // (RGB/256 quantisation lives in ansi-color.ts, shared with the chrome tiers)
44
+ // (RGB/256 quantization lives in ansi-color.ts, shared with the chrome tiers)
45
45
  // ---------------------------------------------------------------------------
46
46
  /**
47
47
  * Build a border color function for a thinking level: takes the theme's
package/dist/state.js CHANGED
@@ -1,6 +1,15 @@
1
1
  import { emptyGitStatus } from "./git.js";
2
2
  import { fmtTokens } from "./format.js";
3
3
  import { formatProviderLabel } from "./format.js";
4
+ /**
5
+ * Total input tokens billed to the model: uncached input + cache read + cache write.
6
+ *
7
+ * pi-ai `Usage.input` excludes the cached prompt prefix (it lives in cacheRead/cacheWrite),
8
+ * so `input` alone under-reports the prompt by the whole cached history on every turn.
9
+ */
10
+ export function totalInputTokens(totals) {
11
+ return totals.input + totals.cacheRead + totals.cacheWrite;
12
+ }
4
13
  let usageCache;
5
14
  function entriesKey(ctx) {
6
15
  const entries = ctx.sessionManager?.getEntries() ?? [];
@@ -8,9 +17,7 @@ function entriesKey(ctx) {
8
17
  return `${entries.length}:${String(last?.id ?? "")}:${String(last?.timestamp ?? "")}`;
9
18
  }
10
19
  export function getUsageTotals(ctx) {
11
- const key = entriesKey(
12
- // SAFETY: pi seam — intentional unsafe cast, validated at runtime
13
- ctx);
20
+ const key = entriesKey(ctx);
14
21
  if (usageCache && usageCache.key === key)
15
22
  return usageCache.totals;
16
23
  const totals = {
@@ -21,23 +28,36 @@ export function getUsageTotals(ctx) {
21
28
  cost: 0,
22
29
  latestCacheHitRate: undefined,
23
30
  };
24
- const entries =
25
- // SAFETY: pi seam — intentional unsafe cast, validated at runtime
26
- ctx.sessionManager?.getEntries() ?? [];
27
- for (const entry of entries) {
31
+ const addUsage = (usage) => {
32
+ totals.input += usage.input ?? 0;
33
+ totals.output += usage.output ?? 0;
34
+ totals.cacheRead += usage.cacheRead ?? 0;
35
+ totals.cacheWrite += usage.cacheWrite ?? 0;
36
+ totals.cost += usage.cost?.total ?? 0;
37
+ };
38
+ for (const entry of ctx.sessionManager?.getEntries() ?? []) {
39
+ // Assistant turns carry the cache split the hit rate is derived from; tool results
40
+ // and summaries are usage from nested model calls — billed, so they count toward the
41
+ // session totals (parity with pi core's addUsageToTotals loop).
28
42
  if (entry.type === "message" && entry.message?.role === "assistant") {
29
- const u = entry.message.usage;
30
- if (!u)
43
+ const usage = entry.message.usage;
44
+ if (!usage)
31
45
  continue;
32
- totals.input += u.input ?? 0;
33
- totals.output += u.output ?? 0;
34
- totals.cacheRead += u.cacheRead ?? 0;
35
- totals.cacheWrite += u.cacheWrite ?? 0;
36
- totals.cost += u.cost?.total ?? 0;
37
- const promptTokens = (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
46
+ addUsage(usage);
47
+ const promptTokens = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
38
48
  if (promptTokens > 0) {
39
- totals.latestCacheHitRate = ((u.cacheRead ?? 0) / promptTokens) * 100;
49
+ totals.latestCacheHitRate = ((usage.cacheRead ?? 0) / promptTokens) * 100;
40
50
  }
51
+ continue;
52
+ }
53
+ if (entry.type === "message" && entry.message?.role === "toolResult") {
54
+ if (entry.message.usage)
55
+ addUsage(entry.message.usage);
56
+ continue;
57
+ }
58
+ if (entry.type === "branch_summary" || entry.type === "compaction") {
59
+ if (entry.usage)
60
+ addUsage(entry.usage);
41
61
  }
42
62
  }
43
63
  usageCache = { key, totals };
package/package.json CHANGED
@@ -33,7 +33,7 @@
33
33
  "@earendil-works/pi-coding-agent": "*",
34
34
  "@earendil-works/pi-tui": "*"
35
35
  },
36
- "version": "0.12.2",
36
+ "version": "0.12.3",
37
37
  "files": [
38
38
  "dist",
39
39
  "src",
package/src/footer.ts CHANGED
@@ -2,8 +2,8 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works
2
2
  import type { ThemeConfig } from "./config.js";
3
3
  import type { GitStatus } from "./git.js";
4
4
  import type { RuntimeInfo } from "./runtime.js";
5
- import type { FooterState, ModelMeta, UsageTotals } from "./state.js";
6
- import { getUsageTotals } from "./state.js";
5
+ import type { FooterState, ModelMeta, UsageTotals, UsageTotalsSource } from "./state.js";
6
+ import { getUsageTotals, totalInputTokens } from "./state.js";
7
7
  import type { IconGlyphs } from "./icons.js";
8
8
  import { runtimeSymbol } from "./icons.js";
9
9
  import { ChromeComposition } from "./chrome-composition.js";
@@ -191,7 +191,9 @@ export function renderFooter(
191
191
 
192
192
  const stats: string[] = [];
193
193
  if (segments.tokens) {
194
- stats.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(totals.input)}`));
194
+ // Total input billed for the model — includes the cached prompt prefix, not just
195
+ // `usage.input` (which excludes cacheRead/cacheWrite). See totalInputTokens.
196
+ stats.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(totalInputTokens(totals))}`));
195
197
  stats.push(theme.fg("success", `${glyphs.output} ${fmtTokens(totals.output)}`));
196
198
  }
197
199
  if (segments.cost) {
@@ -273,24 +275,7 @@ export function installFooter(
273
275
  const totals = getUsageTotals(
274
276
  // ast-grep-ignore: require-safety-comment-for-as-unknown-as
275
277
  // SAFETY: pi seam — intentional unsafe cast, validated at runtime
276
- /* SAFETY: intentional unsafe cast — validated at runtime */ ctx as unknown as {
277
- // SAFETY: intentional unsafe cast — validated at runtime
278
- sessionManager?: {
279
- getEntries(): {
280
- type: string;
281
- message?: {
282
- role: string;
283
- usage?: {
284
- input?: number;
285
- output?: number;
286
- cacheRead?: number;
287
- cacheWrite?: number;
288
- cost?: { total?: number };
289
- };
290
- };
291
- }[];
292
- };
293
- },
278
+ /* SAFETY: intentional unsafe cast — validated at runtime */ ctx as unknown as UsageTotalsSource,
294
279
  );
295
280
  return renderFooter(width, state, config, themeStub, {
296
281
  cwd,
@@ -357,24 +342,7 @@ export function installFooter(
357
342
  const totals = getUsageTotals(
358
343
  // ast-grep-ignore: require-safety-comment-for-as-unknown-as
359
344
  // SAFETY: pi seam — intentional unsafe cast, validated at runtime
360
- /* SAFETY: intentional unsafe cast — validated at runtime */ ctx as unknown as {
361
- // SAFETY: intentional unsafe cast — validated at runtime
362
- sessionManager?: {
363
- getEntries(): {
364
- type: string;
365
- message?: {
366
- role: string;
367
- usage?: {
368
- input?: number;
369
- output?: number;
370
- cacheRead?: number;
371
- cacheWrite?: number;
372
- cost?: { total?: number };
373
- };
374
- };
375
- }[];
376
- };
377
- },
345
+ /* SAFETY: intentional unsafe cast — validated at runtime */ ctx as unknown as UsageTotalsSource,
378
346
  );
379
347
  return renderFooter(width, state, config, theme, {
380
348
  cwd,
package/src/state.ts CHANGED
@@ -21,35 +21,55 @@ export interface UsageTotals {
21
21
  latestCacheHitRate: number | undefined;
22
22
  }
23
23
 
24
+ /** Raw provider usage block as persisted on a session entry. */
25
+ export interface EntryUsage {
26
+ input?: number;
27
+ output?: number;
28
+ cacheRead?: number;
29
+ cacheWrite?: number;
30
+ cost?: { total?: number };
31
+ }
32
+
33
+ /**
34
+ * Session entry slice read by getUsageTotals — the pi seam, validated at runtime.
35
+ * `usage` sits at the top level on branch_summary / compaction entries and inside
36
+ * `message` on message entries.
37
+ */
38
+ export interface UsageSessionEntry {
39
+ type: string;
40
+ id?: string;
41
+ timestamp?: string;
42
+ message?: { role: string; usage?: EntryUsage };
43
+ usage?: EntryUsage;
44
+ }
45
+
46
+ /** Minimal session-manager seam getUsageTotals reads entries through. */
47
+ export interface UsageTotalsSource {
48
+ sessionManager?: { getEntries(): UsageSessionEntry[] };
49
+ }
50
+
51
+ /**
52
+ * Total input tokens billed to the model: uncached input + cache read + cache write.
53
+ *
54
+ * pi-ai `Usage.input` excludes the cached prompt prefix (it lives in cacheRead/cacheWrite),
55
+ * so `input` alone under-reports the prompt by the whole cached history on every turn.
56
+ */
57
+ export function totalInputTokens(
58
+ totals: Pick<UsageTotals, "input" | "cacheRead" | "cacheWrite">,
59
+ ): number {
60
+ return totals.input + totals.cacheRead + totals.cacheWrite;
61
+ }
62
+
24
63
  let usageCache: { key: string; totals: UsageTotals } | undefined;
25
64
 
26
- function entriesKey(ctx: { sessionManager?: { getEntries(): unknown[] } }): string {
65
+ function entriesKey(ctx: UsageTotalsSource): string {
27
66
  const entries = ctx.sessionManager?.getEntries() ?? [];
28
- const last = entries.at(-1) as { id?: string; timestamp?: string } | undefined;
67
+ const last = entries.at(-1);
29
68
  return `${entries.length}:${String(last?.id ?? "")}:${String(last?.timestamp ?? "")}`;
30
69
  }
31
70
 
32
- export function getUsageTotals(ctx: {
33
- sessionManager?: {
34
- getEntries(): {
35
- type: string;
36
- message?: {
37
- role: string;
38
- usage?: {
39
- input?: number;
40
- output?: number;
41
- cacheRead?: number;
42
- cacheWrite?: number;
43
- cost?: { total?: number };
44
- };
45
- };
46
- }[];
47
- };
48
- }): UsageTotals {
49
- const key = entriesKey(
50
- // SAFETY: pi seam — intentional unsafe cast, validated at runtime
51
- ctx as unknown as { sessionManager?: { getEntries(): unknown[] } },
52
- );
71
+ export function getUsageTotals(ctx: UsageTotalsSource): UsageTotals {
72
+ const key = entriesKey(ctx);
53
73
  if (usageCache && usageCache.key === key) return usageCache.totals;
54
74
 
55
75
  const totals: UsageTotals = {
@@ -60,40 +80,33 @@ export function getUsageTotals(ctx: {
60
80
  cost: 0,
61
81
  latestCacheHitRate: undefined,
62
82
  };
63
- const entries =
64
- // SAFETY: pi seam — intentional unsafe cast, validated at runtime
65
- (
66
- ctx as unknown as {
67
- sessionManager?: {
68
- getEntries(): {
69
- type: string;
70
- message?: {
71
- role: string;
72
- usage?: {
73
- input?: number;
74
- output?: number;
75
- cacheRead?: number;
76
- cacheWrite?: number;
77
- cost?: { total?: number };
78
- };
79
- };
80
- }[];
81
- };
82
- }
83
- ).sessionManager?.getEntries() ?? [];
84
- for (const entry of entries) {
83
+ const addUsage = (usage: EntryUsage): void => {
84
+ totals.input += usage.input ?? 0;
85
+ totals.output += usage.output ?? 0;
86
+ totals.cacheRead += usage.cacheRead ?? 0;
87
+ totals.cacheWrite += usage.cacheWrite ?? 0;
88
+ totals.cost += usage.cost?.total ?? 0;
89
+ };
90
+ for (const entry of ctx.sessionManager?.getEntries() ?? []) {
91
+ // Assistant turns carry the cache split the hit rate is derived from; tool results
92
+ // and summaries are usage from nested model calls — billed, so they count toward the
93
+ // session totals (parity with pi core's addUsageToTotals loop).
85
94
  if (entry.type === "message" && entry.message?.role === "assistant") {
86
- const u = entry.message.usage;
87
- if (!u) continue;
88
- totals.input += u.input ?? 0;
89
- totals.output += u.output ?? 0;
90
- totals.cacheRead += u.cacheRead ?? 0;
91
- totals.cacheWrite += u.cacheWrite ?? 0;
92
- totals.cost += u.cost?.total ?? 0;
93
- const promptTokens = (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
95
+ const usage = entry.message.usage;
96
+ if (!usage) continue;
97
+ addUsage(usage);
98
+ const promptTokens = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
94
99
  if (promptTokens > 0) {
95
- totals.latestCacheHitRate = ((u.cacheRead ?? 0) / promptTokens) * 100;
100
+ totals.latestCacheHitRate = ((usage.cacheRead ?? 0) / promptTokens) * 100;
96
101
  }
102
+ continue;
103
+ }
104
+ if (entry.type === "message" && entry.message?.role === "toolResult") {
105
+ if (entry.message.usage) addUsage(entry.message.usage);
106
+ continue;
107
+ }
108
+ if (entry.type === "branch_summary" || entry.type === "compaction") {
109
+ if (entry.usage) addUsage(entry.usage);
97
110
  }
98
111
  }
99
112
  usageCache = { key, totals };