pi-zentui 0.14.0 → 0.15.1

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 CHANGED
@@ -130,7 +130,16 @@ pi install git:github.com/lmilojevicc/pi-zentui
130
130
 
131
131
  User config lives at `~/.pi/agent/zentui.json`. The file is optional: missing or invalid known values fall back to Zentui defaults, unknown keys are ignored at runtime, and `/zentui` can patch color-source settings, UI feature toggles, built-in footer segment visibility, and active third-party status placements.
132
132
 
133
- The interactive `/zentui` menu is split into five sections. Use `Tab` and `Shift+Tab` to switch between `Coloring`, `Features`, `Layout`, `Built-in segments`, and `Extension segments`.
133
+ The interactive `/zentui` menu is split into exactly six sections, in this order. Use `Tab` and `Shift+Tab` to switch sections. Every listed control patches the shown JSON equivalent:
134
+
135
+ 1. **Appearance** — Starship/footer colors (`colorSources.starship`); editor + previous-message colors (`colorSources.editor` and `colorSources.userMessages`); separator (`separator`); icon mode (`icons.mode`).
136
+ 2. **Editor** — editor enabled (`features.editor`); editor model label (`editorModelLabel`); copy-friendly mode (`features.copyFriendly`); viewport indicators (`features.viewportIndicators`); fixed editor (`fixedEditor.enabled`); and, while fixed editor is enabled, mouse scroll (`fixedEditor.mouseScroll`) and copy notice (`fixedEditor.copyNotice`).
137
+ 3. **Footer** — status line enabled (`features.statusLine`); responsive footer (`responsiveFooter`); compact footer rows (`compactFooterMaxLines`); context style (`contextStyle`); path display (`pathDisplay.mode`); path depth (`pathDisplay.depth`).
138
+ 4. **Segments** — visibility toggles for every non-Git built-in segment under `footerSegments`: `cwd`, `sessionName`, `runtime`, `context`, `tokens`, `cost`, `sessionDuration`, `username`, `time`, `os`, and `packageVersion`.
139
+ 5. **Git** — Git branch visibility (`footerSegments.gitBranch`); branch length (`gitBranch.maxLength`); Git status visibility (`footerSegments.gitStatus`); Git counts visibility (`footerSegments.gitCounts`); Git commit visibility (`footerSegments.gitCommit`); commit-only-detached (`gitCommit.onlyDetached`); exact-match tag (`gitCommit.showTag`); Git metrics visibility (`footerSegments.gitMetrics`); hide zero metrics (`gitMetrics.onlyNonzero`); ignore submodules (`gitMetrics.ignoreSubmodules`).
140
+ 6. **Extensions** — default placement (`extensionStatuses.defaultPlacement`) first, followed by placement (`extensionStatuses.placements[key]`) and color (`extensionStatuses.colorModes[key]`) controls for currently active status keys.
141
+
142
+ Free-form values such as custom formats, raw colors/styles, numeric values outside the shown presets, and inactive extension keys remain JSON-only.
134
143
 
135
144
  Useful slash-command shortcuts:
136
145
 
@@ -144,6 +153,9 @@ Useful slash-command shortcuts:
144
153
  /zentui copy-friendly enable
145
154
  /zentui copy-friendly disable
146
155
  /zentui copy-friendly toggle
156
+ /zentui viewport-indicators enable
157
+ /zentui viewport-indicators disable
158
+ /zentui viewport-indicators toggle
147
159
  /zentui fixed-editor enable
148
160
  /zentui fixed-editor disable
149
161
  /zentui fixed-editor toggle
@@ -238,7 +250,8 @@ Default config values — copy this and change any value you want:
238
250
  "features": {
239
251
  "editor": true,
240
252
  "statusLine": true,
241
- "copyFriendly": false
253
+ "copyFriendly": false,
254
+ "viewportIndicators": true
242
255
  },
243
256
  "footerSegments": {
244
257
  "cwd": true,
@@ -285,21 +298,21 @@ Default config values — copy this and change any value you want:
285
298
  - `contextStyle`: `text` (default), `gauge`, or `text+gauge` for the context segment. Context usage refreshes during assistant streaming; token and cost totals remain canonical and finalize at turn boundaries.
286
299
  - `editorModelLabel`: controls the model shown in the editor frame. `id` (default) shows the model id; `name` shows the model's display name (including custom `name` values set in `models.json`), falling back to the id when no name is set.
287
300
  - `editorMetadataFormat`: JSON-only template for the left side of the editor metadata row. Missing, non-string, or empty values restore the default `$model $provider( $thinking)` layout; non-empty strings, including whitespace-only strings, are preserved. See [Editor Metadata Format](#editor-metadata-format) below.
288
- - `separator`: controls the default footer layout and extension-status connectors: `pipe` (default, ` | `), `dot` (` · `), `chevron` (` › `), or `none` (one space). Cycle it from the `/zentui` **Layout** tab. This selects the separator glyph; `colors.separator` controls its color. Custom `footerFormat` literals and `$sep` keep their existing behavior.
301
+ - `separator`: controls the default footer layout and extension-status connectors: `pipe` (default, ` | `), `dot` (` · `), `chevron` (` › `), or `none` (one space). Cycle it from the `/zentui` **Appearance** tab. This selects the separator glyph; `colors.separator` controls its color. Custom `footerFormat` literals and `$sep` keep their existing behavior.
289
302
  - `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
290
- - `pathDisplay`: controls how the cwd/`$cwd` path is shown. `mode` is `basename` (default, last segment only) or `full` (path with home contracted to `~`). In `full` mode, `depth` keeps only the last N trailing directories (`0` = entire path after `~`, max `5`); when parents are dropped the path is prefixed with `…/` (Starship-style). The `/zentui` **Layout** tab cycles path mode and path depth (`0`–`5`; depth is ignored for basename). Example: `~/Projects/foo/bar` with `depth: 2` → `…/foo/bar`.
291
- - `gitBranch.maxLength`: visible width of the built-in branch name and `$git_branch` / `$branch`. The default `full` preserves the complete name; any positive integer uses that width including the trailing `…`. `/zentui` **Layout** cycles `full`, `10`, `20`, `30`, `40`, and `50`; custom positive integers can be set in JSON.
303
+ - `pathDisplay`: controls how the cwd/`$cwd` path is shown. `mode` is `basename` (default, last segment only) or `full` (path with home contracted to `~`). In `full` mode, `depth` keeps only the last N trailing directories (`0` = entire path after `~`, max `5`); when parents are dropped the path is prefixed with `…/` (Starship-style). The `/zentui` **Footer** tab cycles path mode and path depth (`0`–`5`; depth is ignored for basename). Example: `~/Projects/foo/bar` with `depth: 2` → `…/foo/bar`.
304
+ - `gitBranch.maxLength`: visible width of the built-in branch name and `$git_branch` / `$branch`. The default `full` preserves the complete name; any positive integer uses that width including the trailing `…`. `/zentui` **Git** cycles `full`, `10`, `20`, `30`, `40`, and `50`; custom positive integers can be set in JSON.
292
305
  - `icons`: every shown icon key is configurable; omit any key to use the Zentui default. `icons.mode` is `auto` | `nerd` | `ascii` (default `auto`, same glyphs as nerd). ASCII mode swaps in plain fallbacks for statusline icons and runtime symbols — useful without a Nerd Font. Custom per-icon strings always win over mode defaults. Custom `icons.os` always wins; when left at the mode default, Zentui maps the OS icon by platform. `rail` sets the vertical glyph drawn as the left rail of the active editor frame and previous user messages when `copyFriendly` is disabled (default `│`; any single Unicode vertical or block glyph). `editorPrompt` controls an optional copy-friendly editor prompt glyph; the default is `""` so copy-friendly mode stays rail-free.
293
306
  - `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
294
- - `features`: `editor` enables Zentui's custom editor, selector borders, and previous-message chrome. `statusLine` enables Zentui's custom footer/status line. `copyFriendly` hides editor and previous-message rail glyphs so native terminal selection copies less chrome. All three can be changed from `/zentui` or direct slash-command arguments.
295
- - `footerSegments`: show or hide individual built-in footer segments (`cwd`, `sessionName`, `gitBranch`, `gitStatus`, `gitCounts`, `gitCommit`, `gitMetrics`, `runtime`, `packageVersion`, `sessionDuration`, `username`, `time`, `os`, `context`, `tokens`, `cost`). Toggle them from the `Built-in segments` tab in `/zentui`.
296
- - `footerFormat`: optional Starship-style template string that fully controls the footer layout. When set, it overrides `footerSegments`. See [Footer Format Template](#footer-format-template) below. The `/zentui` **Layout** tab configures responsive behavior, compact rows, context style, separator, path display mode/depth, branch length, and icon mode; set or clear custom formats with `/zentui format`.
307
+ - `features`: `editor` enables Zentui's custom editor, selector borders, and previous-message chrome. `statusLine` enables Zentui's custom footer/status line. `copyFriendly` hides editor and previous-message rail glyphs so native terminal selection copies less chrome. `viewportIndicators` preserves Pi's native `↑ N more` / `↓ N more` wrapped-row counts in Zentui's editor borders (default `true`). All four can be changed from `/zentui` or direct slash-command arguments.
308
+ - `footerSegments`: show or hide individual built-in footer segments (`cwd`, `sessionName`, `gitBranch`, `gitStatus`, `gitCounts`, `gitCommit`, `gitMetrics`, `runtime`, `packageVersion`, `sessionDuration`, `username`, `time`, `os`, `context`, `tokens`, `cost`). Toggle non-Git segments from **Segments** and Git segments from **Git** in `/zentui`.
309
+ - `footerFormat`: optional Starship-style template string that fully controls the footer layout. When set, it overrides `footerSegments`. See [Footer Format Template](#footer-format-template) below. The `/zentui` **Footer** tab configures responsive behavior, compact rows, context style, and path display mode/depth; **Appearance** configures separator and icon mode; **Git** configures branch length; set or clear custom formats with `/zentui format`.
297
310
  - `responsiveFooter`: enabled by default. Zentui keeps the current aligned one-row footer while every settings-resolved left/middle/right zone fits without layout truncation. Otherwise it tries two complete left-aligned rows, preferring `left` / `middle right` and then `left middle` / `right`. Only when neither split fits does it use `compactFooterFormat`. Set `false` to restore the legacy one-row fitting behavior. Selection uses measured terminal-cell width, not fixed device breakpoints.
298
311
  - `compactFooterFormat`: JSON-only template used by the compact stage. The default keeps cwd, session name, git branch/status, context, and abbreviated token/cache metrics. A top-level `$wrap` is an automatic wrap opportunity: one space on the same row or no space at a row break. `$wrap_sep` is the same kind of boundary but renders the styled ` | ` divider only when its adjacent chunks share a row. Nested uses of either boundary remain empty variables. `$fill` is ignored. A standalone `$extensions` chunk inserts active non-`off` extension statuses in left/middle/right placement order; embedded uses render empty. Custom `footerFormat` values use this built-in compact fallback unless this key is also customized.
299
312
  - `compactFooterMaxLines`: `1`, `2`, `3`, or `"unlimited"` (default `2`). Finite limits crop remaining chunks with exactly one trailing `…`. Compact cwd always uses basename mode; cwd/session/branch chunks target half the available row width before final ANSI-aware clamping. `/zentui` exposes the responsive toggle and row limit, while compact format editing remains JSON-only. Pi supplies footer width but no supported viewport-height budget, so `"unlimited"` is explicit.
300
313
  - `gitCommit`: Starship [`git_commit`](https://starship.rs/config/#git-commit)-style options for the `gitCommit` footer segment. `hashLength` (default `7`, clamped to `4`–`40`) controls the short-hash display length. `onlyDetached` (default `true`) shows the hash mainly on detached HEAD. `showTag` (default `true`) appends an exact-match tag (`git describe --tags --exact-match HEAD`). The tag probe piggybacks on the existing git refresh — it only runs when both the segment and `showTag` are on, and misses/failures degrade silently.
301
314
  - `gitMetrics`: Starship [`git_metrics`](https://starship.rs/config/#git-metrics)-style options for the `gitMetrics` footer segment. Uses `git diff HEAD --numstat` (staged + unstaged combined — the Starship “total dirty” view) to show aggregate `+added −deleted` line counts. `onlyNonzero` (default `true`) omits each zero component independently and hides the segment entirely at `0/0`. `ignoreSubmodules` (default `false`) adds `--ignore-submodules=all`. The numstat diff piggybacks on the existing git refresh and uses a hard 2s timeout; a metrics-only failure degrades silently without discarding fresh branch/status data. On very large monorepos the diff may lag or be omitted on timeout.
302
- - `extensionStatuses`: controls third-party statuses published by other Pi extensions through `ctx.ui.setStatus()`. `defaultPlacement` and each `placements` value can be `off`, `left`, `middle`, or `right`. The `Extension segments` tab in `/zentui` lists only statuses that are currently active.
315
+ - `extensionStatuses`: controls third-party statuses published by other Pi extensions through `ctx.ui.setStatus()`. `defaultPlacement` and each `placements` value can be `off`, `left`, `middle`, or `right`. The **Extensions** tab in `/zentui` lists only statuses that are currently active. `defaultPlacement` applies only when a status key has no entry in `placements`; keyed overrides always win.
303
316
  - The shown `editor*` values match the default `theme` source. Omit those keys to keep Zentui's source-aware defaults when switching between `theme` and `terminal`.
304
317
  - `editorAccent` styles the active editor rail and previous user-message rail when `features.copyFriendly` is disabled.
305
318
  - `editorPrompt` styles the copy-friendly editor prompt glyph. Omit it to use `editorAccent`, then the default accent fallback.
@@ -67,6 +67,7 @@ export type UiFeaturesConfig = {
67
67
  editor: boolean;
68
68
  statusLine: boolean;
69
69
  copyFriendly: boolean;
70
+ viewportIndicators: boolean;
70
71
  };
71
72
 
72
73
  export type FooterSegmentsConfig = {
@@ -277,6 +278,7 @@ export const defaultConfig: PolishedTuiConfig = {
277
278
  editor: true,
278
279
  statusLine: true,
279
280
  copyFriendly: false,
281
+ viewportIndicators: true,
280
282
  },
281
283
  footerSegments: {
282
284
  cwd: true,
@@ -507,6 +509,7 @@ function normalizeUiFeatures(record: Record<string, unknown>): UiFeaturesConfig
507
509
  editor: booleanValue(record, "editor"),
508
510
  statusLine: booleanValue(record, "statusLine"),
509
511
  copyFriendly: booleanValue(record, "copyFriendly"),
512
+ viewportIndicators: booleanValue(record, "viewportIndicators"),
510
513
  };
511
514
  }
512
515
 
@@ -619,7 +622,12 @@ function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
619
622
  }
620
623
 
621
624
  function isUiFeatureKey(value: string): value is keyof UiFeaturesConfig {
622
- return value === "editor" || value === "statusLine" || value === "copyFriendly";
625
+ return (
626
+ value === "editor" ||
627
+ value === "statusLine" ||
628
+ value === "copyFriendly" ||
629
+ value === "viewportIndicators"
630
+ );
623
631
  }
624
632
 
625
633
  function isFooterSegmentKey(value: string): value is keyof FooterSegmentsConfig {
@@ -990,6 +998,62 @@ export function saveGitBranchPatch(
990
998
  });
991
999
  }
992
1000
 
1001
+ export function saveEditorModelLabel(
1002
+ value: ModelLabelSource,
1003
+ path = configPath,
1004
+ ): PolishedTuiConfig {
1005
+ return mutateConfig(path, (record) => {
1006
+ record.editorModelLabel = parseEditorModelLabel(value);
1007
+ });
1008
+ }
1009
+
1010
+ export function saveGitCommitPatch(
1011
+ patch: Partial<Pick<GitCommitConfig, "onlyDetached" | "showTag">>,
1012
+ path = configPath,
1013
+ ): PolishedTuiConfig {
1014
+ return mutateConfig(path, (record) => {
1015
+ const existing = isRecord(record.gitCommit)
1016
+ ? { ...(record.gitCommit as Record<string, unknown>) }
1017
+ : {};
1018
+ if (typeof patch.onlyDetached === "boolean") existing.onlyDetached = patch.onlyDetached;
1019
+ if (typeof patch.showTag === "boolean") existing.showTag = patch.showTag;
1020
+ record.gitCommit = existing;
1021
+ });
1022
+ }
1023
+
1024
+ export function saveGitMetricsPatch(
1025
+ patch: Partial<GitMetricsConfig>,
1026
+ path = configPath,
1027
+ ): PolishedTuiConfig {
1028
+ return mutateConfig(path, (record) => {
1029
+ const existing = isRecord(record.gitMetrics)
1030
+ ? { ...(record.gitMetrics as Record<string, unknown>) }
1031
+ : {};
1032
+ if (typeof patch.onlyNonzero === "boolean") existing.onlyNonzero = patch.onlyNonzero;
1033
+ if (typeof patch.ignoreSubmodules === "boolean") {
1034
+ existing.ignoreSubmodules = patch.ignoreSubmodules;
1035
+ }
1036
+ record.gitMetrics = existing;
1037
+ });
1038
+ }
1039
+
1040
+ export function saveExtensionStatusDefaultPlacement(
1041
+ placement: ExtensionStatusPlacement,
1042
+ path = configPath,
1043
+ ): PolishedTuiConfig {
1044
+ return mutateConfig(path, (record) => {
1045
+ const existing = isRecord(record.extensionStatuses)
1046
+ ? { ...(record.extensionStatuses as Record<string, unknown>) }
1047
+ : {};
1048
+ record.extensionStatuses = {
1049
+ ...existing,
1050
+ defaultPlacement: isExtensionStatusPlacement(placement)
1051
+ ? placement
1052
+ : defaultConfig.extensionStatuses.defaultPlacement,
1053
+ };
1054
+ });
1055
+ }
1056
+
993
1057
  export function saveExtensionStatusPlacement(
994
1058
  key: string,
995
1059
  placement: ExtensionStatusPlacement,
@@ -0,0 +1,73 @@
1
+ export type EditorComponentFactory = (...args: never[]) => unknown;
2
+
3
+ export type EditorTransferUi<Factory = EditorComponentFactory> = {
4
+ getEditorText?: () => unknown;
5
+ setEditorText?: (text: string) => void;
6
+ setEditorComponent?: (factory: Factory | undefined) => void;
7
+ getEditorComponent?: () => Factory | undefined;
8
+ };
9
+
10
+ export type EditorTransferFailureReason =
11
+ | "unsupported-transfer-api"
12
+ | "editor-factory-snapshot-failed"
13
+ | "editor-text-snapshot-failed"
14
+ | "editor-text-preparation-failed"
15
+ | "editor-replacement-failed-with-rollback"
16
+ | "editor-replacement-rollback-failed";
17
+
18
+ export type EditorTransferResult =
19
+ | { ok: true }
20
+ | { ok: false; reason: EditorTransferFailureReason };
21
+
22
+ /**
23
+ * Prepare the active editor's public expanded text before Pi replaces its factory.
24
+ * This intentionally transfers prompt contents only; editor-private state is never inspected.
25
+ */
26
+ export function replaceEditorComponentWithExpandedText<Factory>(
27
+ ui: EditorTransferUi<Factory>,
28
+ factory: Factory | undefined,
29
+ ): EditorTransferResult {
30
+ if (
31
+ typeof ui.getEditorText !== "function" ||
32
+ typeof ui.setEditorText !== "function" ||
33
+ typeof ui.setEditorComponent !== "function" ||
34
+ typeof ui.getEditorComponent !== "function"
35
+ ) {
36
+ return { ok: false, reason: "unsupported-transfer-api" };
37
+ }
38
+
39
+ let previousFactory: Factory | undefined;
40
+ try {
41
+ previousFactory = ui.getEditorComponent();
42
+ } catch {
43
+ return { ok: false, reason: "editor-factory-snapshot-failed" };
44
+ }
45
+
46
+ let expandedText: unknown;
47
+ try {
48
+ expandedText = ui.getEditorText();
49
+ } catch {
50
+ return { ok: false, reason: "editor-text-snapshot-failed" };
51
+ }
52
+ if (typeof expandedText !== "string") {
53
+ return { ok: false, reason: "editor-text-snapshot-failed" };
54
+ }
55
+
56
+ try {
57
+ ui.setEditorText(expandedText);
58
+ } catch {
59
+ return { ok: false, reason: "editor-text-preparation-failed" };
60
+ }
61
+
62
+ try {
63
+ ui.setEditorComponent(factory);
64
+ } catch {
65
+ try {
66
+ ui.setEditorComponent(previousFactory);
67
+ return { ok: false, reason: "editor-replacement-failed-with-rollback" };
68
+ } catch {
69
+ return { ok: false, reason: "editor-replacement-rollback-failed" };
70
+ }
71
+ }
72
+ return { ok: true };
73
+ }
@@ -1,5 +1,4 @@
1
1
  import { homedir, hostname, userInfo } from "node:os";
2
- import type { AssistantMessage } from "@earendil-works/pi-ai";
3
2
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
4
3
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
4
  import type {
@@ -81,23 +80,40 @@ export type UsageTotals = {
81
80
 
82
81
  export type ContextColorTier = "normal" | "warning" | "error";
83
82
 
83
+ type SessionUsage = {
84
+ input?: unknown;
85
+ output?: unknown;
86
+ cacheRead?: unknown;
87
+ cacheWrite?: unknown;
88
+ cost?: unknown;
89
+ };
90
+
84
91
  type SessionEntry = {
85
92
  type?: string;
86
93
  id?: string | number;
87
94
  timestamp?: string | number;
95
+ usage?: SessionUsage;
88
96
  message?: {
89
97
  role?: string;
90
- usage?: AssistantMessage["usage"];
98
+ usage?: SessionUsage;
91
99
  };
92
100
  };
93
101
 
102
+ type SelectedUsage = {
103
+ usage: SessionUsage | undefined;
104
+ location: "message" | "entry";
105
+ isAssistant: boolean;
106
+ };
107
+
94
108
  type UsageCacheEntry = {
95
109
  key: string;
96
110
  totals: UsageTotals;
97
111
  };
98
112
 
113
+ const MAX_USAGE_TOTAL = Number.MAX_VALUE;
114
+
99
115
  let usageTotalsCache: UsageCacheEntry | undefined;
100
- let usageTotalsComputeCount = 0;
116
+ let usageTotalsAggregationPassCount = 0;
101
117
 
102
118
  export function formatCount(value: number): string {
103
119
  if (value < 1000) return value.toString();
@@ -130,26 +146,78 @@ function calculateCacheHitRate(
130
146
  cacheWrite: number,
131
147
  ): number | undefined {
132
148
  const promptTokens = input + cacheRead + cacheWrite;
133
- return promptTokens > 0 ? (cacheRead / promptTokens) * 100 : undefined;
149
+ if (promptTokens === 0) return undefined;
150
+ if (Number.isFinite(promptTokens)) return (cacheRead / promptTokens) * 100;
151
+
152
+ const scale = Math.max(input, cacheRead, cacheWrite);
153
+ const scaledPromptTokens = input / scale + cacheRead / scale + cacheWrite / scale;
154
+ return (cacheRead / scale / scaledPromptTokens) * 100;
155
+ }
156
+
157
+ function normalizeUsageNumber(value: unknown): number {
158
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
159
+ }
160
+
161
+ function usageCostTotal(usage: SessionUsage | undefined): number {
162
+ if (typeof usage?.cost !== "object" || usage.cost === null) return 0;
163
+ return normalizeUsageNumber((usage.cost as { total?: unknown }).total);
164
+ }
165
+
166
+ function addUsageTotal(total: number, value: number): number {
167
+ const sum = total + value;
168
+ return Number.isFinite(sum) ? sum : MAX_USAGE_TOTAL;
169
+ }
170
+
171
+ function usageForEntry(entry: SessionEntry): SelectedUsage | undefined {
172
+ if (entry.type === "message") {
173
+ const role = entry.message?.role;
174
+ if (role !== "assistant" && role !== "toolResult") return undefined;
175
+ return {
176
+ usage: entry.message?.usage,
177
+ location: "message",
178
+ isAssistant: role === "assistant",
179
+ };
180
+ }
181
+ if (entry.type === "compaction" || entry.type === "branch_summary") {
182
+ return { usage: entry.usage, location: "entry", isAssistant: false };
183
+ }
184
+ return undefined;
185
+ }
186
+
187
+ function normalizedUsage(usage: SessionUsage | undefined) {
188
+ return {
189
+ input: normalizeUsageNumber(usage?.input),
190
+ output: normalizeUsageNumber(usage?.output),
191
+ cacheRead: normalizeUsageNumber(usage?.cacheRead),
192
+ cacheWrite: normalizeUsageNumber(usage?.cacheWrite),
193
+ cost: usageCostTotal(usage),
194
+ };
134
195
  }
135
196
 
136
- function entryIdentity(entry: SessionEntry | undefined): string {
137
- if (!entry) return "";
138
- const usage = entry.message?.usage;
139
- const usageKey = usage
140
- ? `${usage.input ?? 0}:${usage.output ?? 0}:${usage.cacheRead ?? 0}:${usage.cacheWrite ?? 0}:${usage.cost?.total ?? 0}`
141
- : "";
142
- return `${entry.id ?? ""}|${entry.timestamp ?? ""}|${entry.type ?? ""}|${entry.message?.role ?? ""}|${usageKey}`;
197
+ function entryIdentity(entry: SessionEntry): string {
198
+ const selected = usageForEntry(entry);
199
+ if (!selected) return "unsupported";
200
+ const usage = normalizedUsage(selected.usage);
201
+ return JSON.stringify([
202
+ entry.id ?? null,
203
+ entry.timestamp ?? null,
204
+ entry.type ?? null,
205
+ entry.message?.role ?? null,
206
+ selected.location,
207
+ usage.input,
208
+ usage.output,
209
+ usage.cacheRead,
210
+ usage.cacheWrite,
211
+ usage.cost,
212
+ ]);
143
213
  }
144
214
 
145
215
  function buildUsageFingerprint(entries: readonly SessionEntry[]): string {
146
- const first = entries[0];
147
- const last = entries[entries.length - 1];
148
- return `${entries.length}\0${entryIdentity(first)}\0${entryIdentity(last)}`;
216
+ return entries.map(entryIdentity).join("\0");
149
217
  }
150
218
 
151
219
  function computeUsageTotals(entries: readonly SessionEntry[]): UsageTotals {
152
- usageTotalsComputeCount += 1;
220
+ usageTotalsAggregationPassCount += 1;
153
221
  let input = 0;
154
222
  let output = 0;
155
223
  let cacheRead = 0;
@@ -158,18 +226,18 @@ function computeUsageTotals(entries: readonly SessionEntry[]): UsageTotals {
158
226
  let cost = 0;
159
227
 
160
228
  for (const entry of entries) {
161
- if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
162
- const usage = entry.message.usage;
163
- const entryInput = usage?.input ?? 0;
164
- const entryCacheRead = usage?.cacheRead ?? 0;
165
- const entryCacheWrite = usage?.cacheWrite ?? 0;
166
-
167
- input += entryInput;
168
- output += usage?.output ?? 0;
169
- cacheRead += entryCacheRead;
170
- cacheWrite += entryCacheWrite;
171
- cost += usage?.cost?.total ?? 0;
172
- latestCacheHitRate = calculateCacheHitRate(entryInput, entryCacheRead, entryCacheWrite);
229
+ const selected = usageForEntry(entry);
230
+ if (!selected) continue;
231
+ const usage = normalizedUsage(selected.usage);
232
+
233
+ input = addUsageTotal(input, usage.input);
234
+ output = addUsageTotal(output, usage.output);
235
+ cacheRead = addUsageTotal(cacheRead, usage.cacheRead);
236
+ cacheWrite = addUsageTotal(cacheWrite, usage.cacheWrite);
237
+ cost = addUsageTotal(cost, usage.cost);
238
+ if (selected.isAssistant) {
239
+ latestCacheHitRate = calculateCacheHitRate(usage.input, usage.cacheRead, usage.cacheWrite);
240
+ }
173
241
  }
174
242
 
175
243
  return Object.freeze({ input, output, cacheRead, cacheWrite, latestCacheHitRate, cost });
@@ -179,23 +247,26 @@ export function invalidateUsageTotalsCache(): void {
179
247
  usageTotalsCache = undefined;
180
248
  }
181
249
 
182
- /** Test helper: number of full usage scans performed since process start / last reset. */
183
- export function __usageTotalsComputeCount(): number {
184
- return usageTotalsComputeCount;
250
+ /** Test helper: counts aggregation passes only; every cache lookup still fingerprints all entries. */
251
+ export function __usageTotalsAggregationPassCount(): number {
252
+ return usageTotalsAggregationPassCount;
185
253
  }
186
254
 
187
- /** Test helper: reset memoization counters/cache. */
255
+ /** Test helper: reset the aggregation-pass counter and cached totals. */
188
256
  export function __resetUsageTotalsCacheForTests(): void {
189
257
  usageTotalsCache = undefined;
190
- usageTotalsComputeCount = 0;
258
+ usageTotalsAggregationPassCount = 0;
191
259
  }
192
260
 
193
261
  export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
194
262
  const sessionManager = ctx.sessionManager as {
195
- getEntries?: () => SessionEntry[];
196
- getBranch: () => SessionEntry[];
263
+ getEntries?: () => readonly SessionEntry[];
264
+ getBranch: () => readonly SessionEntry[];
197
265
  };
198
- const entries = sessionManager.getEntries?.() ?? sessionManager.getBranch();
266
+ const entries =
267
+ typeof sessionManager.getEntries === "function"
268
+ ? sessionManager.getEntries()
269
+ : sessionManager.getBranch();
199
270
  const key = buildUsageFingerprint(entries);
200
271
  if (usageTotalsCache?.key === key) return usageTotalsCache.totals;
201
272