@pi-archimedes/footer 2.7.3 → 2.9.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/footer",
3
- "version": "2.7.3",
3
+ "version": "2.9.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -19,15 +19,15 @@
19
19
  "./config": "./src/config.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@pi-archimedes/core": "2.7.3"
22
+ "@pi-archimedes/core": "2.9.0"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@earendil-works/pi-coding-agent": ">=0.1.0",
26
26
  "@earendil-works/pi-tui": ">=0.1.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@earendil-works/pi-coding-agent": "^0.85.1",
30
- "@earendil-works/pi-tui": "^0.85.1",
29
+ "@earendil-works/pi-coding-agent": "^0.87.0",
30
+ "@earendil-works/pi-tui": "^0.87.0",
31
31
  "typescript": "^6.0.0"
32
32
  },
33
33
  "pi": {
package/src/index.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
- * dir | branch [+status] | worktree | model | ◕thinking | ↑↓R W $cost ━ context%
2
+ * dir | branch [+status] | worktree | model | ◕thinking · ⚠ext-status | ↑↓R W $cost ━ context%
3
3
  *
4
4
  * Adaptive layout — wraps to additional lines instead of truncating:
5
5
  * - fits width → single line (system info · stats · context bar)
6
6
  * - doesn't fit → two lines (system info above, stats + bar below)
7
7
  * - left sections alone overflow → three or more lines
8
8
  * - below splitThreshold (default 150) → at least two lines, as configured
9
+ * - oversized extension statuses are word-wrapped into multiple chunks so no
10
+ * content is ever truncated by clampLine
9
11
  */
10
12
 
11
13
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -15,7 +17,7 @@ import { loadFooterConfig } from "./config.js";
15
17
  import { CostAccumulator } from "./cost-accumulator.js";
16
18
  import { getGitStatus, isInsideLinkedWorktree } from "./utils/git.js";
17
19
  import { getContextWindowInfo, getTokenUsageStats, type TokenUsageStats } from "./utils/stats.js";
18
- import { formatContextBar, formatGitStatusIndicators, formatThinkingIndicator, formatTokenCount } from "./utils/format.js";
20
+ import { formatContextBar, formatGitStatusIndicators, formatThinkingIndicator, formatTokenCount, wrapStatusToChunks } from "./utils/format.js";
19
21
  import { footerIcons } from "./utils/icons.js";
20
22
  import { packFooterLines, SEP_W, SEPARATOR } from "./utils/layout.js";
21
23
 
@@ -76,12 +78,18 @@ export function registerFooter(pi: ExtensionAPI): void {
76
78
 
77
79
  const branchIcon = inWorktree ? footerIcons.worktree : footerIcons.branch;
78
80
 
79
- // System info sections: dir | branch [+status] | model | thinking
81
+ // Extension status texts (pi setStatus) — may contain ANSI; oversized statuses wrap
82
+ const extensionStatusChunks = [...footerData.getExtensionStatuses().values()]
83
+ .filter(Boolean)
84
+ .flatMap((s) => wrapStatusToChunks(s, width));
85
+
86
+ // System info sections: dir | branch [+status] | model | thinking | ext-statuses
80
87
  const leftSections = [
81
88
  colorize("syntaxFunction", " " + footerIcons.directory + currentDirectory),
82
89
  currentBranch ? colorize("success", branchIcon + " " + currentBranch + (gitStatusStr ? " " + gitStatusStr : "")) : "",
83
90
  colorize("syntaxType", footerIcons.model + " " + activeModel),
84
91
  thinkingIndicatorStr,
92
+ ...extensionStatusChunks,
85
93
  ].filter(Boolean);
86
94
 
87
95
  // Usage stats: ↑in ↓out RcacheRead WcacheWrite $cost contextWindow
@@ -1,10 +1,12 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import { stripAnsi } from "@pi-archimedes/core/text";
3
4
  import {
4
5
  formatTokenCount,
5
6
  formatContextBar,
6
7
  formatGitStatusIndicators,
7
8
  formatThinkingIndicator,
9
+ wrapStatusToChunks,
8
10
  } from "./format.js";
9
11
  import { thinkingLevelColors, thinkingLevelIcons, type ColorFn } from "./icons.js";
10
12
 
@@ -161,3 +163,55 @@ describe("formatThinkingIndicator", () => {
161
163
  expect(formatThinkingIndicator("unknown", mockColor)).toBe("◑ unknown");
162
164
  });
163
165
  });
166
+
167
+ // ── wrapStatusToChunks ──────────────────────────────────────────────────────
168
+
169
+ describe("wrapStatusToChunks", () => {
170
+ it("short status fits → returned unchanged (including ANSI)", () => {
171
+ const status = "\u001b[33m⚠ token invalid\u001b[0m";
172
+ const chunks = wrapStatusToChunks(status, 80);
173
+ expect(chunks).toEqual([status]);
174
+ });
175
+
176
+ it("empty status → [status]", () => {
177
+ expect(wrapStatusToChunks("", 80)).toEqual([""]);
178
+ expect(wrapStatusToChunks(" ", 80)).toEqual([" "]);
179
+ });
180
+
181
+ it("oversized status wraps: no content loss, every chunk ≤ width", () => {
182
+ const colored = "\u001b[33m⚠\u001b[0m";
183
+ // Build a ~40-word status with an ANSI-colored prefix
184
+ const words = Array.from({ length: 40 }, (_, i) => `word${i}`);
185
+ const status = colored + " " + words.join(" ");
186
+ const width = 40;
187
+ const chunks = wrapStatusToChunks(status, width);
188
+ // Every chunk must fit in width
189
+ expect(chunks.every((c) => visibleWidth(c) <= width)).toBe(true);
190
+ // No content loss — normalise whitespace in stripped versions
191
+ const joined = chunks.map(stripAnsi).join(" ").replace(/\s+/g, " ").trim();
192
+ const original = stripAnsi(status).replace(/\s+/g, " ").trim();
193
+ expect(joined).toBe(original);
194
+ });
195
+
196
+ it("oversized single word (URL) splits without loss", () => {
197
+ const url = "https://" + "a".repeat(192);
198
+ const width = 30;
199
+ const chunks = wrapStatusToChunks(url, width);
200
+ // Concat of stripped chunks equals stripped original
201
+ expect(chunks.map(stripAnsi).join("")).toBe(stripAnsi(url));
202
+ // Every chunk fits
203
+ expect(chunks.every((c) => visibleWidth(c) <= width)).toBe(true);
204
+ });
205
+
206
+ it("word-wider-than-width preserves ANSI — concatenated raw string equals input", () => {
207
+ const longWord = "\u001b[31m" + "Z".repeat(60) + "\u001b[0m";
208
+ const width = 20;
209
+ const chunks = wrapStatusToChunks(longWord, width);
210
+ // Some chunk must contain the opening escape
211
+ expect(chunks.some((c) => c.includes("\u001b[31m"))).toBe(true);
212
+ // Concatenation of raw chunks (no spaces) equals the input
213
+ expect(chunks.join("")).toBe(longWord);
214
+ // No chunk exceeds width
215
+ expect(chunks.every((c) => visibleWidth(c) <= width)).toBe(true);
216
+ });
217
+ });
@@ -1,3 +1,4 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
1
2
  import { footerIcons, gitDisplayIcons, gitStatusColors, thinkingLevelColors, thinkingLevelIcons, type ColorFn } from "./icons.js";
2
3
 
3
4
  // Token counts use binary units (1K = 1024)
@@ -52,3 +53,126 @@ export function formatGitStatusIndicators(
52
53
  export function formatThinkingIndicator(thinkingLevel: string, colorize: ColorFn): string {
53
54
  return colorize(thinkingLevelColors[thinkingLevel] || "dim", `${thinkingLevelIcons[thinkingLevel] || "◑"} ${thinkingLevel}`);
54
55
  }
56
+
57
+ /**
58
+ * Wrap an extension status into chunks that fit the terminal.
59
+ * - fits (visibleWidth ≤ width) → [status] (unchanged, including ANSI)
60
+ * - oversized → greedy word-wrap: whole words packed per chunk; a single word
61
+ * wider than `width` is split at character level on the raw string (ANSI
62
+ * sequences stay attached to the chars they prefix because we split on plain
63
+ * whitespace only — never mid-escape).
64
+ * No content is ever dropped; every returned chunk has visibleWidth ≤ width.
65
+ */
66
+ export function wrapStatusToChunks(status: string, width: number): string[] {
67
+ // Edge cases: empty/whitespace or non-positive width → return as-is
68
+ if (width <= 0 || !status.trim()) return [status];
69
+ // Already fits → return unchanged (preserves ANSI)
70
+ if (visibleWidth(status) <= width) return [status];
71
+
72
+ // Split on whitespace boundaries only — never inside ANSI escapes
73
+ // (ANSI escapes contain no plain whitespace, so splitting on /\s+/ is safe)
74
+ const words = status.split(/(\s+)/);
75
+ // words alternates: [word, sep, word, sep, ...]
76
+ // We reassemble non-sep tokens and carry sep tokens to preserve spacing context
77
+ // Simpler: collect [token, isSep] pairs
78
+ const tokens: Array<{ text: string; isSep: boolean }> = [];
79
+ for (const w of words) {
80
+ if (w === "") continue;
81
+ tokens.push({ text: w, isSep: /^\s+$/.test(w) });
82
+ }
83
+
84
+ const chunks: string[] = [];
85
+ let current = "";
86
+ let currentW = 0;
87
+
88
+ /**
89
+ * Split a single word (no whitespace) that is wider than `width` into
90
+ * sub-chunks of at most `width` visible columns, character by character.
91
+ * ANSI escapes are kept intact (we only split printable characters;
92
+ * the regex /\x1b\[[^m]*m/ never contains whitespace, so they were
93
+ * never split by the outer word-split, and here we advance char by char
94
+ * through the raw string, which keeps escape bytes together with their
95
+ * surrounding characters as long as we don't split inside an escape).
96
+ *
97
+ * Implementation: iterate character-by-character through the raw string,
98
+ * accumulating into a sub-chunk until adding the next char would exceed width.
99
+ */
100
+ function splitWideWord(word: string): string[] {
101
+ const result: string[] = [];
102
+ let sub = "";
103
+ let subW = 0;
104
+ let i = 0;
105
+ while (i < word.length) {
106
+ // Detect ANSI escape sequence starting at i (\x1b[...m)
107
+ // An escape contributes 0 visible width, so we append it whole.
108
+ if (word[i] === "\x1b") {
109
+ const end = word.indexOf("m", i + 1);
110
+ if (end !== -1) {
111
+ sub += word.slice(i, end + 1);
112
+ i = end + 1;
113
+ continue;
114
+ }
115
+ }
116
+ // Regular character (may be multi-byte Unicode)
117
+ const char = word[i]!;
118
+ const charW = visibleWidth(char);
119
+ if (subW + charW > width && sub !== "") {
120
+ result.push(sub);
121
+ sub = char;
122
+ subW = charW;
123
+ } else {
124
+ sub += char;
125
+ subW += charW;
126
+ }
127
+ i++;
128
+ }
129
+ if (sub !== "") result.push(sub);
130
+ return result.length > 0 ? result : [word];
131
+ }
132
+
133
+ function flushCurrent() {
134
+ if (current !== "") {
135
+ chunks.push(current);
136
+ current = "";
137
+ currentW = 0;
138
+ }
139
+ }
140
+
141
+ for (const token of tokens) {
142
+ if (token.isSep) {
143
+ // Separator — skip (we rebuild spacing implicitly via chunks)
144
+ continue;
145
+ }
146
+ const word = token.text;
147
+ const wordW = visibleWidth(word);
148
+
149
+ if (wordW > width) {
150
+ // Word is wider than the entire terminal — flush then char-split it
151
+ flushCurrent();
152
+ const subChunks = splitWideWord(word);
153
+ for (let j = 0; j < subChunks.length - 1; j++) {
154
+ chunks.push(subChunks[j]!);
155
+ }
156
+ // Last sub-chunk becomes the start of the next current
157
+ const last = subChunks[subChunks.length - 1]!;
158
+ current = last;
159
+ currentW = visibleWidth(last);
160
+ } else if (currentW === 0) {
161
+ // Starting a new chunk
162
+ current = word;
163
+ currentW = wordW;
164
+ } else if (currentW + 1 + wordW <= width) {
165
+ // Append with a single space
166
+ current += " " + word;
167
+ currentW += 1 + wordW;
168
+ } else {
169
+ // Doesn't fit — flush and start new chunk
170
+ flushCurrent();
171
+ current = word;
172
+ currentW = wordW;
173
+ }
174
+ }
175
+ flushCurrent();
176
+
177
+ return chunks.length > 0 ? chunks : [status];
178
+ }
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect } from "vitest";
2
2
  import { stripAnsi } from "@pi-archimedes/core/text";
3
3
  import { packFooterLines, SEP_W, SEPARATOR } from "./layout.js";
4
- import { formatContextBar } from "./format.js";
4
+ import { formatContextBar, wrapStatusToChunks } from "./format.js";
5
5
  import { visibleWidth } from "@earendil-works/pi-tui";
6
6
 
7
7
  // Minimal stand-ins: same visible chars as the real footer, no ANSI
@@ -14,13 +14,14 @@ const MODEL = "🧠 claude-sonnet-4-6";
14
14
  const THINKING = "◕ high"; // current format for the 'high' level
15
15
  const STATS = "↑141 ↓42k R8.7M W220k $4.25 116k/977k";
16
16
 
17
- function buildLines(width: number, pct: number, splitThreshold = 150) {
17
+ function buildLines(width: number, pct: number, splitThreshold = 150, extraSections: string[] = []) {
18
18
  const leftSections = [
19
19
  DIR,
20
20
  BRANCH,
21
21
  WORKTREE,
22
22
  MODEL,
23
23
  THINKING,
24
+ ...extraSections,
24
25
  ].filter(Boolean);
25
26
  let groups = packFooterLines([...leftSections, STATS], width, SEP_W);
26
27
  if (groups.length < 2 && width < splitThreshold && leftSections.length > 0) {
@@ -84,4 +85,35 @@ describe("render glue simulation", () => {
84
85
  // No line exceeds 40 visible columns
85
86
  expect(lines.every((l) => visibleWidth(stripAnsi(l)) <= 40)).toBe(true);
86
87
  });
88
+
89
+ it("extension status chunk wraps without truncation on the user's terminal", () => {
90
+ const STATUS = "⚠ litellm token invalid — re-auth required";
91
+ const lines = buildLines(158, 12, 150, [STATUS]);
92
+ const stripped = lines.map((l) => stripAnsi(l));
93
+ const all = stripped.join(" ");
94
+ // (a) status text is fully present across joined lines (no clipping)
95
+ expect(all).toContain(STATUS);
96
+ // (b) no line exceeds 158 visible columns
97
+ expect(lines.every((l) => visibleWidth(stripAnsi(l)) <= 158)).toBe(true);
98
+ });
99
+
100
+ it("oversized extension status wraps through the production path without truncation", () => {
101
+ const LONG =
102
+ "⚠ litellm token invalid — re-auth required: " +
103
+ "gcloud auth application-default login (see https://support.google.com/a/answer/9368756 for details) — check Settings → Auth for more context " +
104
+ "and verify your credentials are up-to-date before retrying the long operation that failed";
105
+ // Verify it is >158 visible chars
106
+ expect(visibleWidth(LONG)).toBeGreaterThan(158);
107
+ const chunks = wrapStatusToChunks(LONG, 158);
108
+ const lines = buildLines(158, 12, 150, chunks);
109
+ const stripped = lines.map((l) => stripAnsi(l));
110
+ // (a) every word of the original status appears in the joined lines
111
+ const allText = stripped.join(" ");
112
+ const words = stripAnsi(LONG).split(/\s+/).filter(Boolean);
113
+ for (const word of words) {
114
+ expect(allText).toContain(word);
115
+ }
116
+ // (b) every line ≤ 158 visible cols
117
+ expect(lines.every((l) => visibleWidth(stripAnsi(l)) <= 158)).toBe(true);
118
+ });
87
119
  });
@@ -21,9 +21,16 @@ interface UserMessage {
21
21
  content: string;
22
22
  }
23
23
 
24
+ interface ToolResultMessage {
25
+ role: "toolResult";
26
+ usage?: MessageUsage | undefined;
27
+ }
28
+
24
29
  interface SessionEntry {
25
- type: "message";
26
- message: AssistantMessage | UserMessage;
30
+ id: string;
31
+ type: "message" | "usage" | "compaction" | "branch_summary";
32
+ message?: AssistantMessage | UserMessage | ToolResultMessage | undefined;
33
+ usage?: MessageUsage | undefined;
27
34
  }
28
35
 
29
36
  interface MockContext {
@@ -34,20 +41,48 @@ interface MockContext {
34
41
  model?: { contextWindow?: number };
35
42
  }
36
43
 
37
- function makeAssistantEntry(usage: MessageUsage): SessionEntry {
44
+ let idCounter = 1;
45
+
46
+ function makeAssistantEntry(idOrUsage: string | MessageUsage, maybeUsage?: MessageUsage): SessionEntry {
47
+ const id = typeof idOrUsage === "string" ? idOrUsage : `asst-${idCounter++}`;
48
+ const usage = typeof idOrUsage === "string" ? maybeUsage! : idOrUsage;
38
49
  return {
50
+ id,
39
51
  type: "message",
40
52
  message: { role: "assistant", usage },
41
53
  };
42
54
  }
43
55
 
44
- function makeUserEntry(): SessionEntry {
56
+ function makeUserEntry(id = `user-${idCounter++}`): SessionEntry {
45
57
  return {
58
+ id,
46
59
  type: "message",
47
60
  message: { role: "user", content: "hello" },
48
61
  };
49
62
  }
50
63
 
64
+ function makeUsageEntry(id: string, usage: MessageUsage): SessionEntry {
65
+ return {
66
+ id,
67
+ type: "usage",
68
+ usage,
69
+ };
70
+ }
71
+
72
+ function makeToolResultEntry(id: string, usage?: MessageUsage): SessionEntry {
73
+ return {
74
+ id,
75
+ type: "message",
76
+ message: usage !== undefined ? { role: "toolResult", usage } : { role: "toolResult" },
77
+ };
78
+ }
79
+
80
+ function makeCompactionEntry(id: string, usage?: MessageUsage): SessionEntry {
81
+ return usage !== undefined
82
+ ? { id, type: "compaction", usage }
83
+ : { id, type: "compaction" };
84
+ }
85
+
51
86
  function makeCtx(entries: SessionEntry[], contextUsage?: { contextWindow?: number; percent?: number }, modelContextWindow?: number): any {
52
87
  return {
53
88
  sessionManager: { getEntries: () => entries },
@@ -61,12 +96,14 @@ function makeCtx(entries: SessionEntry[], contextUsage?: { contextWindow?: numbe
61
96
  describe("getTokenUsageStats", () => {
62
97
  let getTokenUsageStats: typeof import("./stats.js").getTokenUsageStats;
63
98
  let invalidateStatsCache: typeof import("./stats.js").invalidateStatsCache;
99
+ let resetStatsState: typeof import("./stats.js").resetStatsState;
64
100
 
65
101
  async function loadModule() {
66
102
  vi.resetModules();
67
103
  const mod = await import("./stats.js");
68
104
  getTokenUsageStats = mod.getTokenUsageStats;
69
105
  invalidateStatsCache = mod.invalidateStatsCache;
106
+ resetStatsState = mod.resetStatsState;
70
107
  }
71
108
 
72
109
  it("empty entries returns zero stats", async () => {
@@ -124,6 +161,135 @@ describe("getTokenUsageStats", () => {
124
161
  expect(result.totalOutput).toBe(50);
125
162
  });
126
163
 
164
+ it("accumulates tokens and cost from UsageEntry (cache warming)", async () => {
165
+ await loadModule();
166
+ const ctx = makeCtx([
167
+ makeAssistantEntry("a1", { input: 100, output: 50, cacheRead: 10, cacheWrite: 5, cost: { total: 0.01 } }),
168
+ makeUsageEntry("u1", { input: 0, output: 0, cacheRead: 500, cacheWrite: 200, cost: { total: 0.005 } }),
169
+ ]);
170
+ const result = getTokenUsageStats(ctx);
171
+ expect(result).toEqual({
172
+ totalInput: 100,
173
+ totalOutput: 50,
174
+ totalCacheRead: 510,
175
+ totalCacheWrite: 205,
176
+ totalCost: 0.015,
177
+ });
178
+ });
179
+
180
+ it("accumulates usage from ToolResultMessage", async () => {
181
+ await loadModule();
182
+ const ctx = makeCtx([
183
+ makeAssistantEntry("a1", { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }),
184
+ makeToolResultEntry("t1", { input: 200, output: 80, cacheRead: 50, cacheWrite: 20, cost: { total: 0.02 } }),
185
+ makeToolResultEntry("t2"), // toolResult without usage ignored
186
+ ]);
187
+ const result = getTokenUsageStats(ctx);
188
+ expect(result).toEqual({
189
+ totalInput: 300,
190
+ totalOutput: 130,
191
+ totalCacheRead: 50,
192
+ totalCacheWrite: 20,
193
+ totalCost: 0.03,
194
+ });
195
+ });
196
+
197
+ it("accumulates usage from CompactionEntry and branch_summary", async () => {
198
+ await loadModule();
199
+ const ctx = makeCtx([
200
+ makeCompactionEntry("c1", { input: 1000, output: 200, cacheRead: 100, cacheWrite: 50, cost: { total: 0.04 } }),
201
+ {
202
+ id: "bs1",
203
+ type: "branch_summary",
204
+ usage: { input: 500, output: 100, cacheRead: 50, cacheWrite: 25, cost: { total: 0.02 } },
205
+ } as SessionEntry,
206
+ ]);
207
+ const result = getTokenUsageStats(ctx);
208
+ expect(result).toEqual({
209
+ totalInput: 1500,
210
+ totalOutput: 300,
211
+ totalCacheRead: 150,
212
+ totalCacheWrite: 75,
213
+ totalCost: 0.06,
214
+ });
215
+ });
216
+
217
+ it("detects branch switch: changing entry IDs at anchor position triggers clean re-scan", async () => {
218
+ await loadModule();
219
+ // Branch 1: entries [a1, a2]
220
+ const branch1 = [
221
+ makeAssistantEntry("a1", { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }),
222
+ makeAssistantEntry("a2", { input: 200, output: 100, cacheRead: 0, cacheWrite: 0, cost: { total: 0.02 } }),
223
+ ];
224
+ let ctx = makeCtx(branch1);
225
+ let result = getTokenUsageStats(ctx);
226
+ expect(result.totalInput).toBe(300);
227
+ expect(result.totalOutput).toBe(150);
228
+
229
+ // Branch switch: second entry is different id/usage (a2_alt instead of a2)
230
+ const branch2: SessionEntry[] = [
231
+ branch1[0]!,
232
+ makeAssistantEntry("a2_alt", { input: 50, output: 25, cacheRead: 0, cacheWrite: 0, cost: { total: 0.005 } }),
233
+ ];
234
+ ctx = makeCtx(branch2);
235
+ result = getTokenUsageStats(ctx);
236
+ // Should NOT be 350 (accumulated on old running total); must re-scan branch2 cleanly
237
+ expect(result).toEqual({
238
+ totalInput: 150,
239
+ totalOutput: 75,
240
+ totalCacheRead: 0,
241
+ totalCacheWrite: 0,
242
+ totalCost: 0.015,
243
+ });
244
+ });
245
+
246
+ it("detects branch switch: changing first entry ID triggers clean re-scan even if tail anchor matches", async () => {
247
+ await loadModule();
248
+ // Branch 1: entries [a1, a2]
249
+ const branch1 = [
250
+ makeAssistantEntry("a1", { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }),
251
+ makeAssistantEntry("a2", { input: 200, output: 100, cacheRead: 0, cacheWrite: 0, cost: { total: 0.02 } }),
252
+ ];
253
+ let ctx = makeCtx(branch1);
254
+ let result = getTokenUsageStats(ctx);
255
+ expect(result.totalInput).toBe(300);
256
+
257
+ // Branch switch where tail ID 'a2' happens to match, but head entry is 'a1_replaced'
258
+ const branch2: SessionEntry[] = [
259
+ makeAssistantEntry("a1_replaced", { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: { total: 0.001 } }),
260
+ branch1[1]!,
261
+ ];
262
+ ctx = makeCtx(branch2);
263
+ result = getTokenUsageStats(ctx);
264
+ expect(result).toEqual({
265
+ totalInput: 210,
266
+ totalOutput: 105,
267
+ totalCacheRead: 0,
268
+ totalCacheWrite: 0,
269
+ totalCost: 0.021,
270
+ });
271
+ });
272
+
273
+ it("updates total when tail entry usage mutates in-place with unchanged entry count", async () => {
274
+ await loadModule();
275
+ const tailEntry = makeAssistantEntry("a1", { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } });
276
+ const entries = [tailEntry];
277
+ const ctx = makeCtx(entries);
278
+
279
+ const initial = getTokenUsageStats(ctx);
280
+ expect(initial.totalOutput).toBe(50);
281
+ expect(initial.totalCost).toBe(0.01);
282
+
283
+ // Direct in-place property mutation of existing usage object (aliasing test)
284
+ const usageObj = (tailEntry.message as AssistantMessage).usage;
285
+ usageObj.output = 120;
286
+ usageObj.cost = { total: 0.025 };
287
+
288
+ const updated = getTokenUsageStats(ctx);
289
+ expect(updated.totalOutput).toBe(120);
290
+ expect(updated.totalCost).toBe(0.025);
291
+ });
292
+
127
293
  it("cache returns same result within TTL", async () => {
128
294
  await loadModule();
129
295
  const ctx = makeCtx([
@@ -146,6 +312,19 @@ describe("getTokenUsageStats", () => {
146
312
  expect(first).not.toBe(second); // different reference (cache cleared)
147
313
  });
148
314
 
315
+ it("resetStatsState clears module state completely", async () => {
316
+ await loadModule();
317
+ const ctx = makeCtx([
318
+ makeAssistantEntry("a1", { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: { total: 0.01 } }),
319
+ ]);
320
+ const first = getTokenUsageStats(ctx);
321
+ expect(first.totalInput).toBe(100);
322
+ resetStatsState();
323
+ const second = getTokenUsageStats(ctx);
324
+ expect(second).toEqual(first);
325
+ expect(second).not.toBe(first);
326
+ });
327
+
149
328
  it("property: stats are monotonic (adding entries never decreases totals)", async () => {
150
329
  await loadModule();
151
330
  fc.assert(
@@ -1,15 +1,11 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
- interface MessageUsage {
4
- input: number;
5
- output: number;
6
- cacheRead: number;
7
- cacheWrite: number;
8
- cost: { total: number };
9
- }
10
-
11
- interface AssistantMessage {
12
- usage: MessageUsage;
3
+ export interface MessageUsage {
4
+ input?: number;
5
+ output?: number;
6
+ cacheRead?: number;
7
+ cacheWrite?: number;
8
+ cost?: { total?: number };
13
9
  }
14
10
 
15
11
  export interface TokenUsageStats {
@@ -20,84 +16,183 @@ export interface TokenUsageStats {
20
16
  totalCost: number;
21
17
  }
22
18
 
23
- // Cache TTL: 500ms — stats don't change more frequently than message_end events
24
- const STATS_CACHE_TTL_MS = 500;
19
+ export function extractEntryUsage(sessionEntry: any): MessageUsage | null {
20
+ if (sessionEntry?.type === "usage") {
21
+ return sessionEntry.usage ?? null;
22
+ }
23
+ if (sessionEntry?.type === "message" && sessionEntry.message?.role === "assistant") {
24
+ return sessionEntry.message.usage ?? null;
25
+ }
26
+ if (sessionEntry?.type === "message" && sessionEntry.message?.role === "toolResult" && sessionEntry.message.usage) {
27
+ return sessionEntry.message.usage;
28
+ }
29
+ if ((sessionEntry?.type === "compaction" || sessionEntry?.type === "branch_summary") && sessionEntry.usage) {
30
+ return sessionEntry.usage;
31
+ }
32
+ return null;
33
+ }
25
34
 
26
- interface StatsCacheEntry {
27
- value: TokenUsageStats;
28
- timestamp: number;
29
- entryCount: number;
35
+ function snapshotUsage(usage: MessageUsage | null | undefined): MessageUsage | undefined {
36
+ if (!usage) return undefined;
37
+ return {
38
+ input: usage.input ?? 0,
39
+ output: usage.output ?? 0,
40
+ cacheRead: usage.cacheRead ?? 0,
41
+ cacheWrite: usage.cacheWrite ?? 0,
42
+ cost: { total: usage.cost?.total ?? 0 },
43
+ };
30
44
  }
31
45
 
32
- let statsCache: StatsCacheEntry | undefined;
46
+ function usageEquals(a: MessageUsage | undefined, b: MessageUsage | undefined): boolean {
47
+ if (!a && !b) return true;
48
+ if (!a || !b) return false;
49
+ return (
50
+ (a.input ?? 0) === (b.input ?? 0) &&
51
+ (a.output ?? 0) === (b.output ?? 0) &&
52
+ (a.cacheRead ?? 0) === (b.cacheRead ?? 0) &&
53
+ (a.cacheWrite ?? 0) === (b.cacheWrite ?? 0) &&
54
+ (a.cost?.total ?? 0) === (b.cost?.total ?? 0)
55
+ );
56
+ }
33
57
 
34
58
  // Running total from initial scan — avoids re-scanning old entries
35
59
  let runningTotal: TokenUsageStats | undefined;
36
60
  let runningTotalEntryCount = 0;
61
+ let lastFirstEntryId: string | undefined;
62
+ let lastAnchorEntryId: string | undefined;
63
+ let lastTailUsage: MessageUsage | undefined;
64
+ let statsCache: TokenUsageStats | undefined;
37
65
 
38
66
  export function getTokenUsageStats(ctx: ExtensionContext): TokenUsageStats {
39
67
  const entries = ctx.sessionManager.getEntries();
40
68
 
41
- // Return cached result if entry count hasn't changed and cache is fresh
42
- if (
43
- statsCache &&
44
- statsCache.entryCount === entries.length &&
45
- Date.now() - statsCache.timestamp < STATS_CACHE_TTL_MS
46
- ) {
47
- return statsCache.value;
69
+ if (entries.length === 0) {
70
+ resetStatsState();
71
+ return {
72
+ totalInput: 0,
73
+ totalOutput: 0,
74
+ totalCacheRead: 0,
75
+ totalCacheWrite: 0,
76
+ totalCost: 0,
77
+ };
78
+ }
79
+
80
+ const anchorMatch =
81
+ runningTotal !== undefined &&
82
+ runningTotalEntryCount > 0 &&
83
+ entries[0]?.id === lastFirstEntryId &&
84
+ entries[runningTotalEntryCount - 1]?.id === lastAnchorEntryId;
85
+
86
+ if (anchorMatch && runningTotal) {
87
+ if (entries.length === runningTotalEntryCount) {
88
+ // Invariant: for unchanged entry count, only the tail entry's usage mutates during streaming.
89
+ const currentTailUsage = extractEntryUsage(entries[entries.length - 1]);
90
+ if (!usageEquals(currentTailUsage ?? undefined, lastTailUsage)) {
91
+ const pIn = lastTailUsage?.input ?? 0;
92
+ const pOut = lastTailUsage?.output ?? 0;
93
+ const pCr = lastTailUsage?.cacheRead ?? 0;
94
+ const pCw = lastTailUsage?.cacheWrite ?? 0;
95
+ const pCost = lastTailUsage?.cost?.total ?? 0;
96
+
97
+ const cIn = currentTailUsage?.input ?? 0;
98
+ const cOut = currentTailUsage?.output ?? 0;
99
+ const cCr = currentTailUsage?.cacheRead ?? 0;
100
+ const cCw = currentTailUsage?.cacheWrite ?? 0;
101
+ const cCost = currentTailUsage?.cost?.total ?? 0;
102
+
103
+ runningTotal = {
104
+ totalInput: runningTotal.totalInput + (cIn - pIn),
105
+ totalOutput: runningTotal.totalOutput + (cOut - pOut),
106
+ totalCacheRead: runningTotal.totalCacheRead + (cCr - pCr),
107
+ totalCacheWrite: runningTotal.totalCacheWrite + (cCw - pCw),
108
+ totalCost: runningTotal.totalCost + (cCost - pCost),
109
+ };
110
+ lastTailUsage = snapshotUsage(currentTailUsage);
111
+ statsCache = runningTotal;
112
+ return runningTotal;
113
+ }
114
+
115
+ if (statsCache !== undefined) {
116
+ return statsCache;
117
+ }
118
+ statsCache = { ...runningTotal };
119
+ return statsCache;
120
+ }
121
+
122
+ if (entries.length > runningTotalEntryCount) {
123
+ let totalInput = runningTotal.totalInput;
124
+ let totalOutput = runningTotal.totalOutput;
125
+ let totalCacheRead = runningTotal.totalCacheRead;
126
+ let totalCacheWrite = runningTotal.totalCacheWrite;
127
+ let totalCost = runningTotal.totalCost;
128
+
129
+ const prevTailUsage = extractEntryUsage(entries[runningTotalEntryCount - 1]);
130
+ if (!usageEquals(prevTailUsage ?? undefined, lastTailUsage)) {
131
+ const pIn = lastTailUsage?.input ?? 0;
132
+ const pOut = lastTailUsage?.output ?? 0;
133
+ const pCr = lastTailUsage?.cacheRead ?? 0;
134
+ const pCw = lastTailUsage?.cacheWrite ?? 0;
135
+ const pCost = lastTailUsage?.cost?.total ?? 0;
136
+
137
+ const cIn = prevTailUsage?.input ?? 0;
138
+ const cOut = prevTailUsage?.output ?? 0;
139
+ const cCr = prevTailUsage?.cacheRead ?? 0;
140
+ const cCw = prevTailUsage?.cacheWrite ?? 0;
141
+ const cCost = prevTailUsage?.cost?.total ?? 0;
142
+
143
+ totalInput += cIn - pIn;
144
+ totalOutput += cOut - pOut;
145
+ totalCacheRead += cCr - pCr;
146
+ totalCacheWrite += cCw - pCw;
147
+ totalCost += cCost - pCost;
148
+ }
149
+
150
+ for (let i = runningTotalEntryCount; i < entries.length; i++) {
151
+ const u = extractEntryUsage(entries[i]);
152
+ if (u) {
153
+ totalInput += u.input ?? 0;
154
+ totalOutput += u.output ?? 0;
155
+ totalCacheRead += u.cacheRead ?? 0;
156
+ totalCacheWrite += u.cacheWrite ?? 0;
157
+ totalCost += u.cost?.total ?? 0;
158
+ }
159
+ }
160
+
161
+ runningTotal = { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
162
+ runningTotalEntryCount = entries.length;
163
+ lastFirstEntryId = entries[0]?.id;
164
+ lastAnchorEntryId = entries[entries.length - 1]?.id;
165
+ lastTailUsage = snapshotUsage(extractEntryUsage(entries[entries.length - 1]));
166
+ statsCache = runningTotal;
167
+ return runningTotal;
168
+ }
48
169
  }
49
170
 
171
+ // Else (first run, entries truncated, or branch switch where anchor id mismatched):
50
172
  let totalInput = 0,
51
173
  totalOutput = 0,
52
174
  totalCacheRead = 0,
53
175
  totalCacheWrite = 0,
54
176
  totalCost = 0;
55
177
 
56
- // If we have a running total, only scan new entries
57
- const hasRunningTotal = runningTotal !== undefined && runningTotalEntryCount < entries.length;
58
- const startIdx = hasRunningTotal ? runningTotalEntryCount : 0;
59
-
60
- if (startIdx === 0) {
61
- // Full scan — no running total yet
62
- for (const sessionEntry of entries) {
63
- if (sessionEntry?.type === "message" && sessionEntry.message?.role === "assistant") {
64
- const assistantMessage = sessionEntry.message as AssistantMessage;
65
- totalInput += assistantMessage.usage.input;
66
- totalOutput += assistantMessage.usage.output;
67
- totalCacheRead += assistantMessage.usage.cacheRead;
68
- totalCacheWrite += assistantMessage.usage.cacheWrite;
69
- totalCost += assistantMessage.usage.cost.total;
70
- }
71
- }
72
- runningTotal = { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
73
- runningTotalEntryCount = entries.length;
74
- } else {
75
- // Incremental: start from running total + scan new entries
76
- const rt = runningTotal!; // Safe: hasRunningTotal guard above
77
- totalInput = rt.totalInput;
78
- totalOutput = rt.totalOutput;
79
- totalCacheRead = rt.totalCacheRead;
80
- totalCacheWrite = rt.totalCacheWrite;
81
- totalCost = rt.totalCost;
82
-
83
- for (let i = startIdx; i < entries.length; i++) {
84
- const sessionEntry = entries[i];
85
- if (sessionEntry?.type === "message" && sessionEntry.message?.role === "assistant") {
86
- const assistantMessage = sessionEntry.message as AssistantMessage;
87
- totalInput += assistantMessage.usage.input;
88
- totalOutput += assistantMessage.usage.output;
89
- totalCacheRead += assistantMessage.usage.cacheRead;
90
- totalCacheWrite += assistantMessage.usage.cacheWrite;
91
- totalCost += assistantMessage.usage.cost.total;
92
- }
178
+ for (const sessionEntry of entries) {
179
+ const u = extractEntryUsage(sessionEntry);
180
+ if (u) {
181
+ totalInput += u.input ?? 0;
182
+ totalOutput += u.output ?? 0;
183
+ totalCacheRead += u.cacheRead ?? 0;
184
+ totalCacheWrite += u.cacheWrite ?? 0;
185
+ totalCost += u.cost?.total ?? 0;
93
186
  }
94
- runningTotalEntryCount = entries.length;
95
187
  }
96
188
 
97
- const result: TokenUsageStats = { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
98
- runningTotal = result;
99
- statsCache = { value: result, timestamp: Date.now(), entryCount: entries.length };
100
- return result;
189
+ runningTotal = { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
190
+ runningTotalEntryCount = entries.length;
191
+ lastFirstEntryId = entries[0]?.id;
192
+ lastAnchorEntryId = entries[entries.length - 1]?.id;
193
+ lastTailUsage = snapshotUsage(extractEntryUsage(entries[entries.length - 1]));
194
+ statsCache = runningTotal;
195
+ return runningTotal;
101
196
  }
102
197
 
103
198
  /** Clear the stats cache — call when a new message arrives. */
@@ -105,6 +200,16 @@ export function invalidateStatsCache(): void {
105
200
  statsCache = undefined;
106
201
  }
107
202
 
203
+ /** Reset all module-level stats state for clean isolation. */
204
+ export function resetStatsState(): void {
205
+ runningTotal = undefined;
206
+ runningTotalEntryCount = 0;
207
+ lastFirstEntryId = undefined;
208
+ lastAnchorEntryId = undefined;
209
+ lastTailUsage = undefined;
210
+ statsCache = undefined;
211
+ }
212
+
108
213
  export interface ContextWindowInfo {
109
214
  percent: string;
110
215
  percentValue: number;