@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 +1 -1
- package/package.json +6 -6
- package/src/cost-source.test.ts +288 -0
- package/src/index.test.ts +380 -0
- package/src/index.ts +76 -37
- package/src/utils/git.test.ts +131 -2
- package/src/utils/git.ts +78 -43
- package/src/utils/jj.test.ts +68 -2
- package/src/utils/jj.ts +40 -13
- package/src/utils/stats.test.ts +35 -1
- package/src/utils/stats.ts +21 -1
- package/src/utils/vcs-change-events.test.ts +34 -0
- package/src/utils/vcs-change-events.ts +38 -0
- package/src/cost-accumulator.test.ts +0 -85
- package/src/cost-accumulator.ts +0 -35
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
|
|
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.
|
|
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.
|
|
22
|
+
"@pi-archimedes/core": "2.11.0"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
26
|
-
"@earendil-works/pi-tui": ">=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": "
|
|
30
|
-
"@earendil-works/pi-tui": "
|
|
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
|
+
});
|