@pi-archimedes/footer 2.10.3 → 2.11.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/README.md CHANGED
@@ -32,7 +32,7 @@ After installing Pi, choose one installation command above, then `cd` into your
32
32
  - **Token ledger** — input (↑) and output (↓) tokens, cache read/write, and the live accumulated dollar cost of the session.
33
33
  - **Context bar** — colour-coded (green → yellow → red). It shows **consumption**: how much of the context window is used, not how much headroom is left.
34
34
  - **Adaptive layout** — one compact line on wide viewports; it wraps to the lines it needs as the terminal narrows instead of truncating essential data.
35
- - **Subagent aggregation** — in the suite, subagent token and cost events flow over core's bus into the same accumulator, so worker spend and main-session spend read as one total.
35
+ - **Subagent aggregation** — in the suite, a subagent reports its tokens and cost on its own tool result, which this footer's session scan already totals, so worker spend and main-session spend read as one number. (It used to arrive a second way, over core's bus, which meant the same money was counted twice.)
36
36
 
37
37
  Two honest caveats: the ledger reflects usage and pricing **as reported to the session** — it's a live read of what Pi has accounted, not a guaranteed provider invoice, and not a tally of every background model call. And the context bar tracks consumption, not remaining headroom.
38
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/footer",
3
- "version": "2.10.3",
3
+ "version": "2.11.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.10.3"
22
+ "@pi-archimedes/core": "2.11.0"
23
23
  },
24
24
  "peerDependencies": {
25
- "@earendil-works/pi-coding-agent": ">=0.1.0",
26
- "@earendil-works/pi-tui": ">=0.1.0"
25
+ "@earendil-works/pi-coding-agent": ">=0.84.4",
26
+ "@earendil-works/pi-tui": ">=0.84.4"
27
27
  },
28
28
  "devDependencies": {
29
- "@earendil-works/pi-coding-agent": "^0.87.0",
30
- "@earendil-works/pi-tui": "^0.87.0",
29
+ "@earendil-works/pi-coding-agent": "1.2.0",
30
+ "@earendil-works/pi-tui": "1.2.0",
31
31
  "typescript": "^6.0.0"
32
32
  },
33
33
  "pi": {
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Where the footer's cost total comes from.
3
+ *
4
+ * The subagent used to smuggle a child's usage to the footer over the suite bus
5
+ * (`subagent/src/cost.ts` → `COST_UPDATE` → `CostAccumulator`), which the footer
6
+ * then added to its own read of the session entries. Once the tool result carries
7
+ * native `usage`, pi persists that usage onto the toolResult message and the
8
+ * footer's own entry scan (`utils/stats.ts`, `extractEntryUsage`) already counts
9
+ * it — so the accumulator stops being an addition and becomes a second copy of
10
+ * the same money. These tests are the proof of both halves.
11
+ */
12
+
13
+ import { describe, it, expect, vi, afterEach } from "vitest";
14
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
+ import { Events } from "@pi-archimedes/core/events";
16
+ import { formatTokenCount } from "./utils/format.js";
17
+
18
+ // Load the footer's module graph at COLLECT time, before any test starts: the
19
+ // first dynamic import used to happen inside the first test, charging it ~0.8s
20
+ // alone / 2.1s in a full run / 5.1s on a loaded machine for transforming and
21
+ // instantiating `index.ts` and its dependencies — a budget spent loading code,
22
+ // not testing, which shows up as "Test timed out in 5000ms" under CPU
23
+ // contention. Same reasoning as `src/index.test.ts`; collection has no test
24
+ // timeout, and `vi.resetModules()` still hands each test a fresh instance.
25
+ import "./index.js";
26
+
27
+ interface FooterInstance {
28
+ render(width: number): string[];
29
+ dispose(): void;
30
+ invalidate(): void;
31
+ }
32
+
33
+ type FooterFactory = (
34
+ tui: { requestRender: () => void },
35
+ theme: unknown,
36
+ footerData: unknown,
37
+ ) => FooterInstance;
38
+
39
+ const THEME = { fg: (_token: string, s: string) => s };
40
+
41
+ /** One subagent's worth of usage, big enough to survive two decimal places. */
42
+ const CHILD_USAGE = {
43
+ input: 4000,
44
+ output: 1500,
45
+ cacheRead: 0,
46
+ cacheWrite: 0,
47
+ totalTokens: 5500,
48
+ cost: { input: 0.1, output: 0.1, cacheRead: 0, cacheWrite: 0, total: 0.2 },
49
+ };
50
+
51
+ /** The entry pi persists for a tool result that returned `usage`. */
52
+ function toolResultEntry(id: string, usage: unknown) {
53
+ return { id, type: "message", message: { role: "toolResult", toolCallId: "t1", toolName: "subagent", usage } };
54
+ }
55
+
56
+ function assistantEntry(id: string) {
57
+ return {
58
+ id,
59
+ type: "message",
60
+ message: {
61
+ role: "assistant",
62
+ content: [],
63
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
64
+ },
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Footers built by the fixture while a test runs, handed back after it.
70
+ *
71
+ * Each test calls `shutdown()` itself, which releases the module-level
72
+ * subscription — but only if the test gets that far: a failed assertion skips
73
+ * it, and `session_shutdown` never touched the host's branch-change
74
+ * subscription anyway, which is the footer's `dispose()` alone. So the release
75
+ * belongs in a hook rather than in each test's happy path, mirroring
76
+ * `src/index.test.ts`.
77
+ */
78
+ let builtFooters: FooterInstance[] = [];
79
+
80
+ afterEach(() => {
81
+ for (const footer of builtFooters) footer.dispose();
82
+ builtFooters = [];
83
+ });
84
+
85
+ async function installFooter(
86
+ entries: unknown[],
87
+ ): Promise<{ rendered: (width?: number) => string[]; shutdown: () => void; bus: ReturnType<typeof fakeBus> }> {
88
+ vi.resetModules();
89
+ vi.doMock("./utils/git.js", () => ({
90
+ getGitStatus: () => ({ staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 }),
91
+ isInsideLinkedWorktree: () => false,
92
+ }));
93
+ vi.doMock("./utils/jj.js", () => ({ getJjBookmark: () => null, getJjChangedFiles: () => 0 }));
94
+ // Pin the footer's settings to the shipped defaults: `loadFooterConfig()` reads
95
+ // the developer's `settings.json`, and `expectRendered` below asserts on the
96
+ // branch the footer paints, so a saved `archimedes.footer.branchMaxLength` of
97
+ // 1–3 fails these tests for a footer that is working correctly. Same fixture
98
+ // decision as `src/index.test.ts`.
99
+ vi.doMock("./config.js", async (importOriginal) => {
100
+ const real = await importOriginal<typeof import("./config.js")>();
101
+ return { ...real, loadFooterConfig: () => real.DEFAULT_FOOTER_CONFIG };
102
+ });
103
+
104
+ const { registerFooter } = await import("./index.js");
105
+
106
+ const handlers: Record<string, (event: unknown, ctx: ExtensionContext) => void> = {};
107
+ // The handle the footer is handed. It no longer matters what is put on it —
108
+ // the footer subscribes to nothing — which is exactly what the cost tests below
109
+ // assert. A handle is passed anyway, because a real `ExtensionAPI` always has
110
+ // one and a test that omits it would not be shaped like production.
111
+ const bus = fakeBus();
112
+ const pi = {
113
+ on: (event: string, handler: (event: unknown, ctx: ExtensionContext) => void) => {
114
+ handlers[event] = handler;
115
+ return () => {};
116
+ },
117
+ getThinkingLevel: () => "off",
118
+ events: bus,
119
+ } as unknown as ExtensionAPI;
120
+
121
+ let factory: FooterFactory | undefined;
122
+ const ctx = {
123
+ hasUI: true,
124
+ model: undefined,
125
+ ui: {
126
+ setFooter: (f: FooterFactory) => {
127
+ factory = f;
128
+ },
129
+ notify: vi.fn(),
130
+ },
131
+ sessionManager: {
132
+ getEntries: () => entries as never,
133
+ getSessionId: () => "s",
134
+ getLeafId: () => null,
135
+ },
136
+ getContextUsage: () => undefined,
137
+ } as unknown as ExtensionContext;
138
+
139
+ registerFooter(pi);
140
+ handlers["session_start"]!({}, ctx);
141
+ if (!factory) throw new Error("footer factory was never installed");
142
+ const footer = factory({ requestRender: vi.fn() }, THEME, {
143
+ onBranchChange: () => vi.fn(),
144
+ getGitBranch: () => "main",
145
+ getExtensionStatuses: () => new Map<string, string>(),
146
+ });
147
+ builtFooters.push(footer);
148
+ return {
149
+ rendered: (width = 200) => footer.render(width),
150
+ shutdown: () => handlers["session_shutdown"]!({}, ctx),
151
+ bus,
152
+ };
153
+ }
154
+
155
+ /** The host event bus, stood in for (`pi.events`). */
156
+ function fakeBus() {
157
+ const listeners = new Map<string, Array<(data: unknown) => void>>();
158
+ return {
159
+ emit(channel: string, data: unknown): void {
160
+ for (const handler of [...(listeners.get(channel) ?? [])]) handler(data);
161
+ },
162
+ on(channel: string, handler: (data: unknown) => void): () => void {
163
+ const subs = listeners.get(channel) ?? [];
164
+ subs.push(handler);
165
+ listeners.set(channel, subs);
166
+ return () => {
167
+ const current = listeners.get(channel) ?? [];
168
+ const idx = current.indexOf(handler);
169
+ if (idx !== -1) current.splice(idx, 1);
170
+ };
171
+ },
172
+ };
173
+ }
174
+
175
+ /** The `$` figure the footer painted, or undefined when it painted none. */
176
+ function paintedCost(lines: string[]): string | undefined {
177
+ const match = lines.join(" ").match(/\$\d+\.\d{2}/);
178
+ return match?.[0];
179
+ }
180
+
181
+ /**
182
+ * Assert the footer actually painted a status bar.
183
+ *
184
+ * `render()` swallows a render failure and returns `[]`, so "there is no cost
185
+ * figure" is satisfied by a footer that vanished. Any test whose point is the
186
+ * ABSENCE of a figure has to prove presence of something else first, or it
187
+ * passes for a blank screen.
188
+ */
189
+ function expectRendered(lines: string[]): void {
190
+ const joined = lines.join(" ");
191
+ // `ctx.model` is undefined in these fixtures, so the model section reads
192
+ // "no-model"; the branch comes from the fake footerData.
193
+ expect(joined).toContain("no-model");
194
+ expect(joined).toContain("main");
195
+ }
196
+
197
+ describe("the footer counts a subagent's cost once", () => {
198
+ it("a toolResult entry carrying usage is already in the footer's total", async () => {
199
+ const { rendered, shutdown } = await installFooter([
200
+ assistantEntry("e1"),
201
+ toolResultEntry("e2", CHILD_USAGE),
202
+ ]);
203
+ const lines = rendered();
204
+ expect(paintedCost(lines)).toBe("$0.20");
205
+ expect(lines.join(" ")).toContain("↑" + formatTokenCount(CHILD_USAGE.input));
206
+ shutdown();
207
+ });
208
+
209
+ it("renders sensibly for a toolResult entry whose usage is null", async () => {
210
+ // Not hypothetical: pi 0.x persisted `usage: null` on every tool result, and
211
+ // those entries are still in old sessions on disk (one measured here:
212
+ // `{"role":"toolResult","toolName":"subagent","usage":null}`). A dereference
213
+ // of that field is a `NaN` in the cost line.
214
+ const { rendered, shutdown } = await installFooter([
215
+ assistantEntry("e1"),
216
+ toolResultEntry("e2", null),
217
+ ]);
218
+ const lines = rendered();
219
+ expectRendered(lines);
220
+ const cost = paintedCost(lines);
221
+ expect(cost === undefined || cost === "$0.00").toBe(true);
222
+ expect(lines.join(" ")).not.toContain("NaN");
223
+ shutdown();
224
+ });
225
+
226
+ it("paints no cost figure for a run that never reported one", async () => {
227
+ // The child that died before its first event, and every pre-spawn refusal,
228
+ // report zeros. `if (totalCost)` means the `$` section is simply absent — a
229
+ // child that cost nothing must not render as though it cost something, and
230
+ // must not push a bare `$0.00` into an otherwise clean line.
231
+ const zero = {
232
+ input: 0,
233
+ output: 0,
234
+ cacheRead: 0,
235
+ cacheWrite: 0,
236
+ totalTokens: 0,
237
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
238
+ };
239
+ const { rendered, shutdown } = await installFooter([
240
+ assistantEntry("e1"),
241
+ toolResultEntry("e2", zero),
242
+ ]);
243
+ const lines = rendered();
244
+ expectRendered(lines);
245
+ expect(paintedCost(lines)).toBeUndefined();
246
+ shutdown();
247
+ });
248
+
249
+ it("ignores COST_UPDATE traffic on the handle: there is no accumulator left to double-count", async () => {
250
+ // The route this replaced (`subagent/src/cost.ts` → `COST_UPDATE` →
251
+ // `CostAccumulator`) added the same usage a second time on top of the entry
252
+ // scan above — measured at `$0.40` / `↑7.8k` for one child before the
253
+ // removal. Anything still publishing on that channel (the bridge's own
254
+ // self-usage emitter does) must not reach the status bar twice.
255
+ const { rendered, shutdown, bus } = await installFooter([
256
+ assistantEntry("e1"),
257
+ toolResultEntry("e2", CHILD_USAGE),
258
+ ]);
259
+ bus.emit(Events.COST_UPDATE, {
260
+ source: "subagent:reviewer",
261
+ inputTokens: CHILD_USAGE.input,
262
+ outputTokens: CHILD_USAGE.output,
263
+ cost: CHILD_USAGE.cost.total,
264
+ });
265
+ const lines = rendered();
266
+ expect(paintedCost(lines)).toBe("$0.20");
267
+ expect(lines.join(" ")).toContain("↑" + formatTokenCount(CHILD_USAGE.input));
268
+ expect(lines.join(" ")).not.toContain("↑" + formatTokenCount(CHILD_USAGE.input * 2));
269
+ shutdown();
270
+ });
271
+
272
+ it("starts a fresh session with nothing carried over from the handle", async () => {
273
+ // No accumulator and nothing queued behind it: a footer installed after an
274
+ // earlier session published COST_UPDATE still reports only what the session
275
+ // entries say. (The retired bus made this a real risk — an unmatched emit
276
+ // sat in a process-wide queue and replayed into the next subscriber.)
277
+ const first = await installFooter([assistantEntry("e1")]);
278
+ first.bus.emit(Events.COST_UPDATE, { source: "main", inputTokens: 9999, cost: 9.99 });
279
+ first.shutdown();
280
+
281
+ const { rendered, shutdown } = await installFooter([assistantEntry("e1")]);
282
+ const lines = rendered();
283
+ expectRendered(lines);
284
+ expect(paintedCost(lines)).toBeUndefined();
285
+ expect(lines.join(" ")).not.toContain("$9.99");
286
+ shutdown();
287
+ });
288
+ });